Skip to content

Commit 4b01b43

Browse files
anareynajbmoelker
andauthored
add preview links from CMS to website pages (#304)
# Associated issue Part of #10 Closes #233 ## Description This PR adds **preview links** to the DatoCMS sidebar so editors can click a link and immediately see the page they're working on: on localhost, the preview environment, or production. We are installing the [Model Deployment Links plugin](https://www.datocms.com/marketplace/plugins/i/datocms-plugin-model-deployment-links) in DatoCMS which adds preview links to the sidebar. When an editor clicks one, it opens the page on the website. This is a rebased and updated version of `feat/preview-links`, #233 updated with the current `main` branch For **Home** and **Not Found** pages, the URL is simple and known (`/{locale}/` and `/{locale}/404`). But for **regular pages**, the CMS only knows the page's slug (e.g. `my-page`), not the full url path, because pages can be nested (`/en/parent/my-page/`). To solve this, we added a reroute endpoint that looks up the page by slug and redirects to the correct url. ## How it works 1. Editor clicks "Preview" in the CMS sidebar 2. Browser opens: /api/preview/enter/?secret=xxx&location=/api/reroute/page/en/my-page 3. Preview enter endpoint checks the secret, sets a preview cookie, redirects to location 4. Reroute endpoint looks up the page by slug, finds its full path 5. Redirects to: /en/parent/my-page/ 6. Editor sees the page (including draft/unpublished content) ## Changes from the original PR (`feat/preview-links`) #233 - Rebased onto current `main` (content layer, new routing, etc.) - Changed reroute endpoint from query params (`/api/reroute/page?locale=en&slug=foo`) to **path-based** (`/api/reroute/page/en/foo`) — the query param approach had a bug where the DatoCMS plugin didn't URL-encode the `location` parameter, causing the slug to be lost - Migration now creates the Preview API token automatically (no manual setup needed) - Migration checks for existing plugin/token before creating duplicates - Updated documentation ## How to test 1. Create a new DatoCMS sandbox environment and run migrations (`npm run cms:manage`) 2. Start the dev server (`npm run dev`) 3. Open the CMS, go to the sandbox environment 4. Open a Page / Home / Not Found record and check the sidebar preview links: - Home > Opens `/{locale}/` directly - Page > Opens `/api/reroute/page/{locale}/{slug}` : redirects to the full canonical URL (`/en/parent/child/`) - Not found > Opens `/{locale}/404` 5. Verify that: - Preview links work for all three models (via the Localhost build trigger) - Nested pages (with parent pages) resolve to the correct url > **Note:** Preview and Production sidebar links will only work once the code is deployed to those environments. # Checklist - [x] I have performed a self-review of my own code - [x] I have made sure that my PR is easy to review (not too big, includes comments) - [x] I have made updated relevant documentation files (in project README, docs/, etc) - [ ] I have added a decision log entry if the change affects the architecture or changes a significant technology - [ ] I have notified a reviewer <!-- Please strike through and check off all items that do not apply (rather than removing them) --> --------- Co-authored-by: Jasper Moelker <jasper@voorhoede.nl>
1 parent de6c5e1 commit 4b01b43

8 files changed

Lines changed: 191 additions & 14 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Client } from '@datocms/cli/lib/cma-client-node';
2+
3+
export const createPreviewToken = async (client: Client) => {
4+
// Check if a token named "Preview" already exists
5+
const existingTokens = await client.accessTokens.list();
6+
const existingPreviewToken = existingTokens.find((token) => token.name === 'Preview');
7+
8+
if (existingPreviewToken) {
9+
return existingPreviewToken;
10+
}
11+
12+
// Create new token if it doesn't exist
13+
const roles = await client.roles.list();
14+
const editorRole = roles.find((role) => role.name === 'Editor');
15+
16+
return client.accessTokens.create({
17+
name: 'Preview',
18+
can_access_cda: true,
19+
can_access_cda_preview: false,
20+
can_access_cma: true,
21+
role: {
22+
type: 'role',
23+
id: editorRole!.id,
24+
},
25+
});
26+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Client } from '@datocms/cli/lib/cma-client-node';
2+
import { createPreviewToken } from '../lib/createPreviewToken';
3+
4+
export default async function (client: Client) {
5+
console.log('Manage upload filters');
6+
7+
console.log('Install plugin "Model Deployment Links"');
8+
const previewApiToken = await createPreviewToken(client);
9+
10+
// Check if plugin already exists
11+
const existingPlugins = await client.plugins.list();
12+
let plugin = existingPlugins.find((p) => p.package_name === 'datocms-plugin-model-deployment-links');
13+
14+
if (!plugin) {
15+
plugin = await client.plugins.create({
16+
package_name: 'datocms-plugin-model-deployment-links',
17+
});
18+
}
19+
20+
await client.plugins.update(plugin.id, {
21+
parameters: { datoApiToken: previewApiToken.token },
22+
});
23+
24+
console.log('Creating new fields/fieldsets');
25+
26+
const page = await client.itemTypes.find('page');
27+
const homePage = await client.itemTypes.find('home_page');
28+
const notFoundPage = await client.itemTypes.find('not_found_page');
29+
30+
console.log(
31+
'Create JSON field "Preview" (`preview`) in model "\uD83D\uDCD1 Page" (`page`)'
32+
);
33+
await client.fields.create(page.id, {
34+
label: 'Preview',
35+
field_type: 'json',
36+
api_key: 'preview',
37+
localized: true,
38+
appearance: {
39+
addons: [],
40+
editor: plugin.id,
41+
parameters: { urlPattern: '/api/reroute/page/{ locale }/{ slug }' },
42+
},
43+
});
44+
45+
console.log(
46+
'Create JSON field "Preview" (`preview`) in model "\uD83C\uDFE0 Home" (`home_page`)'
47+
);
48+
await client.fields.create(homePage.id, {
49+
label: 'Preview',
50+
field_type: 'json',
51+
api_key: 'preview',
52+
localized: true,
53+
appearance: {
54+
addons: [],
55+
editor: plugin.id,
56+
parameters: { urlPattern: '/{ locale }/' },
57+
},
58+
});
59+
60+
console.log(
61+
'Create JSON field "Preview" (`preview`) in model "\uD83E\uDD37 Not found" (`not_found_page`)'
62+
);
63+
await client.fields.create(notFoundPage.id, {
64+
label: 'Preview',
65+
field_type: 'json',
66+
api_key: 'preview',
67+
localized: true,
68+
appearance: {
69+
addons: [],
70+
editor: plugin.id,
71+
parameters: { urlPattern: '/{ locale }/404' },
72+
},
73+
});
74+
}

