create-shapio 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/astro/.env.example +2 -0
- package/templates/astro/package.json +2 -2
- package/templates/astro/scripts/lib/content.ts +46 -5
- package/templates/astro/scripts/seed.ts +26 -4
- package/templates/astro/scripts/smoke.ts +13 -0
- package/templates/astro/scripts/smokeHttp.ts +11 -1
- package/templates/astro/shapio/components/seo.json +114 -0
- package/templates/astro/shapio/models/article.json +21 -0
- package/templates/astro/shapio/models/page.json +21 -0
- package/templates/astro/src/layouts/Base.astro +49 -7
- package/templates/astro/src/lib/preview.ts +1 -0
- package/templates/astro/src/lib/seo.ts +69 -0
- package/templates/astro/src/lib/shapio.ts +23 -4
- package/templates/astro/src/lib/types.ts +7 -0
- package/templates/astro/src/pages/[locale]/[...slug].astro +1 -1
- package/templates/astro/src/pages/[locale]/articles/[slug].astro +1 -1
- package/templates/next/.env.example +2 -0
- package/templates/next/package.json +2 -2
- package/templates/next/scripts/lib/content.ts +46 -5
- package/templates/next/scripts/seed.ts +26 -4
- package/templates/next/scripts/smokeHttp.ts +11 -1
- package/templates/next/shapio/components/seo.json +114 -0
- package/templates/next/shapio/models/article.json +21 -0
- package/templates/next/shapio/models/page.json +21 -0
- package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +10 -2
- package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +11 -2
- package/templates/next/src/app/[locale]/articles/page.tsx +6 -4
- package/templates/next/src/app/[locale]/colophon/page.tsx +6 -4
- package/templates/next/src/app/[locale]/layout.tsx +5 -1
- package/templates/next/src/app/api/revalidate/route.ts +6 -3
- package/templates/next/src/lib/config.ts +4 -0
- package/templates/next/src/lib/metadata.ts +68 -0
- package/templates/next/src/lib/preview.ts +1 -0
- package/templates/next/src/lib/revalidation.ts +20 -1
- package/templates/next/src/lib/revalidationTargets.test.ts +11 -1
- package/templates/next/src/lib/revalidationTargets.ts +5 -0
- package/templates/next/src/lib/seo.ts +69 -0
- package/templates/next/src/lib/shapio.ts +24 -3
- package/templates/next/src/lib/types.ts +7 -0
- package/templates/sveltekit/.env.example +2 -0
- package/templates/sveltekit/package.json +2 -2
- package/templates/sveltekit/scripts/lib/content.ts +46 -5
- package/templates/sveltekit/scripts/seed.ts +26 -4
- package/templates/sveltekit/scripts/smokeHttp.ts +11 -1
- package/templates/sveltekit/shapio/components/seo.json +114 -0
- package/templates/sveltekit/shapio/models/article.json +21 -0
- package/templates/sveltekit/shapio/models/page.json +21 -0
- package/templates/sveltekit/src/env.ts +1 -0
- package/templates/sveltekit/src/lib/components/Seo.svelte +73 -0
- package/templates/sveltekit/src/lib/preview.ts +1 -0
- package/templates/sveltekit/src/lib/seo.ts +69 -0
- package/templates/sveltekit/src/lib/server/config.ts +5 -0
- package/templates/sveltekit/src/lib/server/shapio.ts +24 -3
- package/templates/sveltekit/src/lib/types.ts +7 -0
- package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +8 -2
- package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +2 -6
- package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +2 -3
- package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +2 -6
- package/templates/sveltekit/src/routes/[locale]/colophon/+page.svelte +2 -3
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SeoResolved } from '@shapio/client';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Content as the Shapio delivery API returns it for this site's models (shapio/). Delivery
|
|
3
5
|
* entries are flat: system attributes next to the fields, keyed by API key. Media fields are asset views,
|
|
@@ -24,6 +26,7 @@ export type Media = {
|
|
|
24
26
|
variants: MediaVariant[];
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
/** Rich text as delivery returns it with `richText=html`: sanitized HTML rendered from the document. */
|
|
27
30
|
export type RichText = { format: 'shapio-richtext'; version: number; html: string };
|
|
28
31
|
|
|
29
32
|
type EntryBase = {
|
|
@@ -61,6 +64,8 @@ export type Page = EntryBase & {
|
|
|
61
64
|
slug: string;
|
|
62
65
|
description: string | null;
|
|
63
66
|
sections: Section[];
|
|
67
|
+
/** The built-in SEO fields, read with `seo=resolved` (the site's defaults filled in). */
|
|
68
|
+
seo: SeoResolved;
|
|
64
69
|
};
|
|
65
70
|
|
|
66
71
|
export type Author = EntryBase & { name: string; bio: string | null; avatar: Media | null };
|
|
@@ -74,6 +79,8 @@ export type Article = EntryBase & {
|
|
|
74
79
|
/** Populated (`populate=author`) or just the ID; null when the author is not published. */
|
|
75
80
|
author: Author | string | null;
|
|
76
81
|
publishedOn: string;
|
|
82
|
+
/** The built-in SEO fields, read with `seo=resolved` (the site's defaults filled in). */
|
|
83
|
+
seo: SeoResolved;
|
|
77
84
|
};
|
|
78
85
|
|
|
79
86
|
/** The `siteSettings` singleton: one entry per site, read by its API ID. */
|
|
@@ -23,6 +23,6 @@ type Props = { page: Page; locale: Locale };
|
|
|
23
23
|
const { page, locale } = Astro.props;
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
<Base title={page.title}
|
|
26
|
+
<Base title={page.title} seo={page.seo} locale={locale} alternatePath={pagePath(otherLocale(locale), page.slug)}>
|
|
27
27
|
<div lang={page.locale} set:html={renderPage(page)} />
|
|
28
28
|
</Base>
|
|
@@ -20,6 +20,6 @@ const { locale, article } = Astro.props;
|
|
|
20
20
|
const context = { locale, strings: stringsFor(locale) };
|
|
21
21
|
---
|
|
22
22
|
|
|
23
|
-
<Base title={article.title}
|
|
23
|
+
<Base title={article.title} seo={article.seo} type="article" locale={locale} alternatePath={articlePath(otherLocale(locale), article.slug)}>
|
|
24
24
|
<Fragment set:html={renderArticle(article, context)} />
|
|
25
25
|
</Base>
|
|
@@ -13,3 +13,5 @@ SHAPIO_WEBHOOK_SECRET=
|
|
|
13
13
|
NEXT_TELEMETRY_DISABLED=1
|
|
14
14
|
# Optional: the Shapio URL the browser calls for draft previews, when it differs from SHAPIO_URL.
|
|
15
15
|
# NEXT_PUBLIC_SHAPIO_URL=https://cms.example.com
|
|
16
|
+
# Optional: this site's public origin; pages then carry a canonical URL and og:url.
|
|
17
|
+
# SITE_URL=https://www.example.com
|
|
@@ -448,15 +448,56 @@ export type EntrySpec = {
|
|
|
448
448
|
publish: boolean;
|
|
449
449
|
};
|
|
450
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The SEO fields of one locale (the built-in `seo` component, per locale): a page describes itself with its
|
|
453
|
+
* description, an article with its excerpt and its cover (a shared field, so it comes from the English data).
|
|
454
|
+
* The title stays empty: `?seo=resolved` falls back to the entry's own title, through the site's template.
|
|
455
|
+
*/
|
|
456
|
+
const seoOf = (data: Record<string, unknown>, shared: Record<string, unknown>) => ({
|
|
457
|
+
description: data.excerpt ?? data.description ?? null,
|
|
458
|
+
image: shared.cover ?? null,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const withSeo = (content: Localized<Record<string, unknown>>): Localized<Record<string, unknown>> => ({
|
|
462
|
+
en: { ...content.en, seo: seoOf(content.en, content.en) },
|
|
463
|
+
fr: { ...content.fr, seo: seoOf(content.fr, content.en) },
|
|
464
|
+
});
|
|
465
|
+
|
|
451
466
|
export const entries = (media: MediaIds, authorId: string): EntrySpec[] => [
|
|
452
|
-
{ model: 'page', slug: 'home', content: home(media), publish: true },
|
|
453
|
-
{ model: 'page', slug: 'about', content: about(), publish: true },
|
|
467
|
+
{ model: 'page', slug: 'home', content: withSeo(home(media)), publish: true },
|
|
468
|
+
{ model: 'page', slug: 'about', content: withSeo(about()), publish: true },
|
|
454
469
|
{
|
|
455
470
|
model: 'article',
|
|
456
471
|
slug: 'modelling-without-a-deploy',
|
|
457
|
-
content: modelling(media, authorId),
|
|
472
|
+
content: withSeo(modelling(media, authorId)),
|
|
473
|
+
publish: true,
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
model: 'article',
|
|
477
|
+
slug: 'why-builds-pin-a-snapshot',
|
|
478
|
+
content: withSeo(snapshots(media, authorId)),
|
|
458
479
|
publish: true,
|
|
459
480
|
},
|
|
460
|
-
{ model: 'article', slug: '
|
|
461
|
-
{ model: 'article', slug: 'winter-projects', content: draft(media, authorId), publish: false },
|
|
481
|
+
{ model: 'article', slug: 'winter-projects', content: withSeo(draft(media, authorId)), publish: false },
|
|
462
482
|
];
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The site's SEO defaults (Settings → SEO; `PUT /api/admin/site/seo`): the name and template match the
|
|
486
|
+
* siteSettings singleton's site name, the description its tagline; the hero image is the default social image.
|
|
487
|
+
*/
|
|
488
|
+
export const seoDefaults = (media: MediaIds) => ({
|
|
489
|
+
locales: {
|
|
490
|
+
en: {
|
|
491
|
+
siteName: String(SITE_SETTINGS.en.siteName),
|
|
492
|
+
titleTemplate: `%s · ${String(SITE_SETTINGS.en.siteName)}`,
|
|
493
|
+
description: String(SITE_SETTINGS.en.tagline),
|
|
494
|
+
},
|
|
495
|
+
fr: {
|
|
496
|
+
siteName: String(SITE_SETTINGS.fr.siteName),
|
|
497
|
+
titleTemplate: `%s · ${String(SITE_SETTINGS.fr.siteName)}`,
|
|
498
|
+
description: String(SITE_SETTINGS.fr.tagline),
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
imageId: media.hero,
|
|
502
|
+
twitterHandle: '@northwindstudio',
|
|
503
|
+
});
|
|
@@ -4,6 +4,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import {
|
|
6
6
|
buildUploadForm,
|
|
7
|
+
SEO_COMPONENT_ID,
|
|
7
8
|
ShapioApiError,
|
|
8
9
|
type MediaAsset,
|
|
9
10
|
type SchemaApplyInput,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
AUTHOR,
|
|
16
17
|
entries,
|
|
17
18
|
IMAGES,
|
|
19
|
+
seoDefaults,
|
|
18
20
|
SITE_SETTINGS,
|
|
19
21
|
type EntrySpec,
|
|
20
22
|
type ImageSpec,
|
|
@@ -29,8 +31,9 @@ import { createPng } from './lib/png.js';
|
|
|
29
31
|
* 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
|
|
30
32
|
* as `shapio schema apply` and the admin), as the site's own (SHAPIO_SITE, else the token's site, else the
|
|
31
33
|
* primary): a starter's schema belongs to the first site it is seeded on;
|
|
34
|
+
* The built-in SEO component (components/seo.json) is the exception: it is always shared with every site;
|
|
32
35
|
* 3. placeholder images, an author, the site settings, pages and articles in English and French, published
|
|
33
|
-
* per locale;
|
|
36
|
+
* per locale, and the site's SEO defaults (Settings → SEO);
|
|
34
37
|
* 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
|
|
35
38
|
* entry document's Preview pane and visual editing; it triggers no builds). The site's URL is SITE_URL, else
|
|
36
39
|
* the starter's `--site-url` (its local dev server);
|
|
@@ -50,6 +53,8 @@ const PREVIEW_PATH = '/preview/?model={modelKey}&id={entryId}&locale={locale}#to
|
|
|
50
53
|
/** A placeholder build hook: the connection has no triggers, so nothing is ever sent to it. */
|
|
51
54
|
const PREVIEW_BUILD_HOOK = 'https://build.invalid/shapio-starter';
|
|
52
55
|
const REVALIDATE_WEBHOOK_NAME = 'Site revalidation';
|
|
56
|
+
/** The request's site's SEO defaults (`site.settings`). */
|
|
57
|
+
const SEO_DEFAULTS_PATH = '/api/admin/site/seo';
|
|
53
58
|
/** The events that change what a published site shows (the starter's route ignores any others). */
|
|
54
59
|
const REVALIDATE_EVENTS = [
|
|
55
60
|
'entry.published',
|
|
@@ -58,6 +63,8 @@ const REVALIDATE_EVENTS = [
|
|
|
58
63
|
'change_set.shipped',
|
|
59
64
|
'schema.activated',
|
|
60
65
|
'schema.deleted',
|
|
66
|
+
// The site's SEO defaults changed: every page's head shows them.
|
|
67
|
+
'site.updated',
|
|
61
68
|
];
|
|
62
69
|
/** A local site needs Shapio's OUTBOUND_PRIVATE_NETWORK_ALLOWLIST too; the webhook opts in for loopback only. */
|
|
63
70
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
@@ -129,8 +136,11 @@ const applySchema = async (client: ShapioClient) => {
|
|
|
129
136
|
try {
|
|
130
137
|
response = await client.admin.schema.apply({
|
|
131
138
|
definitions,
|
|
132
|
-
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default
|
|
133
|
-
|
|
139
|
+
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default, except
|
|
140
|
+
// the built-in SEO component: Shapio knows it by its fixed ID, so one shared copy serves every site.
|
|
141
|
+
scopes: definitions.map((definition) =>
|
|
142
|
+
(definition as { id?: unknown }).id === SEO_COMPONENT_ID ? ('network' as const) : ('site' as const),
|
|
143
|
+
),
|
|
134
144
|
base: FIRST_APPLY_BASE,
|
|
135
145
|
prune: false,
|
|
136
146
|
dryRun: false,
|
|
@@ -143,7 +153,8 @@ const applySchema = async (client: ShapioClient) => {
|
|
|
143
153
|
"This starter's schema already belongs to another site of this instance: a starter's schema belongs " +
|
|
144
154
|
'to the first site it is seeded on. To reuse it on this site, share it with all sites ' +
|
|
145
155
|
'with a network admin token (`shapio schema scope <apiKey> --shared --site <that site>` for each ' +
|
|
146
|
-
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance.'
|
|
156
|
+
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance. ' +
|
|
157
|
+
'The SEO component is always shared; a site that keeps its own copy of it blocks the others.',
|
|
147
158
|
{ cause: error },
|
|
148
159
|
);
|
|
149
160
|
}
|
|
@@ -259,6 +270,16 @@ const ensureSiteSettings = async (client: ShapioClient) => {
|
|
|
259
270
|
log('Published siteSettings (en, fr)');
|
|
260
271
|
};
|
|
261
272
|
|
|
273
|
+
/** The site's SEO defaults: the texts per locale, the default social image and the Twitter handle. */
|
|
274
|
+
const ensureSeoDefaults = async (client: ShapioClient, media: MediaIds) => {
|
|
275
|
+
const current = await client.request<{ version: number }>(SEO_DEFAULTS_PATH);
|
|
276
|
+
await client.request(SEO_DEFAULTS_PATH, {
|
|
277
|
+
method: 'PUT',
|
|
278
|
+
body: { expectedVersion: current.version, seo: seoDefaults(media) },
|
|
279
|
+
});
|
|
280
|
+
log('Set the SEO defaults (en, fr)');
|
|
281
|
+
};
|
|
282
|
+
|
|
262
283
|
const upsertEntry = async (client: ShapioClient, spec: EntrySpec) => {
|
|
263
284
|
const found = await client.admin.content.list(spec.model, {
|
|
264
285
|
filters: { slug: { $eq: spec.slug } },
|
|
@@ -413,6 +434,7 @@ const main = async () => {
|
|
|
413
434
|
await applySchema(admin.client);
|
|
414
435
|
const media = await ensureMedia(admin.client);
|
|
415
436
|
await ensureSiteSettings(admin.client);
|
|
437
|
+
await ensureSeoDefaults(admin.client, media);
|
|
416
438
|
const authorId = await ensureAuthor(admin.client, media.avatar);
|
|
417
439
|
for (const spec of entries(media, authorId)) {
|
|
418
440
|
await upsertEntry(admin.client, spec);
|
|
@@ -5,7 +5,8 @@ import { parseArgs } from 'node:util';
|
|
|
5
5
|
* `npm run smoke`, after `npm run seed`, `npm run build` and starting the built site (`npm run start` or
|
|
6
6
|
* `npm run preview`): fetches the site's pages over HTTP and checks the seeded content in English and French,
|
|
7
7
|
* the site settings singleton, rich text, media variants and the author, that the draft article is not
|
|
8
|
-
* served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`),
|
|
8
|
+
* served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`), that pages carry
|
|
9
|
+
* their SEO tags (the entry's SEO fields with the site's defaults: title template, Open Graph, Twitter), and that the
|
|
9
10
|
* preview page is served with a `frame-ancestors` policy. The same checks run against every starter, so all
|
|
10
11
|
* three render the same blog.
|
|
11
12
|
*
|
|
@@ -28,6 +29,9 @@ const PAGES: readonly PageCheck[] = [
|
|
|
28
29
|
['.webp', 'responsive image variants'],
|
|
29
30
|
['Northwind Studio', 'site name from the siteSettings singleton'],
|
|
30
31
|
['Built with Shapio', 'footer from the siteSettings singleton'],
|
|
32
|
+
['<title>Home · Northwind Studio</title>', 'title through the SEO title template'],
|
|
33
|
+
['property="og:image"', 'og:image (the default social image)'],
|
|
34
|
+
['name="twitter:site" content="@northwindstudio"', 'twitter:site from the SEO defaults'],
|
|
31
35
|
],
|
|
32
36
|
},
|
|
33
37
|
{
|
|
@@ -36,6 +40,7 @@ const PAGES: readonly PageCheck[] = [
|
|
|
36
40
|
['Un contenu qui évolue aussi vite que votre produit', 'hero heading'],
|
|
37
41
|
['lang="fr"', 'lang="fr"'],
|
|
38
42
|
['Studio Northwind', 'site name from the siteSettings singleton'],
|
|
43
|
+
['<title>Accueil · Studio Northwind</title>', 'title through the French SEO title template'],
|
|
39
44
|
],
|
|
40
45
|
},
|
|
41
46
|
{
|
|
@@ -43,6 +48,7 @@ const PAGES: readonly PageCheck[] = [
|
|
|
43
48
|
expect: [
|
|
44
49
|
['Modelling content without a deploy', 'article list'],
|
|
45
50
|
['Why our builds pin a snapshot', 'second article'],
|
|
51
|
+
['<title>Journal · Northwind Studio</title>', 'a page without an entry: title through the template'],
|
|
46
52
|
],
|
|
47
53
|
},
|
|
48
54
|
{
|
|
@@ -55,6 +61,10 @@ const PAGES: readonly PageCheck[] = [
|
|
|
55
61
|
['data-shapio-entry="', 'visual-editing entry attribute'],
|
|
56
62
|
['data-shapio-path="title"', 'visual-editing attribute on the title'],
|
|
57
63
|
['data-shapio-path="body"', 'visual-editing attribute on the body'],
|
|
64
|
+
['<title>Modelling content without a deploy · Northwind Studio</title>', 'SEO title (resolved)'],
|
|
65
|
+
['property="og:type" content="article"', 'og:type article'],
|
|
66
|
+
['Nothing restarted.', 'SEO description from the entry'],
|
|
67
|
+
['name="twitter:card" content="summary_large_image"', 'twitter:card with the cover image'],
|
|
58
68
|
],
|
|
59
69
|
},
|
|
60
70
|
{ path: `/fr/articles/${ARTICLE}/`, expect: [['Modéliser du contenu sans déploiement', 'title']] },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiKey": "seo",
|
|
3
|
+
"category": "Built-in",
|
|
4
|
+
"description": "Search and social metadata: title, description, image, canonical URL and indexing.",
|
|
5
|
+
"display": {
|
|
6
|
+
"titleFieldId": "3fe294a9-6af9-4019-8b32-43010822e45a"
|
|
7
|
+
},
|
|
8
|
+
"fields": [
|
|
9
|
+
{
|
|
10
|
+
"apiKey": "title",
|
|
11
|
+
"deprecated": false,
|
|
12
|
+
"editor": {
|
|
13
|
+
"id": "textInput",
|
|
14
|
+
"options": {}
|
|
15
|
+
},
|
|
16
|
+
"filterable": false,
|
|
17
|
+
"id": "3fe294a9-6af9-4019-8b32-43010822e45a",
|
|
18
|
+
"label": "Title",
|
|
19
|
+
"localized": false,
|
|
20
|
+
"public": true,
|
|
21
|
+
"required": false,
|
|
22
|
+
"settings": {},
|
|
23
|
+
"sortable": false,
|
|
24
|
+
"type": "string",
|
|
25
|
+
"unique": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"apiKey": "description",
|
|
29
|
+
"deprecated": false,
|
|
30
|
+
"editor": {
|
|
31
|
+
"id": "textarea",
|
|
32
|
+
"options": {}
|
|
33
|
+
},
|
|
34
|
+
"filterable": false,
|
|
35
|
+
"id": "6d1917f3-94a2-4391-ba1f-a98d14577b93",
|
|
36
|
+
"label": "Description",
|
|
37
|
+
"localized": false,
|
|
38
|
+
"public": true,
|
|
39
|
+
"required": false,
|
|
40
|
+
"settings": {},
|
|
41
|
+
"sortable": false,
|
|
42
|
+
"type": "text",
|
|
43
|
+
"unique": false
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"apiKey": "image",
|
|
47
|
+
"deprecated": false,
|
|
48
|
+
"editor": {
|
|
49
|
+
"id": "mediaPicker",
|
|
50
|
+
"options": {}
|
|
51
|
+
},
|
|
52
|
+
"filterable": false,
|
|
53
|
+
"id": "d27c9354-4afc-4583-93b4-367214a7714a",
|
|
54
|
+
"label": "Social image",
|
|
55
|
+
"localized": false,
|
|
56
|
+
"public": true,
|
|
57
|
+
"required": false,
|
|
58
|
+
"settings": {
|
|
59
|
+
"allowedKinds": [
|
|
60
|
+
"image"
|
|
61
|
+
],
|
|
62
|
+
"multiple": false
|
|
63
|
+
},
|
|
64
|
+
"sortable": false,
|
|
65
|
+
"type": "media",
|
|
66
|
+
"unique": false
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"apiKey": "canonical",
|
|
70
|
+
"deprecated": false,
|
|
71
|
+
"editor": {
|
|
72
|
+
"id": "textInput",
|
|
73
|
+
"options": {}
|
|
74
|
+
},
|
|
75
|
+
"filterable": false,
|
|
76
|
+
"id": "17b374b6-cd85-444e-8403-20c7458ed29e",
|
|
77
|
+
"label": "Canonical URL",
|
|
78
|
+
"localized": false,
|
|
79
|
+
"public": true,
|
|
80
|
+
"required": false,
|
|
81
|
+
"settings": {
|
|
82
|
+
"protocols": [
|
|
83
|
+
"https",
|
|
84
|
+
"http"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"sortable": false,
|
|
88
|
+
"type": "url",
|
|
89
|
+
"unique": false
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"apiKey": "noindex",
|
|
93
|
+
"deprecated": false,
|
|
94
|
+
"editor": {
|
|
95
|
+
"id": "toggle",
|
|
96
|
+
"options": {}
|
|
97
|
+
},
|
|
98
|
+
"filterable": false,
|
|
99
|
+
"id": "e8346f99-dc3c-4b5c-906f-4854e03c9a57",
|
|
100
|
+
"label": "Hide from search engines",
|
|
101
|
+
"localized": false,
|
|
102
|
+
"public": true,
|
|
103
|
+
"required": false,
|
|
104
|
+
"settings": {},
|
|
105
|
+
"sortable": false,
|
|
106
|
+
"type": "boolean",
|
|
107
|
+
"unique": false
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"icon": "search",
|
|
111
|
+
"id": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
|
|
112
|
+
"kind": "component",
|
|
113
|
+
"label": "SEO"
|
|
114
|
+
}
|
|
@@ -140,6 +140,27 @@
|
|
|
140
140
|
"sortable": true,
|
|
141
141
|
"type": "date",
|
|
142
142
|
"unique": false
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"apiKey": "seo",
|
|
146
|
+
"deprecated": false,
|
|
147
|
+
"editor": {
|
|
148
|
+
"id": "seoEditor",
|
|
149
|
+
"options": {}
|
|
150
|
+
},
|
|
151
|
+
"filterable": false,
|
|
152
|
+
"id": "a3f1c0de-7b52-4e0a-9d1e-5c2b8e4f6a01",
|
|
153
|
+
"label": "SEO",
|
|
154
|
+
"localized": true,
|
|
155
|
+
"public": true,
|
|
156
|
+
"required": false,
|
|
157
|
+
"settings": {
|
|
158
|
+
"component": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
|
|
159
|
+
"repeatable": false
|
|
160
|
+
},
|
|
161
|
+
"sortable": false,
|
|
162
|
+
"type": "component",
|
|
163
|
+
"unique": false
|
|
143
164
|
}
|
|
144
165
|
],
|
|
145
166
|
"id": "1679d89c-747f-4dfc-955f-7d47bd5e4f6b",
|
|
@@ -83,6 +83,27 @@
|
|
|
83
83
|
"sortable": false,
|
|
84
84
|
"type": "dynamiczone",
|
|
85
85
|
"unique": false
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"apiKey": "seo",
|
|
89
|
+
"deprecated": false,
|
|
90
|
+
"editor": {
|
|
91
|
+
"id": "seoEditor",
|
|
92
|
+
"options": {}
|
|
93
|
+
},
|
|
94
|
+
"filterable": false,
|
|
95
|
+
"id": "b7e2d1af-3c64-4f1b-8e2a-6d3c9f5a7b02",
|
|
96
|
+
"label": "SEO",
|
|
97
|
+
"localized": true,
|
|
98
|
+
"public": true,
|
|
99
|
+
"required": false,
|
|
100
|
+
"settings": {
|
|
101
|
+
"component": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
|
|
102
|
+
"repeatable": false
|
|
103
|
+
},
|
|
104
|
+
"sortable": false,
|
|
105
|
+
"type": "component",
|
|
106
|
+
"unique": false
|
|
86
107
|
}
|
|
87
108
|
],
|
|
88
109
|
"id": "4c09af05-f302-4d8f-ba03-bb838d3afbd2",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import { Sections } from '../../../components/Sections';
|
|
4
|
+
import { pageMetadata } from '../../../lib/metadata';
|
|
4
5
|
import { listPages } from '../../../lib/shapio';
|
|
5
|
-
import { HOME_SLUG, toLocale, type Locale } from '../../../lib/site';
|
|
6
|
+
import { HOME_SLUG, pagePath, toLocale, type Locale } from '../../../lib/site';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Pages from the `page` collection; `home` is the locale's front page (`/en/`), the others `/en/<slug>/`.
|
|
@@ -25,7 +26,14 @@ const findPage = async (locale: Locale, slug: string[] | undefined) => {
|
|
|
25
26
|
export const generateMetadata = async ({ params }: PageParams): Promise<Metadata> => {
|
|
26
27
|
const { locale, slug } = await params;
|
|
27
28
|
const page = await findPage(toLocale(locale), slug);
|
|
28
|
-
return page
|
|
29
|
+
return page
|
|
30
|
+
? pageMetadata({
|
|
31
|
+
title: page.title,
|
|
32
|
+
seo: page.seo,
|
|
33
|
+
locale: toLocale(locale),
|
|
34
|
+
path: pagePath(locale, page.slug),
|
|
35
|
+
})
|
|
36
|
+
: {};
|
|
29
37
|
};
|
|
30
38
|
|
|
31
39
|
const ContentPage = async ({ params }: PageParams) => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import { ArticleView } from '../../../../components/ArticleView';
|
|
4
|
+
import { pageMetadata } from '../../../../lib/metadata';
|
|
4
5
|
import { listArticles } from '../../../../lib/shapio';
|
|
5
|
-
import { stringsFor, toLocale, type Locale } from '../../../../lib/site';
|
|
6
|
+
import { articlePath, stringsFor, toLocale, type Locale } from '../../../../lib/site';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Published articles only: a draft's URL is a 404. Articles published after the build render on their first
|
|
@@ -21,7 +22,15 @@ const findArticle = async (locale: Locale, slug: string) =>
|
|
|
21
22
|
export const generateMetadata = async ({ params }: ArticleParams): Promise<Metadata> => {
|
|
22
23
|
const { locale, slug } = await params;
|
|
23
24
|
const article = await findArticle(toLocale(locale), slug);
|
|
24
|
-
return article
|
|
25
|
+
return article
|
|
26
|
+
? pageMetadata({
|
|
27
|
+
title: article.title,
|
|
28
|
+
seo: article.seo,
|
|
29
|
+
type: 'article',
|
|
30
|
+
locale: toLocale(locale),
|
|
31
|
+
path: articlePath(locale, article.slug),
|
|
32
|
+
})
|
|
33
|
+
: {};
|
|
25
34
|
};
|
|
26
35
|
|
|
27
36
|
const ArticlePage = async ({ params }: ArticleParams) => {
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { ArticleCard } from '../../../components/ArticleCard';
|
|
3
|
+
import { pageMetadata } from '../../../lib/metadata';
|
|
3
4
|
import { listArticles } from '../../../lib/shapio';
|
|
4
|
-
import { stringsFor, toLocale } from '../../../lib/site';
|
|
5
|
+
import { articlesPath, stringsFor, toLocale } from '../../../lib/site';
|
|
5
6
|
|
|
6
7
|
type ArticlesParams = { params: Promise<{ locale: string }> };
|
|
7
8
|
|
|
8
|
-
export const generateMetadata = async ({ params }: ArticlesParams): Promise<Metadata> =>
|
|
9
|
-
|
|
10
|
-
});
|
|
9
|
+
export const generateMetadata = async ({ params }: ArticlesParams): Promise<Metadata> => {
|
|
10
|
+
const locale = toLocale((await params).locale);
|
|
11
|
+
return pageMetadata({ title: stringsFor(locale).articles, locale, path: articlesPath(locale) });
|
|
12
|
+
};
|
|
11
13
|
|
|
12
14
|
const ArticlesPage = async ({ params }: ArticlesParams) => {
|
|
13
15
|
const locale = toLocale((await params).locale);
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { RichText } from '../../../components/RichText';
|
|
3
|
+
import { pageMetadata } from '../../../lib/metadata';
|
|
3
4
|
import { getSiteSettings } from '../../../lib/shapio';
|
|
4
|
-
import { stringsFor, toLocale } from '../../../lib/site';
|
|
5
|
+
import { colophonPath, stringsFor, toLocale } from '../../../lib/site';
|
|
5
6
|
|
|
6
7
|
type ColophonParams = { params: Promise<{ locale: string }> };
|
|
7
8
|
|
|
8
|
-
export const generateMetadata = async ({ params }: ColophonParams): Promise<Metadata> =>
|
|
9
|
-
|
|
10
|
-
});
|
|
9
|
+
export const generateMetadata = async ({ params }: ColophonParams): Promise<Metadata> => {
|
|
10
|
+
const locale = toLocale((await params).locale);
|
|
11
|
+
return pageMetadata({ title: stringsFor(locale).colophon, locale, path: colophonPath(locale) });
|
|
12
|
+
};
|
|
11
13
|
|
|
12
14
|
/** The singleton page: the siteSettings entry's colophon, one per locale. */
|
|
13
15
|
const ColophonPage = async ({ params }: ColophonParams) => {
|
|
@@ -27,10 +27,14 @@ export const generateStaticParams = () => LOCALES.map((locale) => ({ locale }));
|
|
|
27
27
|
|
|
28
28
|
type LocaleParams = { params: Promise<{ locale: string }> };
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The fallback title and the icon. Every page sets its own complete title (`title.absolute`, already through
|
|
32
|
+
* the site's title template from Shapio's SEO defaults), so this layout applies no template of its own.
|
|
33
|
+
*/
|
|
30
34
|
export const generateMetadata = async ({ params }: LocaleParams): Promise<Metadata> => {
|
|
31
35
|
const locale = toLocale((await params).locale);
|
|
32
36
|
const siteName = (await getSiteSettings(locale))?.siteName ?? stringsFor(locale).siteName;
|
|
33
|
-
return { title:
|
|
37
|
+
return { title: siteName, icons: '/favicon.svg' };
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
type LocaleLayoutProps = LocaleParams & { children: ReactNode };
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { webhookSecret } from '../../../lib/config';
|
|
2
2
|
import { log } from '../../../lib/log';
|
|
3
|
-
import { revalidateChanges } from '../../../lib/revalidation';
|
|
4
|
-
import { REVALIDATING_EVENTS } from '../../../lib/revalidationTargets';
|
|
3
|
+
import { revalidateChanges, revalidateEveryPage } from '../../../lib/revalidation';
|
|
4
|
+
import { REVALIDATING_EVENTS, SITE_WIDE_EVENTS } from '../../../lib/revalidationTargets';
|
|
5
5
|
import { readWebhookEvent } from '../../../lib/webhookEvent';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* POST /api/revalidate/ (with the slash: `trailingSlash` redirects the other form, and Shapio's webhooks do not
|
|
9
9
|
* follow redirects): the target of the Shapio webhook the seed creates (publish, unpublish, delete,
|
|
10
|
-
* change set shipped, schema change). A signed delivery refreshes the pages whose content changed since the
|
|
10
|
+
* change set shipped, schema change, site settings). A signed delivery refreshes the pages whose content changed since the
|
|
11
11
|
* snapshot the site shows (src/lib/revalidation.ts). A failure answers 500, so Shapio retries the delivery.
|
|
12
12
|
*/
|
|
13
13
|
export const POST = async (request: Request) => {
|
|
@@ -21,6 +21,9 @@ export const POST = async (request: Request) => {
|
|
|
21
21
|
return Response.json({ event: event.type, ignored: true });
|
|
22
22
|
}
|
|
23
23
|
try {
|
|
24
|
+
if (SITE_WIDE_EVENTS.includes(event.type)) {
|
|
25
|
+
return Response.json({ event: event.type, ...revalidateEveryPage() });
|
|
26
|
+
}
|
|
24
27
|
return Response.json({ event: event.type, ...(await revalidateChanges()) });
|
|
25
28
|
} catch (error) {
|
|
26
29
|
log(
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - SHAPIO_WEBHOOK_SECRET: the signing secret of the Shapio webhook that calls /api/revalidate (runtime only;
|
|
9
9
|
* `npm run seed` writes it).
|
|
10
10
|
* - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
|
|
11
|
+
* - SITE_URL: optional; this site's public origin, e.g. https://www.example.com. Pages then carry a canonical
|
|
12
|
+
* URL (and `og:url`) unless the entry sets its own.
|
|
11
13
|
* - NEXT_PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL; inlined into
|
|
12
14
|
* the client bundle at build time).
|
|
13
15
|
*/
|
|
@@ -30,6 +32,8 @@ export const deliveryToken = () => {
|
|
|
30
32
|
|
|
31
33
|
export const siteKey = () => read('SHAPIO_SITE');
|
|
32
34
|
|
|
35
|
+
export const siteUrl = () => read('SITE_URL');
|
|
36
|
+
|
|
33
37
|
export const configuredSnapshot = (): number | undefined => {
|
|
34
38
|
// Spelled out (not read(name)): next.config.ts hands the pinned snapshot over through Next's `env`, which
|
|
35
39
|
// replaces this exact expression at build time.
|