create-shapio 0.2.0

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 (161) hide show
  1. package/LICENSE +201 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +311 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +37 -0
  6. package/templates/astro/.env.example +10 -0
  7. package/templates/astro/README.md +31 -0
  8. package/templates/astro/astro.config.mjs +50 -0
  9. package/templates/astro/gitignore +4 -0
  10. package/templates/astro/package.json +31 -0
  11. package/templates/astro/public/favicon.svg +9 -0
  12. package/templates/astro/public/robots.txt +3 -0
  13. package/templates/astro/scripts/incrementalBuild.ts +142 -0
  14. package/templates/astro/scripts/lib/admin.ts +76 -0
  15. package/templates/astro/scripts/lib/callback.ts +46 -0
  16. package/templates/astro/scripts/lib/content.ts +462 -0
  17. package/templates/astro/scripts/lib/png.ts +47 -0
  18. package/templates/astro/scripts/reportStatus.ts +40 -0
  19. package/templates/astro/scripts/seed.ts +396 -0
  20. package/templates/astro/scripts/smoke.ts +127 -0
  21. package/templates/astro/scripts/smokeHttp.ts +162 -0
  22. package/templates/astro/shapio/components/callToAction.json +85 -0
  23. package/templates/astro/shapio/components/feature.json +46 -0
  24. package/templates/astro/shapio/components/featureGrid.json +51 -0
  25. package/templates/astro/shapio/components/gallery.json +52 -0
  26. package/templates/astro/shapio/components/hero.json +108 -0
  27. package/templates/astro/shapio/models/article.json +150 -0
  28. package/templates/astro/shapio/models/author.json +73 -0
  29. package/templates/astro/shapio/models/page.json +93 -0
  30. package/templates/astro/shapio/models/siteSettings.json +87 -0
  31. package/templates/astro/src/layouts/Base.astro +52 -0
  32. package/templates/astro/src/lib/config.ts +42 -0
  33. package/templates/astro/src/lib/preview.ts +57 -0
  34. package/templates/astro/src/lib/render.ts +146 -0
  35. package/templates/astro/src/lib/shapio.ts +104 -0
  36. package/templates/astro/src/lib/site.ts +55 -0
  37. package/templates/astro/src/lib/types.ts +96 -0
  38. package/templates/astro/src/pages/404.astro +8 -0
  39. package/templates/astro/src/pages/[locale]/[...slug].astro +28 -0
  40. package/templates/astro/src/pages/[locale]/articles/[slug].astro +25 -0
  41. package/templates/astro/src/pages/[locale]/articles/index.astro +22 -0
  42. package/templates/astro/src/pages/[locale]/colophon.astro +19 -0
  43. package/templates/astro/src/pages/build.json.ts +9 -0
  44. package/templates/astro/src/pages/index.astro +18 -0
  45. package/templates/astro/src/pages/preview.astro +21 -0
  46. package/templates/astro/src/scripts/preview.ts +47 -0
  47. package/templates/astro/src/styles/site.css +199 -0
  48. package/templates/astro/tsconfig.json +17 -0
  49. package/templates/next/.env.example +15 -0
  50. package/templates/next/README.md +69 -0
  51. package/templates/next/gitignore +5 -0
  52. package/templates/next/next.config.ts +69 -0
  53. package/templates/next/package.json +34 -0
  54. package/templates/next/public/favicon.svg +9 -0
  55. package/templates/next/scripts/lib/admin.ts +76 -0
  56. package/templates/next/scripts/lib/content.ts +462 -0
  57. package/templates/next/scripts/lib/png.ts +47 -0
  58. package/templates/next/scripts/seed.ts +396 -0
  59. package/templates/next/scripts/smokeHttp.ts +162 -0
  60. package/templates/next/shapio/components/callToAction.json +85 -0
  61. package/templates/next/shapio/components/feature.json +46 -0
  62. package/templates/next/shapio/components/featureGrid.json +51 -0
  63. package/templates/next/shapio/components/gallery.json +52 -0
  64. package/templates/next/shapio/components/hero.json +108 -0
  65. package/templates/next/shapio/models/article.json +150 -0
  66. package/templates/next/shapio/models/author.json +73 -0
  67. package/templates/next/shapio/models/page.json +93 -0
  68. package/templates/next/shapio/models/siteSettings.json +87 -0
  69. package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +40 -0
  70. package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +37 -0
  71. package/templates/next/src/app/[locale]/articles/page.tsx +28 -0
  72. package/templates/next/src/app/[locale]/colophon/page.tsx +25 -0
  73. package/templates/next/src/app/[locale]/layout.tsx +77 -0
  74. package/templates/next/src/app/api/revalidate/route.ts +31 -0
  75. package/templates/next/src/app/preview/layout.tsx +20 -0
  76. package/templates/next/src/app/preview/page.tsx +6 -0
  77. package/templates/next/src/app/site.css +199 -0
  78. package/templates/next/src/components/ArticleCard/index.tsx +19 -0
  79. package/templates/next/src/components/ArticleView/index.tsx +40 -0
  80. package/templates/next/src/components/Byline/index.tsx +15 -0
  81. package/templates/next/src/components/LocaleSwitch/index.tsx +16 -0
  82. package/templates/next/src/components/Preview/hooks/usePreviewDraft.ts +53 -0
  83. package/templates/next/src/components/Preview/index.tsx +33 -0
  84. package/templates/next/src/components/ResponsiveImage/index.tsx +36 -0
  85. package/templates/next/src/components/RichText/index.tsx +13 -0
  86. package/templates/next/src/components/Sections/CallToAction/index.tsx +14 -0
  87. package/templates/next/src/components/Sections/FeatureGrid/index.tsx +17 -0
  88. package/templates/next/src/components/Sections/Gallery/index.tsx +20 -0
  89. package/templates/next/src/components/Sections/Hero/index.tsx +31 -0
  90. package/templates/next/src/components/Sections/SectionView/index.tsx +30 -0
  91. package/templates/next/src/components/Sections/index.tsx +19 -0
  92. package/templates/next/src/lib/articles.ts +5 -0
  93. package/templates/next/src/lib/config.ts +57 -0
  94. package/templates/next/src/lib/liveSnapshot.ts +74 -0
  95. package/templates/next/src/lib/log.ts +4 -0
  96. package/templates/next/src/lib/preview.ts +56 -0
  97. package/templates/next/src/lib/revalidation.ts +91 -0
  98. package/templates/next/src/lib/revalidationTargets.test.ts +67 -0
  99. package/templates/next/src/lib/revalidationTargets.ts +66 -0
  100. package/templates/next/src/lib/shapio.ts +75 -0
  101. package/templates/next/src/lib/site.ts +66 -0
  102. package/templates/next/src/lib/types.ts +96 -0
  103. package/templates/next/src/lib/webhookEvent.test.ts +61 -0
  104. package/templates/next/src/lib/webhookEvent.ts +47 -0
  105. package/templates/next/src/styles.d.ts +3 -0
  106. package/templates/next/tsconfig.json +23 -0
  107. package/templates/sveltekit/.env.example +10 -0
  108. package/templates/sveltekit/README.md +38 -0
  109. package/templates/sveltekit/gitignore +4 -0
  110. package/templates/sveltekit/package.json +38 -0
  111. package/templates/sveltekit/scripts/lib/admin.ts +76 -0
  112. package/templates/sveltekit/scripts/lib/content.ts +462 -0
  113. package/templates/sveltekit/scripts/lib/png.ts +47 -0
  114. package/templates/sveltekit/scripts/seed.ts +396 -0
  115. package/templates/sveltekit/scripts/smokeHttp.ts +162 -0
  116. package/templates/sveltekit/shapio/components/callToAction.json +85 -0
  117. package/templates/sveltekit/shapio/components/feature.json +46 -0
  118. package/templates/sveltekit/shapio/components/featureGrid.json +51 -0
  119. package/templates/sveltekit/shapio/components/gallery.json +52 -0
  120. package/templates/sveltekit/shapio/components/hero.json +108 -0
  121. package/templates/sveltekit/shapio/models/article.json +150 -0
  122. package/templates/sveltekit/shapio/models/author.json +73 -0
  123. package/templates/sveltekit/shapio/models/page.json +93 -0
  124. package/templates/sveltekit/shapio/models/siteSettings.json +87 -0
  125. package/templates/sveltekit/src/app.html +12 -0
  126. package/templates/sveltekit/src/env.ts +16 -0
  127. package/templates/sveltekit/src/hooks.server.ts +9 -0
  128. package/templates/sveltekit/src/lib/articles.ts +5 -0
  129. package/templates/sveltekit/src/lib/components/ArticleCard.svelte +21 -0
  130. package/templates/sveltekit/src/lib/components/ArticleView.svelte +43 -0
  131. package/templates/sveltekit/src/lib/components/Byline.svelte +14 -0
  132. package/templates/sveltekit/src/lib/components/CallToAction.svelte +15 -0
  133. package/templates/sveltekit/src/lib/components/FeatureGrid.svelte +22 -0
  134. package/templates/sveltekit/src/lib/components/Gallery.svelte +25 -0
  135. package/templates/sveltekit/src/lib/components/Hero.svelte +24 -0
  136. package/templates/sveltekit/src/lib/components/ResponsiveImage.svelte +30 -0
  137. package/templates/sveltekit/src/lib/components/RichText.svelte +13 -0
  138. package/templates/sveltekit/src/lib/components/Sections.svelte +30 -0
  139. package/templates/sveltekit/src/lib/preview.ts +56 -0
  140. package/templates/sveltekit/src/lib/server/config.ts +46 -0
  141. package/templates/sveltekit/src/lib/server/shapio.ts +93 -0
  142. package/templates/sveltekit/src/lib/site.css +199 -0
  143. package/templates/sveltekit/src/lib/site.ts +66 -0
  144. package/templates/sveltekit/src/lib/types.ts +96 -0
  145. package/templates/sveltekit/src/routes/+layout.ts +3 -0
  146. package/templates/sveltekit/src/routes/+page.ts +7 -0
  147. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +16 -0
  148. package/templates/sveltekit/src/routes/[locale]/+layout.svelte +32 -0
  149. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.server.ts +28 -0
  150. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +15 -0
  151. package/templates/sveltekit/src/routes/[locale]/articles/+page.server.ts +9 -0
  152. package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +17 -0
  153. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.server.ts +25 -0
  154. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +15 -0
  155. package/templates/sveltekit/src/routes/[locale]/colophon/+page.svelte +17 -0
  156. package/templates/sveltekit/src/routes/[locale]/colophon/+page.ts +5 -0
  157. package/templates/sveltekit/src/routes/preview/+page.server.ts +5 -0
  158. package/templates/sveltekit/src/routes/preview/+page.svelte +67 -0
  159. package/templates/sveltekit/static/favicon.svg +9 -0
  160. package/templates/sveltekit/tsconfig.json +14 -0
  161. package/templates/sveltekit/vite.config.ts +63 -0
