Skip to content

Commit

Permalink
feat(redirects): download-redirects; docs on routing
Browse files Browse the repository at this point in the history
Resolves #64
  • Loading branch information
jbmoelker committed Jan 20, 2024
1 parent 5c9e3d5 commit 542af7c
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 1 deletion.
128 changes: 128 additions & 0 deletions config/datocms/migrations/1705751291_redirectRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { Client } from '@datocms/cli/lib/cma-client-node';

export default async function (client: Client) {
console.log('Create new models/block models');

console.log('Create model "\u21AA\uFE0F Redirect Rule" (`redirect_rule`)');
await client.itemTypes.create(
{
// @ts-expect-error next-line DatoCMS auto-generated
id: 'c0S4sIyiRK-EewRhFEFPLA',
name: '\u21AA\uFE0F Redirect Rule',
sortable: true,
api_key: 'redirect_rule',
collection_appearance: 'table',
inverse_relationships_enabled: false,
},
{ skip_menu_item_creation: true }
);

console.log('Creating new fields/fieldsets');

console.log(
'Create Single-line string field "From URL" (`from`) in model "\u21AA\uFE0F Redirect Rule" (`redirect_rule`)'
);
await client.fields.create('c0S4sIyiRK-EewRhFEFPLA', {
// @ts-expect-error next-line DatoCMS auto-generated
id: 'L0DTI0v1Qeqoj1kkAoqEvA',
label: 'From URL',
field_type: 'string',
api_key: 'from',
hint: 'URL or URL pattern to redirect from when visited. <br><br>Pattern can contain wildcards (<code>*</code>) and placeholders (<code>:placeholder_name</code>) which can then be used in the <strong>To URL</strong> field below. Examples (related to <strong>To URL</strong> examples below):<br>\n\u2022 <code>/old-page-slug/</code><br>\n\u2022 <code>/en/catalogue/:code/details/:name</code><br>\n\u2022 <code>/archive/*</code><br><br>\nSee <a href="https://developers.cloudflare.com/pages/configuration/redirects/">Cloudflare documentation on redirects</a> for more info.',
validators: { required: {} },
appearance: {
addons: [],
editor: 'single_line',
parameters: { heading: false },
},
default_value: '',
});

console.log(
'Create Single-line string field "To URL" (`to`) in model "\u21AA\uFE0F Redirect Rule" (`redirect_rule`)'
);
await client.fields.create('c0S4sIyiRK-EewRhFEFPLA', {
// @ts-expect-error next-line DatoCMS auto-generated
id: 'S9_kd7NPQ3-2jM1TfUq1Pw',
label: 'To URL',
field_type: 'string',
api_key: 'to',
hint: 'URL or URL pattern to redirect a visitor to.<br><br>Pattern can contain any placeholders (<code>:placeholder_name</code>) and a splat (<code>:splat</code>) if a wildcard was used in the <strong>From URL</strong> field above. Examples (related to <strong>From URL</strong> examples above):<br>\n\u2022 <code>/en/new-page-slug/</code><br>\n\u2022 <code>/en/products/:code-:name</code><br>\n\u2022 <code>/en/:splat/</code><br><br>\nSee <a href="https://developers.cloudflare.com/pages/configuration/redirects/">Cloudflare documentation on redirects</a> for more info.',
validators: { required: {} },
appearance: {
addons: [],
editor: 'single_line',
parameters: { heading: false },
},
default_value: '',
});

console.log(
'Create Single-line string field "Type" (`status_code`) in model "\u21AA\uFE0F Redirect Rule" (`redirect_rule`)'
);
await client.fields.create('c0S4sIyiRK-EewRhFEFPLA', {
// @ts-expect-error next-line DatoCMS auto-generated
id: 'Bfb1Y4ZCT-2ciVjqeFMlaQ',
label: 'Type',
field_type: 'string',
api_key: 'status_code',
validators: { required: {}, enum: { values: ['301', '302'] } },
appearance: {
addons: [],
editor: 'string_radio_group',
parameters: {
radios: [
{
hint: 'Is remembered by the browser. Best for performance.',
label: 'Permanent redirect',
value: '301',
},
{
hint: 'Is checked on the server every time. Best for changing redirects.',
label: 'Temporary redirect',
value: '302',
},
],
},
},
default_value: '302',
});

console.log('Finalize models/block models');

console.log('Update model "\u21AA\uFE0F Redirect Rule" (`redirect_rule`)');
await client.itemTypes.update('c0S4sIyiRK-EewRhFEFPLA', {
title_field: { id: 'L0DTI0v1Qeqoj1kkAoqEvA', type: 'field' },
});

console.log('Manage menu items');

console.log('Create menu item "\u21AA\uFE0F Redirect Rules"');
await client.menuItems.create({
// @ts-expect-error next-line DatoCMS auto-generated
id: 'dnTgBYv_RYeT_2VXK7SV-A',
label: '\u21AA\uFE0F Redirect Rules',
item_type: { id: 'c0S4sIyiRK-EewRhFEFPLA', type: 'item_type' },
});

console.log('Delete menu item "Schema migration"');
await client.menuItems.destroy('NCW3JSnoTgWVSSTaf4iBYw');

console.log('Update menu item "\uD83C\uDF10 Translations"');
await client.menuItems.update('1304241', {
label: '\uD83C\uDF10 Translations',
position: 7,
});

console.log('Update menu item "\uD83D\uDCD1 Pages"');
await client.menuItems.update('1569863', { label: '\uD83D\uDCD1 Pages' });

console.log('Update menu item "404 Page"');
await client.menuItems.update('1690698', { label: '404 Page' });

console.log('Update menu item "\u21AA\uFE0F Redirect Rules"');
await client.menuItems.update('dnTgBYv_RYeT_2VXK7SV-A', { position: 6 });

console.log('Update menu item "\uD83C\uDFE0 Home"');
await client.menuItems.update('1291667', { label: '\uD83C\uDFE0 Home' });
}
2 changes: 1 addition & 1 deletion datocms-environment.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export const datocmsEnvironment = 'page-partial-layout';
export const datocmsEnvironment = 'embed-block';
export const datocmsBuildTriggerId = '30535';
17 changes: 17 additions & 0 deletions docs/decision-log/2024-01-20-editable-redirects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Editable redirects