docs/getting-started.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ You can now run your project locally:
7373
npm run dev
7474
```
7575

76+
### Configure DatoCMS plugins
77+
78+
Head Start comes with a few DatoCMS plugins pre-installed. The [Model Deployment Links plugin](https://www.datocms.com/marketplace/plugins/i/datocms-plugin-model-deployment-links) is configured automatically when running migrations. It adds preview links to the CMS sidebar so editors can preview pages directly from the CMS.
79+
80+
If you need to configure the plugin manually (e.g. when not using migrations):
81+
82+
- In your DatoCMS instance go to Project Settings > API Tokens (`/project_settings/access_tokens`) and "Add a new access token". Name it "Preview" (or whatever you prefer), for the "Role associated with this API token" select "Editor" and keep the other settings as is.
83+
- Go to Environment Configuration > Plugins > Model Deployment Links and enter the newly created access token in the plugin settings under "DatoCMS API Token".
84+
7685
### Add DatoCMS secrets to repository
7786

7887
Head Start provides GitHub Actions which include linting code and validating HTML on PR changes. These Actions require the DatoCMS tokens to be available.

docs/preview-mode.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Preview mode is protected with a secret. If you attempt to view content protecte
3838
/api/preview/enter/?secret=my-little-secret&location=/en/some-page/
3939
```
4040