@@ -0,0 +1,31 @@
1
+ import type { ShapioAttributes } from '@shapio/visual';
2
+ import { safeHref } from '../../../lib/site';
3
+ import type { HeroSection } from '../../../lib/types';
4
+ import { ResponsiveImage } from '../../ResponsiveImage';
5
+
6
+ type HeroProps = { section: HeroSection; isFirst: boolean; visual?: ShapioAttributes };
7
+
8
+ export const Hero = ({ section, isFirst, visual }: HeroProps) => {
9
+ const Heading = isFirst ? 'h1' : 'h2';
10
+ return (
11
+ <section className="hero">
12
+ <div className="hero-text">
13
+ <Heading {...visual}>{section.heading}</Heading>
14
+ {section.subheading ? <p className="lead">{section.subheading}</p> : null}
15
+ {section.ctaLabel && section.ctaUrl ? (
16
+ <a className="button" href={safeHref(section.ctaUrl)}>
17
+ {section.ctaLabel}
18
+ </a>
19
+ ) : null}
20
+ </div>
21
+ {section.image ? (
22
+ <ResponsiveImage
23
+ media={section.image}
24
+ sizes="(min-width: 64rem) 50vw, 100vw"
25
+ eager={isFirst}
26
+ className="hero-image"
27
+ />
28
+ ) : null}
29
+ </section>
30
+ );
31
+ };
@@ -0,0 +1,30 @@
1
+ import { shapioAttr, type EntryRef } from '@shapio/visual';
2
+ import type { Section } from '../../../lib/types';
3
+ import { CallToAction } from '../CallToAction';
4
+ import { FeatureGrid } from '../FeatureGrid';
5
+ import { Gallery } from '../Gallery';
6
+ import { Hero } from '../Hero';
7
+
8
+ type SectionViewProps = { section: Section; index: number; entry: EntryRef };
9
+
10
+ export const SectionView = ({ section, index, entry }: SectionViewProps) => {
11
+ switch (section.__component) {
12
+ case 'hero':
13
+ return (
14
+ <Hero
15
+ section={section}
16
+ isFirst={index === 0}
17
+ visual={shapioAttr(entry, `sections/${index}/heading`)}
18
+ />
19
+ );
20
+ case 'featureGrid':
21
+ return <FeatureGrid section={section} />;
22
+ case 'gallery':
23
+ return <Gallery section={section} />;
24
+ case 'callToAction':
25
+ return <CallToAction section={section} />;
26
+ default:
27
+ // A section type this site does not know yet (added in Shapio after the last deploy): skipped.
28
+ return null;
29
+ }
30
+ };
@@ -0,0 +1,19 @@
1
+ import { shapioAttr } from '@shapio/visual';
2
+ import type { Page } from '../../lib/types';
3
+ import { SectionView } from './SectionView';
4
+
5
+ type SectionsProps = { page: Pick<Page, 'id' | 'title' | 'sections' | 'locale'> };
6
+
7
+ /** A page's dynamic zone. The page title is the heading when the page does not open with a hero. */
8
+ export const Sections = ({ page }: SectionsProps) => (
9
+ <div lang={page.locale}>
10
+ {page.sections[0]?.__component === 'hero' ? null : (
11
+ <h1 className="page-title" {...shapioAttr(page, 'title')}>
12
+ {page.title}
13
+ </h1>
14
+ )}
15
+ {page.sections.map((section, index) => (
16
+ <SectionView key={`${section.__component}-${index}`} section={section} index={index} entry={page} />
17
+ ))}
18
+ </div>
19
+ );
@@ -0,0 +1,5 @@
1
+ import type { Article, Author } from './types';
2
+
3
+ /** The populated author (`populate=author`); null when only the ID came back or the author is unpublished. */
4
+ export const authorOf = (article: Article): Author | null =>
5
+ typeof article.author === 'object' && article.author !== null ? article.author : null;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Build-time settings, from the environment (or `.env` in this folder, which `npm run seed` writes):
3
+ * - SHAPIO_URL: the Shapio origin (with BASE_PATH), e.g. https://cms.example.com.
4
+ * - SHAPIO_DELIVERY_TOKEN: a read-only delivery token (Settings → API tokens, delivery role).
5
+ * - SHAPIO_SNAPSHOT: optional; the publication snapshot to build. Without it, next.config.ts pins the latest
6
+ * snapshot once when `next build` starts, so every page of the build shows the same moment. When set, the
7
+ * site stays at that snapshot under `next start` too (on-demand revalidation is off).
8
+ * - SHAPIO_WEBHOOK_SECRET: the signing secret of the Shapio webhook that calls /api/revalidate (runtime only;
9
+ * `npm run seed` writes it).
10
+ * - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
11
+ * - NEXT_PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL; inlined into
12
+ * the client bundle at build time).
13
+ */
14
+ const read = (name: string): string | undefined => {
15
+ const value = process.env[name];
16
+ return value === undefined || value === '' ? undefined : value;
17
+ };
18
+
19
+ export const shapioUrl = () => read('SHAPIO_URL') ?? 'http://localhost:4300';
20
+
21
+ export const deliveryToken = () => {
22
+ const token = read('SHAPIO_DELIVERY_TOKEN');
23
+ if (!token) {
24
+ throw new Error(
25
+ 'Set SHAPIO_DELIVERY_TOKEN (a Shapio delivery token) to build the site; `npm run seed` writes one to .env',
26
+ );
27
+ }
28
+ return token;
29
+ };
30
+
31
+ export const siteKey = () => read('SHAPIO_SITE');
32
+
33
+ export const configuredSnapshot = (): number | undefined => {
34
+ // Spelled out (not read(name)): next.config.ts hands the pinned snapshot over through Next's `env`, which
35
+ // replaces this exact expression at build time.
36
+ const value = process.env.SHAPIO_SNAPSHOT;
37
+ if (value === undefined || value === '') {
38
+ return undefined;
39
+ }
40
+ const snapshot = Number(value);
41
+ if (!Number.isInteger(snapshot) || snapshot < 0) {
42
+ throw new Error(`SHAPIO_SNAPSHOT must be a publication snapshot number, got "${value}"`);
43
+ }
44
+ return snapshot;
45
+ };
46
+
47
+ /**
48
+ * True when the build was pinned with SHAPIO_SNAPSHOT (next.config.ts records it): the site then keeps
49
+ * showing that snapshot and /api/revalidate does nothing. Spelled out so Next inlines it at build time.
50
+ */
51
+ export const isSnapshotPinned = () => process.env.SHAPIO_SNAPSHOT_PINNED === 'true';
52
+
53
+ /** The secret /api/revalidate checks webhook signatures with; undefined when it is not set. */
54
+ export const webhookSecret = () => read('SHAPIO_WEBHOOK_SECRET');
55
+
56
+ /** The Shapio URL the preview page calls from the browser. Spelled out so Next inlines it into the client. */
57
+ export const publicShapioUrl = () => process.env.NEXT_PUBLIC_SHAPIO_URL || 'http://localhost:4300';
@@ -0,0 +1,74 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { configuredSnapshot, isSnapshotPinned } from './config';
4
+ import { log } from './log';
5
+
6
+ /**
7
+ * The publication snapshot the site reads. `next build` renders every page at the snapshot next.config.ts
8
+ * pinned; under `next start`, /api/revalidate moves the site forward to each new snapshot Shapio reports and
9
+ * revalidates the pages that changed, which then re-render at that snapshot.
10
+ *
11
+ * The last handled snapshot lives in memory (on `globalThis`, which the route handler and the pages share in
12
+ * the one `next start` process) and in `.next/shapio-revalidate.json`, so a restart carries on from it. The
13
+ * file sits in `.next/`, which `next build` empties, so a new build starts again from its own snapshot.
14
+ * A build pinned with SHAPIO_SNAPSHOT ignores both: it keeps showing that snapshot.
15
+ */
16
+ const STATE_FILE = join(process.cwd(), '.next', 'shapio-revalidate.json');
17
+
18
+ type LiveState = { snapshot: number | undefined; loaded: boolean };
19
+ const holder = globalThis as typeof globalThis & { __shapioLiveSnapshot?: LiveState };
20
+ const state = (): LiveState => (holder.__shapioLiveSnapshot ??= { snapshot: undefined, loaded: false });
21
+
22
+ const buildSnapshot = (): number => {
23
+ const pinned = configuredSnapshot();
24
+ if (pinned === undefined) {
25
+ throw new Error('No pinned snapshot: next.config.ts sets SHAPIO_SNAPSHOT when `next build` starts');
26
+ }
27
+ return pinned;
28
+ };
29
+
30
+ const readStateFile = (): number | undefined => {
31
+ try {
32
+ const { snapshot } = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { snapshot?: unknown };
33
+ return typeof snapshot === 'number' && Number.isInteger(snapshot) && snapshot >= 0 ? snapshot : undefined;
34
+ } catch (error) {
35
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
36
+ log(`Ignoring ${STATE_FILE} (${error instanceof Error ? error.message : String(error)})`);
37
+ }
38
+ return undefined;
39
+ }
40
+ };
41
+
42
+ /** The snapshot every delivery read sends: the build's, or the last one /api/revalidate handled. */
43
+ export const liveSnapshot = (): number => {
44
+ const built = buildSnapshot();
45
+ if (isSnapshotPinned()) {
46
+ return built;
47
+ }
48
+ const current = state();
49
+ if (!current.loaded) {
50
+ current.snapshot = readStateFile();
51
+ current.loaded = true;
52
+ }
53
+ return Math.max(built, current.snapshot ?? built);
54
+ };
55
+
56
+ /**
57
+ * Moves the site to `snapshot` while `revalidate` runs (pages it marks stale re-render at the new snapshot),
58
+ * then records it on disk. If `revalidate` throws, the site stays where it was.
59
+ */
60
+ export const advanceTo = async (snapshot: number, revalidate: () => void | Promise<void>) => {
61
+ const current = state();
62
+ const previous = liveSnapshot();
63
+ current.snapshot = snapshot;
64
+ try {
65
+ await revalidate();
66
+ } catch (error) {
67
+ current.snapshot = previous;
68
+ throw error;
69
+ }
70
+ mkdirSync(dirname(STATE_FILE), { recursive: true });
71
+ const temporary = `${STATE_FILE}.${process.pid}.tmp`;
72
+ writeFileSync(temporary, `${JSON.stringify({ snapshot, updatedAt: new Date().toISOString() })}\n`);
73
+ renameSync(temporary, STATE_FILE);
74
+ };
@@ -0,0 +1,4 @@
1
+ /** Server-side log lines (`next start` prints them with its own output). */
2
+ export const log = (line: string) => {
3
+ process.stderr.write(`[shapio] ${line}\n`);
4
+ };
@@ -0,0 +1,56 @@
1
+ import { createClient } from '@shapio/client';
2
+ import { DEFAULT_LOCALE, isLocale, type Locale } from './site';
3
+ import type { Article, DeliveryItem, Page } from './types';
4
+
5
+ /**
6
+ * Preview: Shapio opens `/preview/?model={modelKey}&id={entryId}&locale={locale}#token={token}` (the
7
+ * connection's preview URL template). `{modelKey}` is the model's route key (`pages`, `articles`). The token
8
+ * sits in the fragment, so it never reaches a server log or a Referer header; it is a scoped, expiring preview
9
+ * token, never an admin credential. The draft is read in the browser from `/api/preview/content/...` and
10
+ * rendered with the same components as the published pages.
11
+ */
12
+ export const PREVIEW_MODELS = ['pages', 'articles'] as const;
13
+
14
+ export type PreviewRequest = {
15
+ token: string;
16
+ modelKey: (typeof PREVIEW_MODELS)[number];
17
+ entryId: string;
18
+ locale: Locale;
19
+ };
20
+
21
+ export type PreviewDraft =
22
+ | { modelKey: 'pages'; entry: Page; locale: Locale }
23
+ | { modelKey: 'articles'; entry: Article; locale: Locale };
24
+
25
+ /** Reads the request from a preview URL; undefined when something is missing or unknown. */
26
+ export const parsePreviewUrl = (url: URL): PreviewRequest | undefined => {
27
+ const fragment = new URLSearchParams(url.hash.slice(1));
28
+ const token = fragment.get('token') ?? url.searchParams.get('token');
29
+ const modelKey = url.searchParams.get('model');
30
+ const entryId = url.searchParams.get('id');
31
+ const locale = url.searchParams.get('locale') ?? DEFAULT_LOCALE;
32
+ if (!token || !entryId || !(PREVIEW_MODELS as readonly string[]).includes(modelKey ?? '')) {
33
+ return undefined;
34
+ }
35
+ return {
36
+ token,
37
+ modelKey: modelKey as PreviewRequest['modelKey'],
38
+ entryId,
39
+ locale: isLocale(locale) ? locale : DEFAULT_LOCALE,
40
+ };
41
+ };
42
+
43
+ /** Fetches the draft through the preview API. */
44
+ export const loadDraft = async (shapioUrl: string, request: PreviewRequest): Promise<PreviewDraft> => {
45
+ const client = createClient({ baseUrl: shapioUrl, token: request.token });
46
+ const params = new URLSearchParams({
47
+ locale: request.locale,
48
+ ...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
49
+ });
50
+ const { data } = await client.request<DeliveryItem<Page | Article>>(
51
+ `/api/preview/content/${encodeURIComponent(request.modelKey)}/${encodeURIComponent(request.entryId)}?${params.toString()}`,
52
+ );
53
+ return request.modelKey === 'pages'
54
+ ? { modelKey: 'pages', entry: data as Page, locale: request.locale }
55
+ : { modelKey: 'articles', entry: data as Article, locale: request.locale };
56
+ };
@@ -0,0 +1,91 @@
1
+ import { ShapioApiError, type SnapshotChange, type SnapshotChangeKind } from '@shapio/client';
2
+ import { revalidatePath } from 'next/cache';
3
+ import { configuredSnapshot, isSnapshotPinned } from './config';
4
+ import { advanceTo, liveSnapshot } from './liveSnapshot';
5
+ import { log } from './log';
6
+ import { revalidationTargets, type ChangedEntry, type RevalidationTarget } from './revalidationTargets';
7
+ import { shapio } from './shapio';
8
+
9
+ /**
10
+ * On-demand revalidation (`next start`): asks Shapio what changed between the snapshot the site shows and
11
+ * the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and moves the site to
12
+ * the new snapshot. The diff, not the event, is the source of truth: one call covers a change set's many
13
+ * entries, retried or missed deliveries, and both locales of a publish.
14
+ */
15
+ export type RevalidationResult =
16
+ { from: number; to: number; changed: number; revalidated: RevalidationTarget[] } | { skipped: string };
17
+
18
+ /** Models whose pages are addressed by slug. */
19
+ const SLUGGED_MODELS: ReadonlySet<string> = new Set(['page', 'article']);
20
+
21
+ /** The entry's slug in `locale` at `snapshot`; null when it was not published there. */
22
+ const slugAt = async (change: SnapshotChange, locale: string, snapshot: number): Promise<string | null> => {
23
+ try {
24
+ const { data } = await shapio().delivery.get<{ slug?: unknown }>(change.routeKey, change.id, {
25
+ locale,
26
+ snapshot,
27
+ fields: ['slug'],
28
+ });
29
+ return typeof data.slug === 'string' ? data.slug : null;
30
+ } catch (error) {
31
+ if (error instanceof ShapioApiError && error.status === 404) {
32
+ return null;
33
+ }
34
+ throw error;
35
+ }
36
+ };
37
+
38
+ /** Where the entry was (unpublished), is (published), or both (updated: the slug may have changed). */
39
+ const slugSnapshots = (kind: SnapshotChangeKind, from: number, to: number): number[] =>
40
+ kind === 'published' ? [to] : kind === 'unpublished' ? [from] : [from, to];
41
+
42
+ const changedEntries = async (items: readonly SnapshotChange[], from: number, to: number) => {
43
+ const lookups = items.flatMap((change) =>
44
+ change.locales.map(async ({ locale, change: kind }): Promise<ChangedEntry> => {
45
+ if (!SLUGGED_MODELS.has(change.modelKey)) {
46
+ return { modelKey: change.modelKey, locale, slugs: [] };
47
+ }
48
+ const slugs = await Promise.all(slugSnapshots(kind, from, to).map((at) => slugAt(change, locale, at)));
49
+ const found = slugs.filter((slug): slug is string => slug !== null);
50
+ return { modelKey: change.modelKey, locale, slugs: [...new Set(found)] };
51
+ }),
52
+ );
53
+ return Promise.all(lookups);
54
+ };
55
+
56
+ const revalidateNow = async (): Promise<RevalidationResult> => {
57
+ if (isSnapshotPinned()) {
58
+ return { skipped: `the build is pinned to snapshot ${configuredSnapshot()} by SHAPIO_SNAPSHOT` };
59
+ }
60
+ const from = liveSnapshot();
61
+ const { snapshot: to } = await shapio().snapshots.current();
62
+ if (to <= from) {
63
+ return { from, to: from, changed: 0, revalidated: [] };
64
+ }
65
+ const diff = await shapio().snapshots.allChanges({ from, to });
66
+ const { schemaVersions } = diff;
67
+ const schemaChanged = schemaVersions.from === null || schemaVersions.from !== schemaVersions.to;
68
+ const targets = revalidationTargets(await changedEntries(diff.items, from, to), schemaChanged);
69
+ await advanceTo(to, () => {
70
+ for (const target of targets) {
71
+ revalidatePath(target.path, target.type);
72
+ }
73
+ });
74
+ return { from, to, changed: diff.items.length, revalidated: targets };
75
+ };
76
+
77
+ /** One run at a time: deliveries that arrive together diff from the snapshot the previous run reached. */
78
+ let queue: Promise<unknown> = Promise.resolve();
79
+
80
+ export const revalidateChanges = (): Promise<RevalidationResult> => {
81
+ const run = queue.then(revalidateNow, revalidateNow);
82
+ queue = run.catch(() => undefined);
83
+ return run.then((result) => {
84
+ log(
85
+ 'skipped' in result
86
+ ? `Revalidation skipped: ${result.skipped}`
87
+ : `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s)`,
88
+ );
89
+ return result;
90
+ });
91
+ };
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { EVERY_PAGE, revalidationTargets } from './revalidationTargets';
3
+
4
+ describe('revalidationTargets', () => {
5
+ it('maps a changed article to its page and the locale’s article list', () => {
6
+ expect(revalidationTargets([{ modelKey: 'article', locale: 'fr', slugs: ['hello'] }], false)).toEqual([
7
+ { path: '/fr/articles/' },
8
+ { path: '/fr/articles/hello/' },
9
+ ]);
10
+ });
11
+
12
+ it('revalidates both slugs of an article whose slug changed, and its list once', () => {
13
+ expect(
14
+ revalidationTargets(
15
+ [
16
+ { modelKey: 'article', locale: 'en', slugs: ['old', 'new'] },
17
+ { modelKey: 'article', locale: 'en', slugs: ['other'] },
18
+ ],
19
+ false,
20
+ ),
21
+ ).toEqual([
22
+ { path: '/en/articles/' },
23
+ { path: '/en/articles/new/' },
24
+ { path: '/en/articles/old/' },
25
+ { path: '/en/articles/other/' },
26
+ ]);
27
+ });
28
+
29
+ it('maps pages by slug, with home as the locale’s front page', () => {
30
+ expect(
31
+ revalidationTargets(
32
+ [
33
+ { modelKey: 'page', locale: 'en', slugs: ['home'] },
34
+ { modelKey: 'page', locale: 'fr', slugs: ['about'] },
35
+ ],
36
+ false,
37
+ ),
38
+ ).toEqual([{ path: '/en/' }, { path: '/fr/about/' }]);
39
+ });
40
+
41
+ it('refreshes the article list and every article page for an author', () => {
42
+ expect(revalidationTargets([{ modelKey: 'author', locale: 'en', slugs: [] }], false)).toEqual([
43
+ { path: '/[locale]/articles/[slug]', type: 'page' },
44
+ { path: '/en/articles/' },
45
+ ]);
46
+ });
47
+
48
+ it('refreshes every page for the siteSettings singleton or a schema change', () => {
49
+ const page = { modelKey: 'page', locale: 'en', slugs: ['about'] };
50
+ expect(revalidationTargets([page, { modelKey: 'siteSettings', locale: 'en', slugs: [] }], false)).toEqual(
51
+ [EVERY_PAGE],
52
+ );
53
+ expect(revalidationTargets([page], true)).toEqual([EVERY_PAGE]);
54
+ });
55
+
56
+ it('ignores models the site does not render and entries without a slug', () => {
57
+ expect(
58
+ revalidationTargets(
59
+ [
60
+ { modelKey: 'product', locale: 'en', slugs: ['x'] },
61
+ { modelKey: 'page', locale: 'en', slugs: [] },
62
+ ],
63
+ false,
64
+ ),
65
+ ).toEqual([]);
66
+ });
67
+ });
@@ -0,0 +1,66 @@
1
+ import { articlePath, articlesPath, pagePath } from './site';
2
+
3
+ /**
4
+ * What a set of Shapio changes means for this site's pages: the arguments for Next's `revalidatePath`. Pure,
5
+ * so the mapping is unit-tested; src/lib/revalidation.ts feeds it from the snapshot diff.
6
+ */
7
+ export type RevalidationTarget = { path: string; type?: 'page' | 'layout' };
8
+
9
+ /** One entry's change in one locale, with its slugs (where it was and where it is; empty for models without). */
10
+ export type ChangedEntry = { modelKey: string; locale: string; slugs: readonly string[] };
11
+
12
+ /** Every page: the root layout holds them all (the siteSettings singleton is on each, in its header and footer). */
13
+ export const EVERY_PAGE: RevalidationTarget = { path: '/', type: 'layout' };
14
+ /** Every article page in every locale (each shows its author). */
15
+ const EVERY_ARTICLE: RevalidationTarget = { path: '/[locale]/articles/[slug]', type: 'page' };
16
+
17
+ /** The webhook events that can change what the site shows; anything else is acknowledged and ignored. */
18
+ export const REVALIDATING_EVENTS: readonly string[] = [
19
+ 'entry.published',
20
+ 'entry.unpublished',
21
+ 'entry.deleted',
22
+ 'change_set.shipped',
23
+ 'schema.activated',
24
+ 'schema.deleted',
25
+ // "Send test" in Shapio's webhook screen: runs a real (usually empty) revalidation.
26
+ 'webhook.test',
27
+ ];
28
+
29
+ const targetsOf = ({ modelKey, locale, slugs }: ChangedEntry): RevalidationTarget[] => {
30
+ switch (modelKey) {
31
+ case 'page':
32
+ return slugs.map((slug) => ({ path: pagePath(locale, slug) }));
33
+ case 'article':
34
+ return [{ path: articlesPath(locale) }, ...slugs.map((slug) => ({ path: articlePath(locale, slug) }))];
35
+ case 'author':
36
+ return [{ path: articlesPath(locale) }, EVERY_ARTICLE];
37
+ case 'siteSettings':
38
+ return [EVERY_PAGE];
39
+ default:
40
+ // A model this site does not render.
41
+ return [];
42
+ }
43
+ };
44
+
45
+ const keyOf = (target: RevalidationTarget) => `${target.type ?? ''} ${target.path}`;
46
+
47
+ /**
48
+ * The pages to revalidate, without duplicates. A schema change between the two snapshots (fields added,
49
+ * converted or removed without a republish) or a change to every page collapses to revalidating everything.
50
+ */
51
+ export const revalidationTargets = (
52
+ entries: readonly ChangedEntry[],
53
+ schemaChanged: boolean,
54
+ ): RevalidationTarget[] => {
55
+ if (schemaChanged) {
56
+ return [EVERY_PAGE];
57
+ }
58
+ const targets = new Map<string, RevalidationTarget>();
59
+ for (const target of entries.flatMap(targetsOf)) {
60
+ targets.set(keyOf(target), target);
61
+ }
62
+ if (targets.has(keyOf(EVERY_PAGE))) {
63
+ return [EVERY_PAGE];
64
+ }
65
+ return [...targets.values()].sort((a, b) => a.path.localeCompare(b.path));
66
+ };
@@ -0,0 +1,75 @@
1
+ import { createClient, ShapioApiError, type DeliveryListQuery } from '@shapio/client';
2
+ import { deliveryToken, shapioUrl, siteKey } from './config';
3
+ import { liveSnapshot } from './liveSnapshot';
4
+ import type { Locale } from './site';
5
+ import type { Article, Page, SiteSettings } from './types';
6
+
7
+ /**
8
+ * The site's read side: Shapio's delivery API through `@shapio/client`, at one publication snapshot. Next
9
+ * renders pages in several worker processes, so the snapshot is pinned once in next.config.ts (it sets
10
+ * SHAPIO_SNAPSHOT for the whole build) and every request here sends it. Under `next start`, /api/revalidate
11
+ * moves that snapshot forward (src/lib/liveSnapshot.ts).
12
+ */
13
+ const PAGE_SIZE = 100;
14
+
15
+ /** Delivery addresses a collection by its plural API ID, a singleton by its API ID. */
16
+ const PAGES = 'pages';
17
+ const ARTICLES = 'articles';
18
+ const SITE_SETTINGS = 'siteSettings';
19
+
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'];
23
+ const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
24
+
25
+ export const createShapio = () =>
26
+ createClient({ baseUrl: shapioUrl(), token: deliveryToken(), site: siteKey() });
27
+
28
+ let client: ReturnType<typeof createShapio> | undefined;
29
+ export const shapio = () => (client ??= createShapio());
30
+
31
+ const snapshot = liveSnapshot;
32
+
33
+ /** Every published entry of a collection in one locale, page by page, at the pinned snapshot. */
34
+ const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
35
+ const entries: T[] = [];
36
+ for (let page = 1; ; page += 1) {
37
+ const result = await shapio().delivery.list<T>(routeKey, {
38
+ ...query,
39
+ snapshot: snapshot(),
40
+ page,
41
+ pageSize: PAGE_SIZE,
42
+ });
43
+ entries.push(...result.data);
44
+ if (page >= result.meta.pagination.pageCount) {
45
+ return entries;
46
+ }
47
+ }
48
+ };
49
+
50
+ export const listPages = (locale: Locale) => listAll<Page>(PAGES, { locale, fields: PAGE_FIELDS });
51
+
52
+ export const listArticles = (locale: Locale) =>
53
+ listAll<Article>(ARTICLES, {
54
+ locale,
55
+ fields: ARTICLE_FIELDS,
56
+ populate: ['author'],
57
+ sort: [{ field: 'publishedOn', direction: 'desc' }],
58
+ });
59
+
60
+ /** The siteSettings singleton in one locale; null until it is published. */
61
+ export const getSiteSettings = async (locale: Locale): Promise<SiteSettings | null> => {
62
+ try {
63
+ const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
64
+ locale,
65
+ snapshot: snapshot(),
66
+ fields: SITE_SETTINGS_FIELDS,
67
+ });
68
+ return data;
69
+ } catch (error) {
70
+ if (error instanceof ShapioApiError && error.status === 404) {
71
+ return null;
72
+ }
73
+ throw error;
74
+ }
75
+ };
@@ -0,0 +1,66 @@
1
+ /** The site's locales (they must exist in Shapio: the seed creates `fr`; `en` is Shapio's default). */
2
+ export const LOCALES = ['en', 'fr'] as const;
3
+ export type Locale = (typeof LOCALES)[number];
4
+ export const DEFAULT_LOCALE: Locale = 'en';
5
+
6
+ /** The page whose slug is `home` is each locale's front page. */
7
+ export const HOME_SLUG = 'home';
8
+
9
+ export const isLocale = (value: string): value is Locale => (LOCALES as readonly string[]).includes(value);
10
+
11
+ /** Reads a route's `[locale]` segment; the layout answers 404 for any other value. */
12
+ export const toLocale = (value: string): Locale => (isLocale(value) ? value : DEFAULT_LOCALE);
13
+
14
+ const STRINGS = {
15
+ en: {
16
+ siteName: 'Northwind Studio',
17
+ articles: 'Journal',
18
+ colophon: 'Colophon',
19
+ by: 'By',
20
+ switchTo: 'Français',
21
+ skipToContent: 'Skip to content',
22
+ footer: 'Built with Shapio',
23
+ previewBanner: 'Preview: this is a draft, not the published page.',
24
+ previewLoading: 'Loading the draft…',
25
+ previewFailed: 'The preview could not be loaded',
26
+ previewIncomplete: 'This preview link is incomplete.',
27
+ },
28
+ fr: {
29
+ siteName: 'Studio Northwind',
30
+ articles: 'Journal',
31
+ colophon: 'Colophon',
32
+ by: 'Par',
33
+ switchTo: 'English',
34
+ skipToContent: 'Aller au contenu',
35
+ footer: 'Construit avec Shapio',
36
+ previewBanner: 'Aperçu : ceci est un brouillon, pas la page publiée.',
37
+ previewLoading: 'Chargement du brouillon…',
38
+ previewFailed: "L'aperçu n'a pas pu être chargé",
39
+ previewIncomplete: "Ce lien d'aperçu est incomplet.",
40
+ },
41
+ } as const satisfies Record<Locale, Record<string, string>>;
42
+
43
+ export type Strings = (typeof STRINGS)[Locale];
44
+
45
+ export const stringsFor = (locale: Locale): Strings => STRINGS[locale];
46
+
47
+ export const otherLocale = (locale: Locale): Locale => (locale === 'en' ? 'fr' : 'en');
48
+
49
+ export const formatDate = (iso: string, locale: string) =>
50
+ new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(iso));
51
+
52
+ export const pagePath = (locale: string, slug: string) =>
53
+ slug === HOME_SLUG ? `/${locale}/` : `/${locale}/${slug}/`;
54
+ export const articlesPath = (locale: string) => `/${locale}/articles/`;
55
+ export const articlePath = (locale: string, slug: string) => `/${locale}/articles/${slug}/`;
56
+ export const colophonPath = (locale: string) => `/${locale}/colophon/`;
57
+
58
+ /** The same path in another locale (slugs are shared across locales in this starter's models). */
59
+ export const switchLocale = (pathname: string, locale: Locale) =>
60
+ pathname.replace(/^\/(en|fr)(?=\/|$)/, `/${locale}`);
61
+
62
+ /** Links from content: web, mail, phone and site-relative only (a `javascript:` URL becomes `#`). */
63
+ export const safeHref = (value: string | null | undefined): string => {
64
+ const href = (value ?? '').trim();
65
+ return /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(href) ? href : '#';
66
+ };