**Compile editable redirect rules in the CMS to a [Cloudflare Pages `_redirects` file](https://developers.cloudflare.com/pages/configuration/redirects/).**

- Date: 2024-01-20
- Alternatives Considered: compile to [Astro redirects](https://docs.astro.build/en/reference/configuration-reference/#redirects); use link field with InternalLink record instead of single line string field for "To URL" field.
- Decision Made By: [Jasper](https://github.com/jbmoelker)

## Decision

### Use Cloudflare Pages to handle redirects

Both Astro and Cloudflare Pages offer functionality to handle redirects: [Astro redirects](https://docs.astro.build/en/reference/configuration-reference/#redirects) and [Cloudflare Pages `_redirects` file](https://developers.cloudflare.com/pages/configuration/redirects/). However the redirect destination in Astro redirects configuration requires knowledge of the avalable file based routes in the code base which content editors in the CMS don't have. The Cloudflare Pages redirects offer functionality from a user perspective and are therefore preferred.

### Use single line string for To URL field

A redirect rule is typically defined to redirect to an existing page in the CMS, so it would potentially make sense to use a link field referencing an Internal Link record to resolve the URL to redirect to. However the redirect source (From URL) may not match the available locales of an Internal Link record target. This would mean the From URL needs to be localised, which complicates the setup. See [comments on redirects issue](https://github.com/voorhoede/head-start/issues/64#issuecomment-1852715925). So instead the editable redirects offer a simple single line string field for both the From URL and To URL field in the CMS.
29 changes: 29 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Routing

**Head Start leverages [Astro file-based routing](https://docs.astro.build/en/core-concepts/routing/#_top) combined with Cloudflare features for redirects and page not found behaviour. The setup is enhanced with i18n routing, API routing, nested page routing and helpers to resolve routes.**

## I18n routes

Head Start supports multi-language websites with localised routing (`/:locale/page/to/page/`). See [i18n configuration and routing](./i18n.md).

## Nested routes

Head Start supports nested pages, so editors can create page URLs like `/en/overview-page/category-page/detail-page/`. This is achieved using a `parentPage` field* in Page models in the CMS, combined with a catch all route `src/pages/[locale]/[...path]/` using [Astro rest parameters](https://docs.astro.build/en/core-concepts/routing/#rest-parameters). This setup can be copied for new page models added to your project.

To make nested routes more useful for website visitors and easier to manage for developers, Head Start provides a [Breadcrumbs component](../src/components/Breadcrumbs/) and an [Internal Link component](../src/blocks/InternalLink/) which resolves URLs for all content models.

\* See [decision entry on nested page setup](./decision-log/2023-12-26-nested-page-setup.md) for motivation.

## 404 routes

Head Start leverages [Cloudflare's Not Found behaviour](https://developers.cloudflare.com/pages/configuration/serving-pages/#not-found-behavior), which supports different 404 pages on different routes. Cloudflare will look up the directory tree until it finds a matching 404 page. A localised [root 404 page](../src/pages/[locale]/404.astro) is provided and can be edit from the CMS. You can add more specific 404 pages on specific routes, for example a `src/pages/[locale]/products/404.astro`.

## API routes

Astro supports [API routes](https://docs.astro.build/en/core-concepts/endpoints/#server-endpoints-api-routes) (server endpoints), which can be any route in `src/pages/`. Head Start uses a convention to place all API routes in `src/pages/api/`. This way it's clear where all API routes live, they have a logical URL prefix in the browser (`/api/`) and [API routes not found](../src/pages/api/[...notFound].ts) can be caught and respond with a 404 JSON response, rather than an HTML response.

## Redirects

Head Start supports redirect rules which are editable and [sortable](https://www.datocms.com/docs/content-modelling/record-ordering) in the CMS. These redirect rules are compiled to a [Cloudflare Pages `_redirects` file](https://developers.cloudflare.com/pages/configuration/redirects/)* during build, and support placeholders (`:placeholder_name`) and wildcards (`:*` -> `:splat`).

\* See [decision entry on editable redirects](./decision-log/2024-01-20-editable-redirects.md) for motivation.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"lint:eslint": "eslint . --ext .js,.ts,.astro",
"lint:html": "html-validate --config config/htmlvalidate/config.json dist/",
"post": "run-s post:* --print-label",
"post:download-redirects": "jiti scripts/download-redirects.ts",
"post:move-404-pages": "jiti scripts/move-404-pages.ts",
"postinstall": "npx husky install"
},
Expand Down
47 changes: 47 additions & 0 deletions scripts/download-redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { writeFile } from 'node:fs/promises';
import { buildClient } from '@datocms/cma-client-node';
import dotenv from 'dotenv-safe';
import { datocmsEnvironment } from '../datocms-environment';

dotenv.config({
allowEmptyValues: Boolean(process.env.CI),
});

type RedirectRuleRecord = {
from: string;
to: string;
status_code: string;
[key: string]: string | object;
}
type RedirectRule = {
from: string;
to: string;
statusCode: '301'|'302';
}

async function fetchRedirectRules() {
// use client instead of http api for pagination support
const client = buildClient({
apiToken: process.env.DATOCMS_READONLY_API_TOKEN!,
environment: datocmsEnvironment,
});
const redirectRules: RedirectRule[] = [];

for await (const item of client.items.listPagedIterator({ filter: { type: 'redirect_rule' } }) as unknown as RedirectRuleRecord[]) {
redirectRules.push({
from: item.from,
to: item.to,
statusCode: item.status_code as RedirectRule['statusCode'],
});
}
return redirectRules;
}

async function downloadRedirectRules() {
const redirectRules = await fetchRedirectRules();
const cloudflareRedirectFile = redirectRules.map(rule => `${rule.from} ${rule.to} ${rule.statusCode}`).join('\n');
await writeFile('./dist/_redirects', cloudflareRedirectFile);
}

downloadRedirectRules()
.then(() => console.log('RedirectRules downloaded'));
1 change: 1 addition & 0 deletions src/blocks/InternalLink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Internal Link

0 comments on commit 542af7c

Please sign in to comment.