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
@@ -0,0 +1,68 @@
1
+ import type { SeoResolved } from '@shapio/client';
2
+ import type { Metadata } from 'next';
3
+ import { siteUrl } from './config';
4
+ import { seoHead, type SeoHead } from './seo';
5
+ import { getSite } from './shapio';
6
+ import { DEFAULT_LOCALE, type Locale } from './site';
7
+
8
+ type PageMetadataInput = {
9
+ /** The page's own title; entry pages pass their SEO fields too, which win. */
10
+ title: string;
11
+ /** The entry's SEO fields, read with `seo=resolved`. */
12
+ seo?: SeoResolved | null;
13
+ type?: SeoHead['type'];
14
+ locale: Locale;
15
+ /** This page's path, e.g. `/en/articles/x/` (the canonical URL with SITE_URL). */
16
+ path: string;
17
+ };
18
+
19
+ /** Next's metadata from the SEO head: `title.absolute`, as the title already went through the site's template. */
20
+ export const toMetadata = (head: SeoHead): Metadata => {
21
+ const images = head.image
22
+ ? [
23
+ {
24
+ url: head.image.url,
25
+ ...(head.image.width ? { width: head.image.width } : {}),
26
+ ...(head.image.height ? { height: head.image.height } : {}),
27
+ ...(head.image.alt ? { alt: head.image.alt } : {}),
28
+ },
29
+ ]
30
+ : undefined;
31
+ return {
32
+ title: { absolute: head.title },
33
+ ...(head.description ? { description: head.description } : {}),
34
+ ...(head.canonical ? { alternates: { canonical: head.canonical } } : {}),
35
+ ...(head.noindex ? { robots: { index: false } } : {}),
36
+ openGraph: {
37
+ type: head.type,
38
+ title: head.title,
39
+ locale: head.locale,
40
+ ...(head.siteName ? { siteName: head.siteName } : {}),
41
+ ...(head.description ? { description: head.description } : {}),
42
+ ...(head.canonical ? { url: head.canonical } : {}),
43
+ ...(images ? { images } : {}),
44
+ },
45
+ twitter: {
46
+ card: head.image ? 'summary_large_image' : 'summary',
47
+ title: head.title,
48
+ ...(head.twitterHandle ? { site: head.twitterHandle } : {}),
49
+ ...(head.description ? { description: head.description } : {}),
50
+ ...(images ? { images } : {}),
51
+ },
52
+ };
53
+ };
54
+
55
+ /** A page's metadata: the entry's SEO fields over the site's SEO defaults (`GET /api/site`). */
56
+ export const pageMetadata = async (input: PageMetadataInput): Promise<Metadata> =>
57
+ toMetadata(
58
+ seoHead({
59
+ seo: input.seo ?? null,
60
+ site: await getSite(),
61
+ locale: input.locale,
62
+ localeChain: [input.locale, DEFAULT_LOCALE],
63
+ title: input.title,
64
+ path: input.path,
65
+ siteUrl: siteUrl(),
66
+ ...(input.type ? { type: input.type } : {}),
67
+ }),
68
+ );
@@ -45,6 +45,7 @@ export const loadDraft = async (shapioUrl: string, request: PreviewRequest): Pro
45
45
  const client = createClient({ baseUrl: shapioUrl, token: request.token });
46
46
  const params = new URLSearchParams({
47
47
  locale: request.locale,
48
+ richText: 'html',
48
49
  ...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
49
50
  });
50
51
  const { data } = await client.request<DeliveryItem<Page | Article>>(
@@ -3,7 +3,12 @@ import { revalidatePath } from 'next/cache';
3
3
  import { configuredSnapshot, isSnapshotPinned } from './config';
4
4
  import { advanceTo, liveSnapshot } from './liveSnapshot';
5
5
  import { log } from './log';
6
- import { revalidationTargets, type ChangedEntry, type RevalidationTarget } from './revalidationTargets';
6
+ import {
7
+ EVERY_PAGE,
8
+ revalidationTargets,
9
+ type ChangedEntry,
10
+ type RevalidationTarget,
11
+ } from './revalidationTargets';
7
12
  import { shapio } from './shapio';
8
13
 
9
14
  /**
@@ -89,3 +94,17 @@ export const revalidateChanges = (): Promise<RevalidationResult> => {
89
94
  return result;
90
95
  });
91
96
  };
97
+
98
+ /**
99
+ * Site-wide changes that publish no snapshot (the site's SEO defaults: `site.updated`): every page is
100
+ * revalidated at the snapshot the site already shows.
101
+ */
102
+ export const revalidateEveryPage = (): RevalidationResult => {
103
+ if (isSnapshotPinned()) {
104
+ return { skipped: `the build is pinned to snapshot ${configuredSnapshot()} by SHAPIO_SNAPSHOT` };
105
+ }
106
+ revalidatePath(EVERY_PAGE.path, EVERY_PAGE.type);
107
+ const at = liveSnapshot();
108
+ log(`Site settings changed: revalidated every page at snapshot ${at}`);
109
+ return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE] };
110
+ };
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { EVERY_PAGE, revalidationTargets } from './revalidationTargets';
2
+ import {
3
+ EVERY_PAGE,
4
+ REVALIDATING_EVENTS,
5
+ revalidationTargets,
6
+ SITE_WIDE_EVENTS,
7
+ } from './revalidationTargets';
3
8
 
