create-shapio 0.4.1 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shapio",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold a new Shapio project (npx create-shapio my-cms) or a site starter (--site astro|next|sveltekit)",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://shapio.dev",
@@ -19,8 +19,8 @@
19
19
  "typecheck": "tsc -p tsconfig.json"
20
20
  },
21
21
  "dependencies": {
22
- "@shapio/client": "^0.4.1",
23
- "@shapio/visual": "^0.4.1",
22
+ "@shapio/client": "^0.5.0",
23
+ "@shapio/visual": "^0.5.0",
24
24
  "astro": "7.3.5"
25
25
  },
26
26
  "devDependencies": {
@@ -1,4 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { parseArgs } from 'node:util';
3
5
 
4
6
  /**
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
13
15
  * `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
14
16
  * Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
15
17
  * and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
18
+ *
19
+ * `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
20
+ * asset links removed: they name build-specific chunks), so two builds of a site can be compared with
21
+ * `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
16
22
  */
17
23
  const DRAFT_TITLE = 'Coming soon: our winter projects';
18
24
  const ARTICLE = 'modelling-without-a-deploy';
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
85
91
  return { status: response.status, html: await response.text() };
86
92
  };
87
93
 
94
+ /** The page as rendered: without scripts and framework asset links, which differ from build to build. */
95
+ const renderedMarkup = (html: string) =>
96
+ html
97
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
98
+ .replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
99
+ .replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
100
+
101
+ const savePage = async (directory: string, path: string, html: string) => {
102
+ const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
103
+ await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
104
+ };
105
+
88
106
  const REVALIDATE_PATH = '/api/revalidate/';
89
107
 
90
108
  const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
137
155
 
