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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/templates/astro/.env.example +2 -0
  3. package/templates/astro/package.json +2 -2
  4. package/templates/astro/scripts/lib/content.ts +46 -5
  5. package/templates/astro/scripts/seed.ts +26 -4
  6. package/templates/astro/scripts/smoke.ts +13 -0
  7. package/templates/astro/scripts/smokeHttp.ts +11 -1
  8. package/templates/astro/shapio/components/seo.json +114 -0
  9. package/templates/astro/shapio/models/article.json +21 -0
  10. package/templates/astro/shapio/models/page.json +21 -0
  11. package/templates/astro/src/layouts/Base.astro +49 -7
  12. package/templates/astro/src/lib/preview.ts +1 -0
  13. package/templates/astro/src/lib/seo.ts +69 -0
  14. package/templates/astro/src/lib/shapio.ts +23 -4
  15. package/templates/astro/src/lib/types.ts +7 -0
  16. package/templates/astro/src/pages/[locale]/[...slug].astro +1 -1
  17. package/templates/astro/src/pages/[locale]/articles/[slug].astro +1 -1
  18. package/templates/next/.env.example +2 -0
  19. package/templates/next/package.json +2 -2
  20. package/templates/next/scripts/lib/content.ts +46 -5
  21. package/templates/next/scripts/seed.ts +26 -4
  22. package/templates/next/scripts/smokeHttp.ts +11 -1
  23. package/templates/next/shapio/components/seo.json +114 -0
  24. package/templates/next/shapio/models/article.json +21 -0
  25. package/templates/next/shapio/models/page.json +21 -0
  26. package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +10 -2
  27. package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +11 -2
  28. package/templates/next/src/app/[locale]/articles/page.tsx +6 -4
  29. package/templates/next/src/app/[locale]/colophon/page.tsx +6 -4
  30. package/templates/next/src/app/[locale]/layout.tsx +5 -1
  31. package/templates/next/src/app/api/revalidate/route.ts +6 -3
  32. package/templates/next/src/lib/config.ts +4 -0
  33. package/templates/next/src/lib/metadata.ts +68 -0
  34. package/templates/next/src/lib/preview.ts +1 -0
  35. package/templates/next/src/lib/revalidation.ts +20 -1
  36. package/templates/next/src/lib/revalidationTargets.test.ts +11 -1
  37. package/templates/next/src/lib/revalidationTargets.ts +5 -0
  38. package/templates/next/src/lib/seo.ts +69 -0
  39. package/templates/next/src/lib/shapio.ts +24 -3
  40. package/templates/next/src/lib/types.ts +7 -0
  41. package/templates/sveltekit/.env.example +2 -0
  42. package/templates/sveltekit/package.json +2 -2
  43. package/templates/sveltekit/scripts/lib/content.ts +46 -5
  44. package/templates/sveltekit/scripts/seed.ts +26 -4
  45. package/templates/sveltekit/scripts/smokeHttp.ts +11 -1
  46. package/templates/sveltekit/shapio/components/seo.json +114 -0
  47. package/templates/sveltekit/shapio/models/article.json +21 -0
  48. package/templates/sveltekit/shapio/models/page.json +21 -0
  49. package/templates/sveltekit/src/env.ts +1 -0
  50. package/templates/sveltekit/src/lib/components/Seo.svelte +73 -0
  51. package/templates/sveltekit/src/lib/preview.ts +1 -0
  52. package/templates/sveltekit/src/lib/seo.ts +69 -0
  53. package/templates/sveltekit/src/lib/server/config.ts +5 -0
  54. package/templates/sveltekit/src/lib/server/shapio.ts +24 -3
  55. package/templates/sveltekit/src/lib/types.ts +7 -0
  56. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +8 -2
  57. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +2 -6
  58. package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +2 -3
  59. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +2 -6
  60. 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} description={page.description} locale={locale} alternatePath={pagePath(otherLocale(locale), page.slug)}>
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} description={article.excerpt} locale={locale} alternatePath={articlePath(otherLocale(locale), article.slug)}>
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
@@ -17,8 +17,8 @@
17
17
  "test": "vitest run"
18
18
  },
19
19
  "dependencies": {
20
- "@shapio/client": "^0.3.1",
21
- "@shapio/visual": "^0.3.1",
20
+ "@shapio/client": "^0.4.1",
21
+ "@shapio/visual": "^0.4.1",
22
22
  "next": "16.3.8",
23
23
  "react": "19.3.0",
24
24
  "react-dom": "19.3.0"
@@ -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: 'why-builds-pin-a-snapshot', content: snapshots(media, authorId), publish: true },
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
- scopes: definitions.map(() => 'site' as const),
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`), and that the
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 ? { title: page.title, description: page.description ?? undefined } : {};
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 ? { title: article.title, description: article.excerpt ?? undefined } : {};
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
- title: stringsFor(toLocale((await params).locale)).articles,
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
- title: stringsFor(toLocale((await params).locale)).colophon,
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: { template: `%s · ${siteName}`, default: siteName }, icons: '/favicon.svg' };
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.