4
9
  describe('revalidationTargets', () => {
5
10
  it('maps a changed article to its page and the locale’s article list', () => {
@@ -64,4 +69,9 @@ describe('revalidationTargets', () => {
64
69
  ),
65
70
  ).toEqual([]);
66
71
  });
72
+
73
+ it('treats a change to the site (its SEO defaults) as site-wide, without a snapshot diff', () => {
74
+ expect(REVALIDATING_EVENTS).toContain('site.updated');
75
+ expect(SITE_WIDE_EVENTS).toEqual(['site.updated']);
76
+ });
67
77
  });
@@ -22,10 +22,15 @@ export const REVALIDATING_EVENTS: readonly string[] = [
22
22
  'change_set.shipped',
23
23
  'schema.activated',
24
24
  'schema.deleted',
25
+ // The site's SEO defaults changed (no new snapshot): every page's head shows them.
26
+ 'site.updated',
25
27
  // "Send test" in Shapio's webhook screen: runs a real (usually empty) revalidation.
26
28
  'webhook.test',
27
29
  ];
28
30
 
31
+ /** Events that change every page without moving the publication snapshot (nothing to diff). */
32
+ export const SITE_WIDE_EVENTS: readonly string[] = ['site.updated'];
33
+
29
34
  const targetsOf = ({ modelKey, locale, slugs }: ChangedEntry): RevalidationTarget[] => {
30
35
  switch (modelKey) {
31
36
  case 'page':
@@ -0,0 +1,69 @@
1
+ import {
2
+ applyTitleTemplate,
3
+ seoDefaultsForLocale,
4
+ type DeliveryAsset,
5
+ type DeliverySite,
6
+ type SeoResolved,
7
+ } from '@shapio/client';
8
+
9
+ /**
10
+ * What a page puts in its <head> (title, description, Open Graph, Twitter card, canonical, robots), from an
11
+ * entry's SEO fields read with `?seo=resolved` and the site's SEO defaults (`GET /api/site`). Pages without an
12
+ * entry (listings, the colophon) pass their own title: it goes through the site's title template here.
13
+ */
14
+ export type SeoHead = {
15
+ title: string;
16
+ description: string | null;
17
+ image: { url: string; width: number | null; height: number | null; alt: string | null } | null;
18
+ /** The entry's canonical URL, else this page's URL on SITE_URL; null when neither is known. */
19
+ canonical: string | null;
20
+ noindex: boolean;
21
+ siteName: string | null;
22
+ twitterHandle: string | null;
23
+ /** `article` for articles, `website` for everything else (`og:type`). */
24
+ type: 'website' | 'article';
25
+ /** `og:locale`, e.g. `en` or `fr`. */
26
+ locale: string;
27
+ };
28
+
29
+ export type SeoHeadInput = {
30
+ /** The entry's resolved SEO fields; absent on pages without an entry. */
31
+ seo?: SeoResolved | null;
32
+ /** The site and its defaults; null when it could not be read (the page still renders a title). */
33
+ site: DeliverySite | null;
34
+ locale: string;
35
+ /** The locales whose defaults apply, in order (the page's locale, then the default one). */
36
+ localeChain: readonly string[];
37
+ /** The page's own title (entry pages: used only when the entry has no SEO fields). */
38
+ title: string;
39
+ /** This page's path, e.g. `/en/articles/x/`; with `siteUrl` it makes the canonical URL. */
40
+ path: string;
41
+ siteUrl?: string | undefined;
42
+ type?: SeoHead['type'];
43
+ };
44
+
45
+ const imageOf = (asset: DeliveryAsset | null | undefined): SeoHead['image'] =>
46
+ asset ? { url: asset.url, width: asset.width, height: asset.height, alt: asset.alt } : null;
47
+
48
+ const canonicalOf = (own: string | null | undefined, path: string, siteUrl: string | undefined) => {
49
+ if (own) {
50
+ return own;
51
+ }
52
+ return siteUrl ? new URL(path, siteUrl).href : null;
53
+ };
54
+
55
+ export const seoHead = (input: SeoHeadInput): SeoHead => {
56
+ const defaults = seoDefaultsForLocale(input.site?.seo ?? { locales: {} }, input.localeChain);
57
+ const { seo } = input;
58
+ return {
59
+ title: seo?.title ?? applyTitleTemplate(input.title, defaults.titleTemplate),
60
+ description: seo?.description ?? defaults.description ?? null,
61
+ image: imageOf(seo?.image ?? input.site?.seo.image),
62
+ canonical: canonicalOf(seo?.canonical, input.path, input.siteUrl),
63
+ noindex: seo?.noindex ?? false,
64
+ siteName: defaults.siteName ?? null,
65
+ twitterHandle: input.site?.seo.twitterHandle ?? null,
66
+ type: input.type ?? 'website',
67
+ locale: input.locale,
68
+ };
69
+ };
@@ -1,4 +1,4 @@
1
- import { createClient, ShapioApiError, type DeliveryListQuery } from '@shapio/client';
1
+ import { createClient, ShapioApiError, type DeliveryListQuery, type DeliverySite } from '@shapio/client';
2
2
  import { deliveryToken, shapioUrl, siteKey } from './config';
3
3
  import { liveSnapshot } from './liveSnapshot';
4
4
  import type { Locale } from './site';
@@ -18,8 +18,8 @@ const ARTICLES = 'articles';
18
18
  const SITE_SETTINGS = 'siteSettings';
19
19
 
20
20
  /** The fields each read renders (`fields=`): smaller responses, and accurate field usage in Shapio. */
21
- const PAGE_FIELDS = ['title', 'slug', 'description', 'sections'];
22
- const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn'];
21
+ const PAGE_FIELDS = ['title', 'slug', 'description', 'sections', 'seo'];
22
+ const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn', 'seo'];
23
23
  const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
24
24
 
25
25
  export const createShapio = () =>
@@ -36,6 +36,10 @@ const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T
36
36
  for (let page = 1; ; page += 1) {
37
37
  const result = await shapio().delivery.list<T>(routeKey, {
38
38
  ...query,
39
+ // The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
40
+ // its SEO fields with the site's defaults filled in.
41
+ richText: 'html',
42
+ seo: 'resolved',
39
43
  snapshot: snapshot(),
40
44
  page,
41
45
  pageSize: PAGE_SIZE,
@@ -64,6 +68,7 @@ export const getSiteSettings = async (locale: Locale): Promise<SiteSettings | nu
64
68
  locale,
65
69
  snapshot: snapshot(),
66
70
  fields: SITE_SETTINGS_FIELDS,
71
+ richText: 'html',
67
72
  });
68
73
  return data;
69
74
  } catch (error) {
@@ -73,3 +78,19 @@ export const getSiteSettings = async (locale: Locale): Promise<SiteSettings | nu
73
78
  throw error;
74
79
  }
75
80
  };
81
+
82
+ /**
83
+ * The site and its SEO defaults (name and title template per locale, default image, Twitter handle). Read on
84
+ * every render: a `site.updated` webhook revalidates every page, which then shows the new defaults. Null when
85
+ * the Shapio predates them (the pages still render, with plain titles).
86
+ */
87
+ export const getSite = async (): Promise<DeliverySite | null> => {
88
+ try {
89
+ return await shapio().site.get();
90
+ } catch (error) {
91
+ if (error instanceof ShapioApiError && error.status === 404) {
92
+ return null;
93
+ }
94
+ throw error;
95
+ }
96
+ };
@@ -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 the starter'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. */
@@ -8,3 +8,5 @@ SHAPIO_DELIVERY_TOKEN=
8
8
  # SHAPIO_SITE=marketing
9
9
  # Optional: the Shapio URL the browser calls for draft previews, when it differs from SHAPIO_URL.
10
10
  # PUBLIC_SHAPIO_URL=https://cms.example.com
11
+ # Optional: this site's public origin; pages then carry a canonical URL and og:url.
12
+ # SITE_URL=https://www.example.com
@@ -21,8 +21,8 @@
21
21
  "typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
22
22
  },
23
23
  "dependencies": {
24
- "@shapio/client": "^0.3.1",
25
- "@shapio/visual": "^0.3.1"
24
+ "@shapio/client": "^0.4.1",
25
+ "@shapio/visual": "^0.4.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@sveltejs/adapter-static": "4.0.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",