138
156
  const main = async () => {
139
157
  const { values } = parseArgs({
140
- options: { url: { type: 'string' }, revalidate: { type: 'boolean', default: false } },
158
+ options: {
159
+ url: { type: 'string' },
160
+ revalidate: { type: 'boolean', default: false },
161
+ save: { type: 'string' },
162
+ },
141
163
  });
142
164
  const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
165
+ if (values.save) {
166
+ await mkdir(values.save, { recursive: true });
167
+ }
143
168
  for (const page of PAGES) {
144
169
  const { status, html } = await fetchPage(origin, page.path);
145
170
  check(status === 200, `${page.path}: HTTP ${status}`);
171
+ if (values.save) {
172
+ await savePage(values.save, page.path, html);
173
+ }
146
174
  for (const [needle, label] of page.expect) {
147
175
  check(html.includes(needle), `${page.path}: ${label}`);
148
176
  }
@@ -15,3 +15,7 @@ NEXT_TELEMETRY_DISABLED=1
15
15
  # NEXT_PUBLIC_SHAPIO_URL=https://cms.example.com
16
16
  # Optional: this site's public origin; pages then carry a canonical URL and og:url.
17
17
  # SITE_URL=https://www.example.com
18
+ # Optional: read Shapio's delivery API in this server process instead of over HTTP (@shapio/local), from
19
+ # Shapio's own database. A read-only database role is enough. Previews still use SHAPIO_URL.
20
+ # SHAPIO_MODE=in-process
21
+ # DATABASE_URL=postgres://shapio_site:password@localhost:5432/shapio
@@ -22,16 +22,17 @@ npm test # unit tests (path mapping, signature check)
22
22
  `next.config.ts` pins the latest one when the build starts); `SHAPIO_SITE` picks the site on a multi-site
23
23
  instance. Next.js telemetry is off: `NEXT_TELEMETRY_DISABLED=1` in the npm scripts and `.env.example`.
24
24
 
25
- | File | Role |
26
- | ---------------------------- | ------------------------------------------------------------------- |
27
- | `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors` |
28
- | `src/lib/shapio.ts` | reads the delivery API with `@shapio/client` at the pinned snapshot |
29
- | `src/app/[locale]/…` | the layout (site settings), pages, articles and the colophon |
30
- | `src/components/…` | sections, articles and responsive images |
31
- | `src/app/preview/` | draft preview with visual editing (`@shapio/visual`) |
32
- | `src/app/api/revalidate/` | the webhook target: on-demand revalidation of changed pages |
33
- | `src/lib/revalidation*.ts` | the snapshot diff and which pages a change touches |
34
- | `shapio/`, `scripts/seed.ts` | the models and the seed |
25
+ | File | Role |
26
+ | ---------------------------- | ------------------------------------------------------------------ |
27
+ | `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors` |
28
+ | `src/lib/shapio.ts` | reads the delivery API at the pinned snapshot |
29
+ | `src/lib/shapioClient.ts` | over HTTP (`@shapio/client`) or in process (`@shapio/local`) |
30
+ | `src/app/[locale]/…` | the layout (site settings), pages, articles and the colophon |
31
+ | `src/components/…` | sections, articles and responsive images |
32
+ | `src/app/preview/` | draft preview with visual editing (`@shapio/visual`) |
33
+ | `src/app/api/revalidate/` | the webhook target: on-demand revalidation of changed pages |
34
+ | `src/lib/revalidation*.ts` | the snapshot diff, and which pages and cache tags a change touches |
35
+ | `shapio/`, `scripts/seed.ts` | the models and the seed |
35
36
 
36
37
  Preview: the seed creates a deployment connection named **Preview** that opens drafts at
37
38
  `http://localhost:3000/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
@@ -39,6 +40,18 @@ Preview: the seed creates a deployment connection named **Preview** that opens d
39
40
  re-render the draft ([visual editing](https://github.com/mybrokengnome/shapio/blob/main/documentation/visual-editing.md)). `NEXT_PUBLIC_SHAPIO_URL` sets the Shapio URL the browser
40
41
  calls, when it differs from `SHAPIO_URL`.
41
42
 
43
+ ## Reading in process
44
+
45
+ By default the site reads Shapio's delivery API over HTTP. With `SHAPIO_MODE=in-process` and `DATABASE_URL` set
46
+ to Shapio's own database (PostgreSQL or MySQL), it reads the same API as function calls in the Next.js server
47
+ process (`@shapio/local`), with no HTTP hop: the same delivery token, the same answers, the same pages.
48
+ `next.config.ts` lists `@shapio/local` in `serverExternalPackages`, so each server process opens one database
49
+ pool. Previews and the browser still use `SHAPIO_URL`, and the Shapio server keeps running (it applies schema
50
+ changes and sends the revalidation webhook). Install the same `@shapio/local` version as the server's release;
51
+ the scaffolded `package.json` pins it. Reads in process do not go through `fetch`, so the cache tags below do
52
+ not apply to them; `revalidatePath` still refreshes the pages. Guide:
53
+ [in-process delivery](https://github.com/mybrokengnome/shapio/blob/main/documentation/in-process.md).
54
+
42
55
  ## Incremental rebuilds (on-demand revalidation)
43
56
 
44
57
  With `npm run start`, a publish refreshes only the pages it changed, without a rebuild:
@@ -55,7 +68,11 @@ With `npm run start`, a publish refreshes only the pages it changed, without a r
55
68
  changed between the snapshot the site shows and the current one (`/api/snapshots/changes`), and calls
56
69
  `revalidatePath` for those pages: an article's page and its locale's list, a page by its slug (`home` is
57
70
  `/en/`), the list and every article page for an author, every page for the `siteSettings` singleton or a
58
- schema change. A changed slug refreshes the old and the new address.
71
+ schema change. A changed slug refreshes the old and the new address. Reads are tagged by `@shapio/client` with
72
+ the site, the model and the entry, so the route also calls `revalidateTag` for each changed model and entry;
73
+ `site.updated` and a schema change expire the site tag, which every read carries. Content reads are pinned to
74
+ a snapshot, so they sit in Next's data cache (`force-cache`) regardless; the client sets no cache mode, so
75
+ `site.get()` stays fresh on every build (the data cache outlives `next build`).
59
76
  3. The site moves to the new snapshot: revalidated pages re-render at it, and the snapshot is kept in
60
77
  `.next/shapio-revalidate.json` so a restart carries on from it. `next build` empties `.next/`, so a new build
61
78
  starts again from its own snapshot.
@@ -1,6 +1,6 @@
1
- import { createClient } from '@shapio/client';
2
1
  import type { NextConfig } from 'next';
3
2
  import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
3
+ import { createShapioClient } from './src/lib/shapioClient';
4
4
 
5
5
  /**
6
6
  * Pins one publication snapshot for the whole `next build`: Next renders pages in several worker processes, so
@@ -13,17 +13,15 @@ const pinSnapshot = async (): Promise<string> => {
13
13
  if (process.env.SHAPIO_SNAPSHOT) {
14
14
  return process.env.SHAPIO_SNAPSHOT;
15
15
  }
16
- const token = process.env.SHAPIO_DELIVERY_TOKEN;
17
- if (!token) {
18
- throw new Error('Set SHAPIO_DELIVERY_TOKEN to build the site; `npm run seed` writes one to .env');
16
+ // Over HTTP or in process, as the pages read (SHAPIO_MODE).
17
+ const client = createShapioClient();
18
+ try {
19
+ const { snapshot } = await client.snapshots.current();
20
+ return String(snapshot);
21
+ } finally {
22
+ // An in-process client holds database connections; this process has no further use for them.
23
+ await client.close?.();
19
24
  }
20
- const client = createClient({
21
- baseUrl: process.env.SHAPIO_URL || 'http://localhost:4300',
22
- token,
23
- site: process.env.SHAPIO_SITE || undefined,
24
- });
25
- const { snapshot } = await client.snapshots.current();
26
- return String(snapshot);
27
25
  };
28
26
 
29
27
  const originOf = (url: string) => {
@@ -47,6 +45,9 @@ const frameAncestors = () => {
47
45
 
48
46
  const nextConfig = async (phase: string): Promise<NextConfig> => ({
49
47
  trailingSlash: true,
48
+ // SHAPIO_MODE=in-process: loaded from node_modules once per server process (one database pool), not
49
+ // bundled into every route.
50
+ serverExternalPackages: ['@shapio/local'],
50
51
  headers: async () => [
51
52
  { source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: frameAncestors() }] },
52
53
  ],
@@ -17,8 +17,9 @@
17
17
  "test": "vitest run"
18
18
  },
19
19
  "dependencies": {
20
- "@shapio/client": "^0.4.1",
21
- "@shapio/visual": "^0.4.1",
20
+ "@shapio/client": "^0.5.0",
21
+ "@shapio/local": "0.5.0",
22
+ "@shapio/visual": "^0.5.0",
22
23
  "next": "16.3.8",
23
24
  "react": "19.3.0",
24
25
  "react-dom": "19.3.0"
@@ -1,4 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { parseArgs } from 'node:util';
3
5
 
4
6
  /**
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
13
15
  * `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
14
16
  * Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
15
17
  * and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
18
+ *
19
+ * `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
20
+ * asset links removed: they name build-specific chunks), so two builds of a site can be compared with
21
+ * `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
16
22
  */
17
23
  const DRAFT_TITLE = 'Coming soon: our winter projects';
18
24
  const ARTICLE = 'modelling-without-a-deploy';
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
85
91
  return { status: response.status, html: await response.text() };
86
92
  };