41-
This endpoint can for example be used to link to previews from within the CMS.
41+
This endpoint is used by the CMS preview links (see [Preview links from the CMS](#preview-links-from-the-cms) below).
4242

4343
When authorised an encrypted cookie is set, to persist preview mode throughout a session. Calling the 'exit preview mode' endpoint removes the cookie and disables preview mode:
4444

@@ -52,6 +52,26 @@ When authorised an encrypted cookie is set, to persist preview mode throughout a
5252

5353
Note: the secret is configured as environment variable `HEAD_START_PREVIEW_SECRET`.
5454

55+
## Preview links from the CMS
56+
57+
Head Start includes the [Model Deployment Links plugin](https://www.datocms.com/marketplace/plugins/i/datocms-plugin-model-deployment-links) which adds preview links to the CMS sidebar. This allows editors to preview any page directly from the CMS, including draft (unpublished) content.
58+
59+
The plugin is configured automatically via migrations (see [`1750900000_previewLinks.ts`](../config/datocms/migrations/1750900000_previewLinks.ts)). It adds a "Preview" field to the Home, Page, and Not Found models with URL patterns for each.
60+
61+
### How it works
62+
63+
Each model has a different URL pattern:
64+
65+
| Model | URL pattern | How it resolves |
66+
|---|---|---|
67+
| Home | `/{ locale }/` | Direct link to the home page |
68+
| Page | `/api/reroute/page/{ locale }/{ slug }` | Looks up the page by slug, 307 redirects to its canonical URL |
69+
| Not found | `/{ locale }/404` | Direct link to the 404 page |
70+
71+
The **Page** model uses a reroute endpoint because the CMS only knows a page's slug, not its full nested path (e.g. a page with slug `my-page` might live at `/en/parent/my-page/`). The endpoint at `src/pages/api/reroute/page/[locale]/[slug].ts` queries DatoCMS for the page by slug and redirects to the correct canonical URL.
72+
73+
The sidebar links are shown for each Build Trigger configured in DatoCMS (e.g. Localhost, Preview, Production).
74+
5575
## Preview mode subscriptions
5676

5777
In preview mode the web page listens for content changes and automatically reloads to re-render on updates. To configure which content changes to listen to you can add one or more `PreviewModeSubscription` components, which accept the same `query` and `variables` properties you use to request the initial data:

src/pages/404.astro

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
11
---
2-
import type { NotFoundPageQuery, SiteLocale } from '@lib/datocms/types';
3-
import { datocmsRequest } from '@lib/datocms';
4-
import { noIndexTag, titleTag } from '@lib/seo';
5-
import Layout from '@layouts/Default.astro';
62
import Blocks from '@blocks/Blocks.astro';
73
import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro';
4+
import Layout from '@layouts/Default.astro';
5+
import { datocmsRequest } from '@lib/datocms';
6+
import type { NotFoundPageQuery, SiteLocale } from '@lib/datocms/types';
7+
import { noIndexTag, titleTag } from '@lib/seo';
88
import query from './_404.query.graphql';
99
1010
export const prerender = false;
1111
1212
Astro.response.status = 404;
1313
14-
type Params = {
15-
locale: SiteLocale;
16-
};
17-
18-
const { locale } = Astro.params as Params;
14+
const localeFromPath = Astro.params.locale as SiteLocale;
15+
const localeFromQuery = Astro.url.searchParams.get('locale') as SiteLocale;
16+
const locale = localeFromQuery || localeFromPath;
1917
const variables = { locale };
2018
// While this page is not prerendered, we still want to treat it as a regular page with the default prerender strategy.
2119
const { page } = await datocmsRequest<NotFoundPageQuery, true>({ query, variables });
2220
---
2321

2422
<Layout pageUrls={[]} seoMetaTags={[noIndexTag, titleTag(page.title)]}>
2523
<PreviewModeSubscription query={query} variables={variables} record={{ type: page.__typename, id: page.id }} />
26-
<h1>{page.title} {Astro.params.locale}</h1>
24+
<h1>{page.title}</h1>
2725
<Blocks blocks={page.bodyBlocks} />
2826
</Layout>

src/pages/[locale]/[...path]/index.astro

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,22 @@ export async function getStaticPaths() {
1414
1515
// Load as an entry so this route can also be a server route in preview mode.
1616
// @see /docs/preview-mode.md
17-
const { path } = Astro.params;
17+
const { locale, path } = Astro.params;
1818
const entry = await getEntry('Pages', path);
1919
if (!entry) {
20-
return Astro.redirect('/404', 404);
20+
return Astro.rewrite(`/404/?locale=${locale}`);
2121
}
2222
2323
const {
2424
data: { meta, ...page },
2525
subscription,
2626
} = entry;
2727
const { breadcrumbs, pageUrls, recordId, recordType } = meta;
28+
29+
const canonicalUrl = pageUrls.find((url) => url.locale === meta.locale)?.pathname;
30+
if (canonicalUrl && Astro.url.pathname !== canonicalUrl) {
31+
return Astro.redirect(canonicalUrl);
32+
}
2833
---
2934

3035
<Layout
@@ -39,4 +44,4 @@ const { breadcrumbs, pageUrls, recordId, recordType } = meta;
3944
/>
4045
<h1>{page.title}</h1>
4146
<Blocks blocks={page.bodyBlocks} />
42-
</Layout>
47+
</Layout>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#import '@lib/routing/PageRoute.fragment.graphql'
2+
3+
query ReroutePage($locale: SiteLocale!, $slug: String!) {
4+
page(locale: $locale, filter: { slug: { eq: $slug } }) {
5+
...PageRoute
6+
}
7+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { APIRoute } from 'astro';
2+
import { datocmsRequest } from '@lib/datocms';
3+
import type { ReroutePageQuery, SiteLocale } from '@lib/datocms/types';
4+
import { getPageHref } from '@lib/routing';
5+
import query from '../../_page.query.graphql';
6+
7+
export const prerender = false;
8+
9+
const jsonResponse = (data: object, status: number = 200) => {
10+
return new Response(JSON.stringify(data), {
11+
status,
12+
headers: {
13+
'Content-Type': 'application/json',
14+
},
15+
});
16+
};
17+
18+
export const GET: APIRoute = async ({ params }) => {
19+
const locale = params.locale as SiteLocale;
20+
if (!locale) {
21+
return jsonResponse({ error: 'Missing \'locale\' parameter' }, 400);
22+
}
23+
24+
const slug = params.slug;
25+
if (!slug) {
26+
return jsonResponse({ error: 'Missing \'slug\' parameter' }, 400);
27+
}
28+
29+
const { page } = (await datocmsRequest<ReroutePageQuery>({ query, variables: { slug, locale } }));
30+
if (!page) {
31+
return jsonResponse({ error: 'Page not found' }, 404);
32+
}
33+
34+
return new Response('', {
35+
status: 307,
36+
headers: { 'Location': getPageHref({ locale, record: page }) },
37+
});
38+
};

0 commit comments

Comments
 (0)