87
93
 
94
+ /** The page as rendered: without scripts and framework asset links, which differ from build to build. */
95
+ const renderedMarkup = (html: string) =>
96
+ html
97
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
98
+ .replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
99
+ .replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
100
+
101
+ const savePage = async (directory: string, path: string, html: string) => {
102
+ const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
103
+ await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
104
+ };
105
+
88
106
  const REVALIDATE_PATH = '/api/revalidate/';
89
107
 
90
108
  const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
137
155
 
138
156
  const main = async () => {
139
157
  const { values } = parseArgs({
140
- options: { url: { type: 'string' }, revalidate: { type: 'boolean', default: false } },
158
+ options: {
159
+ url: { type: 'string' },
160
+ revalidate: { type: 'boolean', default: false },
161
+ save: { type: 'string' },
162
+ },
141
163
  });
142
164
  const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
165
+ if (values.save) {
166
+ await mkdir(values.save, { recursive: true });
167
+ }
143
168
  for (const page of PAGES) {
144
169
  const { status, html } = await fetchPage(origin, page.path);
145
170
  check(status === 200, `${page.path}: HTTP ${status}`);
171
+ if (values.save) {
172
+ await savePage(values.save, page.path, html);
173
+ }
146
174
  for (const [needle, label] of page.expect) {
147
175
  check(html.includes(needle), `${page.path}: ${label}`);
148
176
  }
@@ -8,7 +8,9 @@ import { readWebhookEvent } from '../../../lib/webhookEvent';
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
10
  * change set shipped, schema change, site settings). A signed delivery refreshes the pages whose content changed since the
11
- * snapshot the site shows (src/lib/revalidation.ts). A failure answers 500, so Shapio retries the delivery.
11
+ * snapshot the site shows and expires the cached reads tagged with their models and entries; `site.updated` and
12
+ * schema changes expire the site tag, which every read carries (src/lib/revalidation.ts). A failure answers 500,
13
+ * so Shapio retries the delivery.
12
14
  */
13
15
  export const POST = async (request: Request) => {
14
16
  const read = await readWebhookEvent(request, webhookSecret());
@@ -12,6 +12,10 @@
12
12
  * URL (and `og:url`) unless the entry sets its own.
13
13
  * - NEXT_PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL; inlined into
14
14
  * the client bundle at build time).
15
+ * - SHAPIO_MODE: `http` (default) reads Shapio's delivery API over HTTP; `in-process` reads the same API as
16
+ * function calls in this server process (@shapio/local), from Shapio's database at DATABASE_URL. Previews
17
+ * and the browser still use SHAPIO_URL. See documentation/in-process.md.
18
+ * - DATABASE_URL: Shapio's own database (PostgreSQL or MySQL), for SHAPIO_MODE=in-process only.
15
19
  */
16
20
  const read = (name: string): string | undefined => {
17
21
  const value = process.env[name];
@@ -32,6 +36,24 @@ export const deliveryToken = () => {
32
36
 
33
37
  export const siteKey = () => read('SHAPIO_SITE');
34
38
 
39
+ export type ShapioMode = 'http' | 'in-process';
40
+
41
+ export const shapioMode = (): ShapioMode => {
42
+ const mode = read('SHAPIO_MODE') ?? 'http';
43
+ if (mode !== 'http' && mode !== 'in-process') {
44
+ throw new Error(`SHAPIO_MODE must be "http" or "in-process", got "${mode}"`);
45
+ }
46
+ return mode;
47
+ };
48
+
49
+ export const databaseUrl = () => {
50
+ const url = read('DATABASE_URL');
51
+ if (!url) {
52
+ throw new Error("SHAPIO_MODE=in-process reads Shapio's database: set DATABASE_URL to it");
53
+ }
54
+ return url;
55
+ };
56
+
35
57
  export const siteUrl = () => read('SITE_URL');
36
58
 
37
59
  export const configuredSnapshot = (): number | undefined => {
@@ -1,8 +1,9 @@
1
1
  import { ShapioApiError, type SnapshotChange, type SnapshotChangeKind } from '@shapio/client';
2
- import { revalidatePath } from 'next/cache';
3
- import { configuredSnapshot, isSnapshotPinned } from './config';
2
+ import { revalidatePath, revalidateTag } from 'next/cache';
3
+ import { configuredSnapshot, isSnapshotPinned, siteKey } from './config';
4
4
  import { advanceTo, liveSnapshot } from './liveSnapshot';
5
5
  import { log } from './log';
6
+ import { changeTags, siteTags } from './revalidationTags';
6
7
  import {
7
8
  EVERY_PAGE,
8
9
  revalidationTargets,
@@ -13,12 +14,21 @@ import { shapio } from './shapio';
13
14
 
14
15
  /**
15
16
  * On-demand revalidation (`next start`): asks Shapio what changed between the snapshot the site shows and
16
- * the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and moves the site to
17
- * the new snapshot. The diff, not the event, is the source of truth: one call covers a change set's many
18
- * entries, retried or missed deliveries, and both locales of a publish.
17
+ * the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and the cached reads
18
+ * tagged with their models and entries, and moves the site to the new snapshot. The diff, not the event, is the
19
+ * source of truth: one call covers a change set's many entries, retried or missed deliveries, and both locales
20
+ * of a publish.
19
21
  */
20
22
  export type RevalidationResult =
21
- { from: number; to: number; changed: number; revalidated: RevalidationTarget[] } | { skipped: string };
23
+ | { from: number; to: number; changed: number; revalidated: RevalidationTarget[]; tags: string[] }
24
+ | { skipped: string };
25
+
26
+ /** Expires the cached reads at once (not stale-while-revalidate): the next render reads the new content. */
27
+ const expireTags = (tags: readonly string[]) => {
28
+ for (const tag of tags) {
29
+ revalidateTag(tag, { expire: 0 });
30
+ }
31
+ };
22
32
 
23
33
  /** Models whose pages are addressed by slug. */
24
34
  const SLUGGED_MODELS: ReadonlySet<string> = new Set(['page', 'article']);
@@ -65,18 +75,20 @@ const revalidateNow = async (): Promise<RevalidationResult> => {
65
75
  const from = liveSnapshot();
66
76
  const { snapshot: to } = await shapio().snapshots.current();
67
77
  if (to <= from) {
68
- return { from, to: from, changed: 0, revalidated: [] };
78
+ return { from, to: from, changed: 0, revalidated: [], tags: [] };
69
79
  }
70
80
  const diff = await shapio().snapshots.allChanges({ from, to });
71
81
  const { schemaVersions } = diff;
72
82
  const schemaChanged = schemaVersions.from === null || schemaVersions.from !== schemaVersions.to;
73
83
  const targets = revalidationTargets(await changedEntries(diff.items, from, to), schemaChanged);
84
+ const tags = changeTags(diff.items, schemaChanged, siteKey());
74
85
  await advanceTo(to, () => {
86
+ expireTags(tags);
75
87
  for (const target of targets) {
76
88
  revalidatePath(target.path, target.type);
77
89
  }
78
90
  });
79
- return { from, to, changed: diff.items.length, revalidated: targets };
91
+ return { from, to, changed: diff.items.length, revalidated: targets, tags };
80
92
  };
81
93
 
82
94
  /** One run at a time: deliveries that arrive together diff from the snapshot the previous run reached. */
@@ -89,22 +101,25 @@ export const revalidateChanges = (): Promise<RevalidationResult> => {
89
101
  log(
90
102
  'skipped' in result
91
103
  ? `Revalidation skipped: ${result.skipped}`
92
- : `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s)`,
104
+ : `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s), ${result.tags.length} tag(s)`,
93
105
  );
94
106
  return result;
95
107
  });
96
108
  };
97
109
 
98
110
  /**
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.
111
+ * Site-wide changes that publish no snapshot (the site's SEO defaults: `site.updated`): the site tag expires
112
+ * every cached read (`site.get()` among them) and every page is revalidated at the snapshot the site already
113
+ * shows.
101
114
  */
102
115
  export const revalidateEveryPage = (): RevalidationResult => {
103
116
  if (isSnapshotPinned()) {
104
117
  return { skipped: `the build is pinned to snapshot ${configuredSnapshot()} by SHAPIO_SNAPSHOT` };
105
118
  }
119
+ const tags = siteTags(siteKey());
120
+ expireTags(tags);
106
121
  revalidatePath(EVERY_PAGE.path, EVERY_PAGE.type);
107
122
  const at = liveSnapshot();
108
123
  log(`Site settings changed: revalidated every page at snapshot ${at}`);
109
- return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE] };
124
+ return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE], tags };
110
125
  };
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { changeTags, siteTags } from './revalidationTags';
3
+
4
+ describe('changeTags', () => {
5
+ it("tags each changed entry's model and the entry, without duplicates", () => {
6
+ expect(
7
+ changeTags(
8
+ [
9
+ { routeKey: 'articles', id: 'a1' },
10
+ { routeKey: 'articles', id: 'a2' },
11
+ { routeKey: 'siteSettings', id: 's1' },
12
+ { routeKey: 'articles', id: 'a1' },
13
+ ],
14
+ false,
15
+ 'marketing',
16
+ ),
17
+ ).toEqual([
18
+ 'shapio:articles',
19
+ 'shapio:articles:a1',
20
+ 'shapio:articles:a2',
21
+ 'shapio:siteSettings',
22
+ 'shapio:siteSettings:s1',
23
+ ]);
24
+ });
25
+
26
+ it('revalidates the site tag (every read) after a schema change', () => {
27
+ expect(changeTags([{ routeKey: 'articles', id: 'a1' }], true, 'marketing')).toEqual([
28
+ 'shapio:site:marketing',
29
+ ]);
30
+ expect(changeTags([], true, undefined)).toEqual(['shapio:site']);
31
+ });
32
+
33
+ it('has nothing to revalidate when nothing changed', () => {
34
+ expect(changeTags([], false, undefined)).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe('siteTags', () => {
39
+ it("is the client's site tag: keyed when the site is configured, bare otherwise", () => {
40
+ expect(siteTags('marketing')).toEqual(['shapio:site:marketing']);
41
+ expect(siteTags(undefined)).toEqual(['shapio:site']);
42
+ });
43
+ });
@@ -0,0 +1,30 @@
1
+ import { shapioTags, type SnapshotChange } from '@shapio/client';
2
+
3
+ /**
4
+ * What a set of Shapio changes means for Next's data cache: the arguments for `revalidateTag`, matching the
5
+ * tags `@shapio/client` puts on every delivery read (`shapioTags`). Pure, so the mapping is unit-tested;
6
+ * src/lib/revalidation.ts feeds it from the snapshot diff, which names each changed entry by its route key.
7
+ */
8
+ export type ChangedEntryRef = Pick<SnapshotChange, 'routeKey' | 'id'>;
9
+
10
+ /** Every delivery read of the site carries this tag: the site's settings, and everything after a schema change. */
11
+ export const siteTags = (siteKey: string | undefined): string[] => [shapioTags.site(siteKey)];
12
+
13
+ /**
14
+ * The tags to revalidate for the entries that changed between two snapshots: each entry's model and the entry
15
+ * itself. A schema change (fields added, converted or removed) can change any read, so it revalidates the
16
+ * site tag, which every read carries.
17
+ */
18
+ export const changeTags = (
19
+ changes: readonly ChangedEntryRef[],
20
+ schemaChanged: boolean,
21
+ siteKey: string | undefined,
22
+ ): string[] => {
23
+ if (schemaChanged) {
24
+ return siteTags(siteKey);
25
+ }
26
+ const tags = new Set(
27
+ changes.flatMap(({ routeKey, id }) => [shapioTags.model(routeKey), shapioTags.entry(routeKey, id)]),
28
+ );
29
+ return [...tags].sort();
30
+ };
@@ -1,14 +1,25 @@
1
- import { createClient, ShapioApiError, type DeliveryListQuery, type DeliverySite } from '@shapio/client';
2
- import { deliveryToken, shapioUrl, siteKey } from './config';
1
+ import {
2
+ ShapioApiError,
3
+ type DeliveryListQuery,
4
+ type DeliverySite,
5
+ type ShapioDeliveryClient,
6
+ } from '@shapio/client';
3
7
  import { liveSnapshot } from './liveSnapshot';
8
+ import { createShapioClient } from './shapioClient';
4
9
  import type { Locale } from './site';
5
10
  import type { Article, Page, SiteSettings } from './types';
6
11
 
7
12
  /**
8
- * The site's read side: Shapio's delivery API through `@shapio/client`, at one publication snapshot. Next
13
+ * The site's read side: Shapio's delivery API (over HTTP, or in this process with SHAPIO_MODE=in-process:
14
+ * src/lib/shapioClient.ts), at one publication snapshot. Next
9
15
  * renders pages in several worker processes, so the snapshot is pinned once in next.config.ts (it sets
10
16
  * SHAPIO_SNAPSHOT for the whole build) and every request here sends it. Under `next start`, /api/revalidate
11
17
  * moves that snapshot forward (src/lib/liveSnapshot.ts).
18
+ *
19
+ * The client tags every read with the site, model and entry, and /api/revalidate expires the tags of what
20
+ * changed (src/lib/revalidationTags.ts) as well as the pages. No `next: { cache }` option: reads pinned to a
21
+ * snapshot are `force-cache` regardless, and `site.get()` (no snapshot) stays fresh on every build, since Next's
22
+ * data cache outlives `next build`.
12
23
  */
13
24
  const PAGE_SIZE = 100;
14
25
 
@@ -22,11 +33,8 @@ const PAGE_FIELDS = ['title', 'slug', 'description', 'sections', 'seo'];
22
33
  const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn', 'seo'];
23
34
  const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
24
35
 
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());
36
+ let client: ShapioDeliveryClient | undefined;
37
+ export const shapio = () => (client ??= createShapioClient());
30
38
 
31
39
  const snapshot = liveSnapshot;
32
40
 
@@ -80,8 +88,9 @@ export const getSiteSettings = async (locale: Locale): Promise<SiteSettings | nu
80
88
  };
81
89
 
82
90
  /**
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
91
+ * The site and its SEO defaults (name and title template per locale, default image, Twitter handle). Cached
92
+ * under the site tag: a `site.updated` webhook expires it and revalidates every page, which then shows the new
93
+ * defaults. Null when
85
94
  * the Shapio predates them (the pages still render, with plain titles).
86
95
  */
87
96
  export const getSite = async (): Promise<DeliverySite | null> => {
@@ -0,0 +1,17 @@
1
+ import { createClient, type ShapioDeliveryClient } from '@shapio/client';
2
+ import { createLocalClient } from '@shapio/local';
3
+ import { databaseUrl, deliveryToken, shapioMode, shapioUrl, siteKey } from './config';
4
+
5
+ /**
6
+ * The client the site reads with: Shapio's delivery API over HTTP (SHAPIO_MODE=http, the default), or the same
7
+ * API in this process (SHAPIO_MODE=in-process, `@shapio/local`), reading Shapio's database directly. Both
8
+ * expose the same `delivery`, `site` and `snapshots` calls and answer them identically, with the same delivery
9
+ * token deciding what the site may read.
10
+ */
11
+ export const createShapioClient = (): ShapioDeliveryClient & { close?: () => Promise<void> } => {
12
+ const site = siteKey();
13
+ if (shapioMode() === 'in-process') {
14
+ return createLocalClient({ databaseUrl: databaseUrl(), token: deliveryToken(), site });
15
+ }
16
+ return createClient({ baseUrl: shapioUrl(), token: deliveryToken(), site });
17
+ };
@@ -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.4.1",
25
- "@shapio/visual": "^0.4.1"
24
+ "@shapio/client": "^0.5.0",
25
+ "@shapio/visual": "^0.5.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@sveltejs/adapter-static": "4.0.0",
@@ -1,4 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { parseArgs } from 'node:util';
3
5
 
4
6
  /**
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
13
15
  * `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
14
16
  * Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
15
17
  * and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
18
+ *
19
+ * `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
20
+ * asset links removed: they name build-specific chunks), so two builds of a site can be compared with
21
+ * `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
16
22
  */
17
23
  const DRAFT_TITLE = 'Coming soon: our winter projects';
18
24
  const ARTICLE = 'modelling-without-a-deploy';
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
85
91
  return { status: response.status, html: await response.text() };
86
92
  };
87
93
 
94
+ /** The page as rendered: without scripts and framework asset links, which differ from build to build. */
95
+ const renderedMarkup = (html: string) =>
96
+ html
97
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
98
+ .replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
99
+ .replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
100
+
101
+ const savePage = async (directory: string, path: string, html: string) => {
102
+ const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
103
+ await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
104
+ };
105
+
88
106
  const REVALIDATE_PATH = '/api/revalidate/';
89
107
 
90
108
  const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
137
155
 
138
156
  const main = async () => {
139
157
  const { values } = parseArgs({
140
- options: { url: { type: 'string' }, revalidate: { type: 'boolean', default: false } },
158
+ options: {
159
+ url: { type: 'string' },
160
+ revalidate: { type: 'boolean', default: false },
161
+ save: { type: 'string' },
162
+ },
141
163
  });
142
164
  const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
165
+ if (values.save) {
166
+ await mkdir(values.save, { recursive: true });
167
+ }
143
168
  for (const page of PAGES) {
144
169
  const { status, html } = await fetchPage(origin, page.path);
145
170
  check(status === 200, `${page.path}: HTTP ${status}`);
171
+ if (values.save) {
172
+ await savePage(values.save, page.path, html);
173
+ }
146
174
  for (const [needle, label] of page.expect) {
147
175
  check(html.includes(needle), `${page.path}: ${label}`);
148
176
  }