create-shapio 0.5.1 → 0.5.3

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.5.1",
3
+ "version": "0.5.3",
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",
@@ -17,7 +17,8 @@ npm run smoke:http # checks the served pages over HTTP (in another terminal, wh
17
17
 
18
18
  `npm run seed` applies the models in `shapio/` to Shapio live, creates the content and writes `SHAPIO_URL` and
19
19
  `SHAPIO_DELIVERY_TOKEN` to `.env`. Other settings (`SHAPIO_SNAPSHOT`, `SHAPIO_SITE`, `PUBLIC_SHAPIO_URL`) are
20
- in `.env.example`. Astro's telemetry is turned off in the npm scripts.
20
+ in `.env.example`. Settings are read from the environment and `.env` only (not `.env.local` or
21
+ `.env.production`). Astro's telemetry is turned off in the npm scripts.
21
22
 
22
23
  Drafts mode, for your development server: set `SHAPIO_DRAFTS=true` in `.env` and `npm run dev` shows saved
23
24
  drafts instead of published content, fresh on every reload, with a **Drafts** badge on every page. Save in
@@ -28,6 +29,14 @@ without it fails with `DRAFTS_FORBIDDEN`). Never put that token or the flag in a
28
29
  (http://localhost:4321; needs `SHAPIO_ADMIN_EMAIL`/`PASSWORD` to save a test change, which it puts back). Guide:
29
30
  [drafts mode](https://github.com/mybrokengnome/shapio/blob/main/documentation/starters.md#drafts-mode).
30
31
 
32
+ Incremental builds: `npm run build` re-renders only the pages whose data or code changed and restores the rest
33
+ from the previous build (Astro's `experimental.incrementalBuild`; each page's `cacheKey` is a digest of what it
34
+ renders, `src/lib/cacheKey.ts`). The cache is `node_modules/.astro`: on Cloudflare Pages, turn on build caching
35
+ (Settings → Builds → Build cache) so it survives between builds; without it every build is a full one.
36
+ `npm run check:incremental` checks this against the seeded Shapio (it publishes one edit and puts it back).
37
+ `npm run build:incremental` is a local and CI tool on top: it skips the build entirely when nothing the site
38
+ reads changed since the `dist/` on disk, and always builds where there is none.
39
+
31
40
  Preview: the seed creates a deployment connection named **Preview** that opens drafts at
32
41
  `http://localhost:4321/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
33
42
  `CORS_ORIGINS`. In Shapio's preview pane, clicking the title, body or cover focuses that field and saves
@@ -47,4 +47,11 @@ export default defineConfig({
47
47
  build: { format: 'directory' },
48
48
  server: { headers: { 'Content-Security-Policy': FRAME_ANCESTORS } },
49
49
  integrations: [frameAncestorsHeaders],
50
+ /**
51
+ * Incremental builds: a page whose `cacheKey` (src/lib/cacheKey.ts) and code are unchanged is restored from
52
+ * the previous build instead of rendered. The cache lives in Astro's cacheDir, `node_modules/.astro`, which
53
+ * Cloudflare Pages build caching (Settings → Builds → Build cache) restores between builds. Do not set
54
+ * `cacheDir`: the cache would silently stop surviving there, and every build would render every page.
55
+ */
56
+ experimental: { incrementalBuild: true },
50
57
  });
@@ -17,12 +17,13 @@
17
17
  "smoke:http": "node --import tsx scripts/smokeHttp.ts --url http://localhost:4321",
18
18
  "report-status": "node --env-file-if-exists=.env --import tsx scripts/reportStatus.ts",
19
19
  "build:incremental": "node --env-file-if-exists=.env --import tsx scripts/incrementalBuild.ts",
20
+ "check:incremental": "node --env-file-if-exists=.env --import tsx scripts/incrementalCheck.ts",
20
21
  "typecheck": "tsc -p tsconfig.json",
21
22
  "test": "vitest run"
22
23
  },
23
24
  "dependencies": {
24
- "@shapio/client": "^0.5.1",
25
- "@shapio/visual": "^0.5.1",
25
+ "@shapio/client": "^0.5.3",
26
+ "@shapio/visual": "^0.5.3",
26
27
  "astro": "7.3.5"
27
28
  },
28
29
  "devDependencies": {
@@ -1,10 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { resolve } from 'node:path';
4
5
  import { createClient, ShapioApiError, type SnapshotChange } from '@shapio/client';
5
6
 
6
7
  /**
7
- * `npm run build:incremental [--dry-run]`: builds only when content changed.
8
+ * `npm run build:incremental [--dry-run]`: skips a build that would change nothing. A local and CI tool, for
9
+ * machines where `dist/` survives between builds.
8
10
  *
9
11
  * It asks Shapio what changed between the snapshot the last build showed (`dist/build.json`, or
10
12
  * SHAPIO_FROM_SNAPSHOT) and the current one (`/api/snapshots/changes`), prints the routes those changes
@@ -12,6 +14,11 @@ import { createClient, ShapioApiError, type SnapshotChange } from '@shapio/clien
12
14
  * schema change between the two snapshots, or no previous build, means a full rebuild. The printed routes
13
15
  * are what a server-rendered site would revalidate (documentation/snapshots.md shows `revalidatePath`).
14
16
  *
17
+ * The per-page work is Astro's: the build itself restores every page whose data and code are unchanged
18
+ * (`experimental.incrementalBuild`, src/lib/cacheKey.ts). This script only decides whether to build at all, and
19
+ * it never skips without a `dist/` to keep: on a host that starts every build from a fresh checkout (Cloudflare
20
+ * Pages, Netlify), a skipped build would leave nothing to deploy, so there it always builds.
21
+ *
15
22
  * Settings: SHAPIO_URL and SHAPIO_DELIVERY_TOKEN (as for `npm run build`), SHAPIO_FROM_SNAPSHOT (optional).
16
23
  */
17
24
  const DIST_BUILD_INFO = resolve(import.meta.dirname, '../dist/build.json');
@@ -66,6 +73,13 @@ const slugAt = async (change: SnapshotChange, locale: string, snapshot: number):
66
73
  }
67
74
  };
68
75
 
76
+ /**
77
+ * The models whose entries have a route of their own, found by slug. Any other model (a redirect type a user
78
+ * adds, say) maps to no route here: it may have no `slug` field, and Astro's incremental build still decides
79
+ * page by page what to rebuild.
80
+ */
81
+ const SLUG_ROUTED_MODELS = new Set(['page', 'article']);
82
+
69
83
  /** Site routes (src/pages) an entry change touches. */
70
84
  const routesOf = async (change: SnapshotChange, from: number, to: number): Promise<string[]> => {
71
85
  const routes: string[] = [];
@@ -74,6 +88,9 @@ const routesOf = async (change: SnapshotChange, from: number, to: number): Promi
74
88
  routes.push(`/${locale}/articles/`);
75
89
  continue;
76
90
  }
91
+ if (!SLUG_ROUTED_MODELS.has(change.modelKey)) {
92
+ continue;
93
+ }
77
94
  // An unpublished entry's route is where it was; the others are where they are now.
78
95
  const slug = await slugAt(change, locale, kind === 'unpublished' ? from : to);
79
96
  if (slug === null) {
@@ -108,7 +125,10 @@ const main = async () => {
108
125
  const say = (line: string) => process.stdout.write(`${line}\n`);
109
126
 
110
127
  if (from === undefined) {
111
- say(`No previous build: building everything at snapshot ${to}.`);
128
+ say(`No previous build: building at snapshot ${to}.`);
129
+ } else if (!existsSync(DIST_BUILD_INFO)) {
130
+ // SHAPIO_FROM_SNAPSHOT without the build it names: skipping would leave no dist/ to deploy.
131
+ say(`No dist/ from snapshot ${from} here: building at snapshot ${to}.`);
112
132
  } else if (from === to) {
113
133
  say(`Nothing published since snapshot ${from}: skipping the build.`);
114
134
  return;
@@ -125,7 +145,9 @@ const main = async () => {
125
145
  for (const change of diff.items) {
126
146
  (await routesOf(change, from, to)).forEach((route) => routes.add(route));
127
147
  }
128
- say(`Snapshots ${from} → ${to}: ${diff.items.length} changed entries; routes to refresh:`);
148
+ say(
149
+ `Snapshots ${from} → ${to}: ${diff.items.length} changed entries; the routes they touch (the build re-renders only pages whose data changed):`,
150
+ );
129
151
  [...routes].sort().forEach((route) => say(` ${route}`));
130
152
  }
131
153
  }
@@ -0,0 +1,218 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { cp, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative, resolve } from 'node:path';
5
+ import { createClient } from '@shapio/client';
6
+ import { connectAdmin, type AdminConnection } from './lib/admin.js';
7
+
8
+ /**
9
+ * `npm run check:incremental`, after `npm run seed`: checks Astro's incremental build end to end against a running
10
+ * Shapio. Each step runs `astro build`:
11
+ * 1. a full build (`--force`), kept as the reference;
12
+ * 2. the same snapshot again: every keyed page is restored, and dist/ matches the reference except build.json
13
+ * (its `builtAt` changes on every build);
14
+ * 3. the same content pinned with SHAPIO_SNAPSHOT (as a webhook-triggered build is): every keyed page is still
15
+ * restored, so no setting's value leaks into the code Astro hashes;
16
+ * 4. one article's English title edited and published: only that page re-renders among the keyed ones, with the
17
+ * new title (the title is put back afterwards);
18
+ * 5. the cache removed: a full build, nothing restored.
19
+ * Changes content (and puts it back): run it after anything else that reads the seeded site. Needs
20
+ * SHAPIO_ADMIN_EMAIL and SHAPIO_ADMIN_PASSWORD (or SHAPIO_TOKEN) to publish the edit, as the seed does.
21
+ */
22
+ const ROOT = resolve(import.meta.dirname, '..');
23
+ const DIST = join(ROOT, 'dist');
24
+ const CACHE_DIR = join(ROOT, 'node_modules', '.astro');
25
+ const MANIFEST = join(CACHE_DIR, 'incremental-build.json');
26
+ const ARTICLE = 'modelling-without-a-deploy';
27
+ const EDITED_PAGE = `en/articles/${ARTICLE}/index.html`;
28
+ /** Always rendered, and carries the build time: the only file two builds of one snapshot may differ in. */
29
+ const TIMESTAMPED = new Set(['build.json']);
30
+ /** How long a publish may take to become the current snapshot: polled, so a fast server costs nothing. */
31
+ const SNAPSHOT_WITHIN_MS = 30_000;
32
+ const POLL_EVERY_MS = 250;
33
+
34
+ const failures: string[] = [];
35
+ const check = (condition: boolean, message: string) => {
36
+ process.stdout.write(`${condition ? 'ok ' : 'FAIL'} ${message}\n`);
37
+ if (!condition) {
38
+ failures.push(message);
39
+ }
40
+ };
41
+ const say = (line: string) => process.stdout.write(`${line}\n`);
42
+
43
+ /** Runs `astro build` (with `npm run build`, as a site's host does) and returns its output. */
44
+ const build = (args: string[] = [], env: NodeJS.ProcessEnv = {}) =>
45
+ new Promise<string>((resolveBuild, reject) => {
46
+ const child = spawn('npm', ['run', 'build', '--', ...args], {
47
+ cwd: ROOT,
48
+ env: { ...process.env, ...env, FORCE_COLOR: '0', NO_COLOR: '1' },
49
+ });
50
+ let output = '';
51
+ child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
52
+ child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
53
+ child.on('error', reject);
54
+ child.on('exit', (code) =>
55
+ code === 0 ? resolveBuild(output) : reject(new Error(`astro build exited with ${code}:\n${output}`)),
56
+ );
57
+ });
58
+
59
+ type Manifest = { routes: Record<string, { paths: Record<string, { outputFile: string }> }> };
60
+
61
+ /** The output files of every keyed page (a page whose getStaticPaths entry has a cacheKey). */
62
+ const keyedFiles = async (): Promise<string[]> => {
63
+ const manifest = JSON.parse(await readFile(MANIFEST, 'utf8')) as Manifest;
64
+ return Object.values(manifest.routes)
65
+ .flatMap((route) => Object.values(route.paths).map((path) => path.outputFile))
66
+ .sort();
67
+ };
68
+
69
+ /** How a build treated each keyed page: Astro logs `├─ /<file> (restored)` for one it did not render. */
70
+ const outcomes = (output: string, files: string[]) => {
71
+ const lines = output.split('\n');
72
+ const restored = files.filter((file) =>
73
+ lines.some((line) => line.includes(` /${file} `) && /\((restored|cached)\)/.test(line)),
74
+ );
75
+ return { restored, rendered: files.filter((file) => !restored.includes(file)) };
76
+ };
77
+
78
+ const listTree = async (root: string): Promise<string[]> =>
79
+ (await readdir(root, { recursive: true, withFileTypes: true }))
80
+ .filter((entry) => entry.isFile())
81
+ .map((entry) => relative(root, join(entry.parentPath, entry.name)).split('\\').join('/'))
82
+ .sort();
83
+
84
+ /** Files that differ between two output directories (missing on either side, or different bytes). */
85
+ const differences = async (left: string, right: string): Promise<string[]> => {
86
+ const [a, b] = await Promise.all([listTree(left), listTree(right)]);
87
+ const all = [...new Set([...a, ...b])].sort();
88
+ const differing: string[] = [];
89
+ for (const file of all) {
90
+ if (!a.includes(file) || !b.includes(file)) {
91
+ differing.push(file);
92
+ continue;
93
+ }
94
+ const [x, y] = await Promise.all([readFile(join(left, file)), readFile(join(right, file))]);
95
+ if (!x.equals(y)) {
96
+ differing.push(file);
97
+ }
98
+ }
99
+ return differing;
100
+ };
101
+
102
+ const delivery = () => {
103
+ const token = process.env.SHAPIO_DELIVERY_TOKEN;
104
+ if (!token) {
105
+ throw new Error('Set SHAPIO_DELIVERY_TOKEN (npm run seed writes it to .env)');
106
+ }
107
+ return createClient({ baseUrl: process.env.SHAPIO_URL ?? 'http://localhost:4300', token });
108
+ };
109
+
110
+ /** Waits until the current snapshot is past `after` (a real condition, not a fixed wait). */
111
+ const waitForSnapshotAfter = async (after: number) => {
112
+ const deadline = Date.now() + SNAPSHOT_WITHIN_MS;
113
+ while (Date.now() < deadline) {
114
+ const { snapshot } = await delivery().snapshots.current();
115
+ if (snapshot > after) {
116
+ return snapshot;
117
+ }
118
+ await new Promise((resolveWait) => setTimeout(resolveWait, POLL_EVERY_MS));
119
+ }
120
+ throw new Error(`No snapshot after ${after} within ${SNAPSHOT_WITHIN_MS} ms of publishing`);
121
+ };
122
+
123
+ /** Saves and publishes the article's English title; returns a function that publishes the old one again. */
124
+ const publishTitle = async ({ client }: AdminConnection, title: string) => {
125
+ const [item] = (
126
+ await client.admin.content.list('article', { locale: 'en', filters: { slug: { $eq: ARTICLE } } })
127
+ ).items;
128
+ if (!item) {
129
+ throw new Error(`The seeded article "${ARTICLE}" is missing; run npm run seed first`);
130
+ }
131
+ const entry = await client.admin.content.get('article', item.id, { locale: 'en' });
132
+ const original = entry.data.title;
133
+ const publish = async (expectedVersion: number, value: unknown) => {
134
+ const before = (await delivery().snapshots.current()).snapshot;
135
+ const saved = await client.admin.content.update('article', item.id, {
136
+ locale: 'en',
137
+ expectedVersion,
138
+ data: { title: value },
139
+ });
140
+ await client.admin.content.publish('article', item.id, { locales: ['en'] });
141
+ await waitForSnapshotAfter(before);
142
+ return saved.version;
143
+ };
144
+ const version = await publish(entry.version, title);
145
+ return () => publish(version, original);
146
+ };
147
+
148
+ const main = async () => {
149
+ const reference = await mkdtemp(join(tmpdir(), 'shapio-astro-incremental-'));
150
+ const admin = await connectAdmin(process.env);
151
+ try {
152
+ say('1. Full build (--force)');
153
+ await build(['--force']);
154
+ await cp(DIST, reference, { recursive: true });
155
+ const keyed = await keyedFiles();
156
+ check(keyed.length > 0, `${keyed.length} keyed pages recorded in the cache`);
157
+
158
+ say('2. The same snapshot again');
159
+ const second = outcomes(await build(), keyed);
160
+ check(
161
+ second.rendered.length === 0,
162
+ `${second.restored.length}/${keyed.length} keyed pages restored${second.rendered.length > 0 ? `; rendered: ${second.rendered.join(', ')}` : ''}`,
163
+ );
164
+ const changed = await differences(reference, DIST);
165
+ check(
166
+ changed.every((file) => TIMESTAMPED.has(file)),
167
+ `dist/ matches the full build except build.json (differs: ${changed.join(', ') || 'nothing'})`,
168
+ );
169
+
170
+ say('3. The same content, pinned with SHAPIO_SNAPSHOT');
171
+ const { snapshot } = await delivery().snapshots.current();
172
+ const pinned = outcomes(await build([], { SHAPIO_SNAPSHOT: String(snapshot) }), keyed);
173
+ check(
174
+ pinned.rendered.length === 0,
175
+ `${pinned.restored.length}/${keyed.length} keyed pages restored with SHAPIO_SNAPSHOT=${snapshot}${pinned.rendered.length > 0 ? `; rendered: ${pinned.rendered.join(', ')}` : ''}`,
176
+ );
177
+
178
+ say('4. One article edited and published');
179
+ const marker = `Incremental check ${Date.now()}`;
180
+ const restore = await publishTitle(admin, marker);
181
+ try {
182
+ const third = outcomes(await build(), keyed);
183
+ check(
184
+ third.rendered.length === 1 && third.rendered[0] === EDITED_PAGE,
185
+ `only ${EDITED_PAGE} re-rendered among keyed pages (rendered: ${third.rendered.join(', ') || 'none'}; restored ${third.restored.length})`,
186
+ );
187
+ check(
188
+ (await readFile(join(DIST, EDITED_PAGE), 'utf8')).includes(marker),
189
+ `${EDITED_PAGE} has the new title`,
190
+ );
191
+ } finally {
192
+ await restore();
193
+ }
194
+
195
+ say('5. No cache');
196
+ await rm(CACHE_DIR, { recursive: true, force: true });
197
+ const fourth = await build();
198
+ check(!/\((restored|cached)\)/.test(fourth), 'a build without the cache renders every page');
199
+ const afterRestore = await differences(reference, DIST);
200
+ check(
201
+ afterRestore.every((file) => TIMESTAMPED.has(file)),
202
+ `with the title put back, dist/ matches the first build except build.json (differs: ${afterRestore.join(', ') || 'nothing'})`,
203
+ );
204
+ } finally {
205
+ await admin.close();
206
+ await rm(reference, { recursive: true, force: true });
207
+ }
208
+
209
+ if (failures.length > 0) {
210
+ throw new Error(`${failures.length} check(s) failed`);
211
+ }
212
+ say('Incremental build check passed.');
213
+ };
214
+
215
+ main().catch((error: unknown) => {
216
+ process.stderr.write(`check:incremental: ${error instanceof Error ? error.message : String(error)}\n`);
217
+ process.exitCode = 1;
218
+ });
@@ -1,9 +1,8 @@
1
1
  ---
2
2
  import '../styles/site.css';
3
3
  import type { SeoResolved } from '@shapio/client';
4
- import { isDraftsMode } from '../lib/config.js';
4
+ import { layoutInputs } from '../lib/layoutInputs.js';
5
5
  import { seoHead, type SeoHead } from '../lib/seo.js';
6
- import { getSite, getSiteSettings } from '../lib/shapio.js';
7
6
  import {
8
7
  articlesPath,
9
8
  colophonPath,
@@ -31,12 +30,13 @@ const { title, seo, type, locale, alternatePath, noindex = false } = Astro.props
31
30
  const strings = stringsFor(locale);
32
31
  const other = otherLocale(locale);
33
32
  // The name, tagline and footer come from the siteSettings singleton (the built-in strings until it is published).
34
- const settings = await getSiteSettings(locale);
33
+ // Read through layoutInputs, which keyed pages fold into their cacheKey (src/lib/cacheKey.ts).
34
+ const { settings, site, env } = await layoutInputs(locale);
35
35
  const siteName = settings?.siteName ?? strings.siteName;
36
36
  // Title, description, social tags and canonical: the entry's SEO fields over the site's SEO defaults.
37
37
  const head = seoHead({
38
38
  seo: seo ?? null,
39
- site: await getSite(),
39
+ site,
40
40
  locale,
41
41
  localeChain: [locale, DEFAULT_LOCALE],
42
42
  title,
@@ -92,7 +92,7 @@ const robotsNoindex = noindex || head.noindex;
92
92
  <p>{siteName} · {settings?.footer ?? 'Built with Shapio'} · Astro</p>
93
93
  </footer>
94
94
  {
95
- isDraftsMode() ? (
95
+ env.drafts ? (
96
96
  <p class="drafts-badge" title={strings.draftsNote} data-shapio-drafts="">
97
97
  {strings.drafts}
98
98
  </p>
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { buildEnvKey, cacheKeyOf } from './cacheKey.js';
3
+
4
+ describe('cacheKeyOf', () => {
5
+ it('is a 32-character hex digest', () => {
6
+ expect(cacheKeyOf({ title: 'Hello' })).toMatch(/^[0-9a-f]{32}$/);
7
+ });
8
+
9
+ it('does not depend on the order of object keys, at any depth', () => {
10
+ expect(cacheKeyOf({ a: 1, b: { c: 2, d: [{ e: 3, f: 4 }] } })).toBe(
11
+ cacheKeyOf({ b: { d: [{ f: 4, e: 3 }], c: 2 }, a: 1 }),
12
+ );
13
+ });
14
+
15
+ it('changes when any nested value changes', () => {
16
+ const page = { title: 'Home', sections: [{ heading: 'Hi', image: { url: '/a.webp' } }] };
17
+ const edited = { title: 'Home', sections: [{ heading: 'Hi', image: { url: '/b.webp' } }] };
18
+ expect(cacheKeyOf(page)).not.toBe(cacheKeyOf(edited));
19
+ });
20
+
21
+ it('is sensitive to array order', () => {
22
+ expect(cacheKeyOf([1, 2])).not.toBe(cacheKeyOf([2, 1]));
23
+ });
24
+
25
+ it('tells its inputs apart (a value moved from one argument to the next is a different key)', () => {
26
+ expect(cacheKeyOf({ a: 1 }, { b: 2 })).not.toBe(cacheKeyOf({ a: 1, b: 2 }));
27
+ });
28
+
29
+ it('reads undefined as null and a date as its ISO text', () => {
30
+ expect(cacheKeyOf({ a: undefined })).toBe(cacheKeyOf({ a: null }));
31
+ expect(cacheKeyOf({ at: new Date('2026-10-07T00:00:00.000Z') })).toBe(
32
+ cacheKeyOf({ at: '2026-10-07T00:00:00.000Z' }),
33
+ );
34
+ });
35
+
36
+ it('throws on what is not plain data, naming where it is', () => {
37
+ expect(() => cacheKeyOf({ render: () => '' })).toThrow('inputs[0].render is a function');
38
+ expect(() => cacheKeyOf({ id: Symbol('x') })).toThrow('is a symbol');
39
+ expect(() => cacheKeyOf({ size: 1n })).toThrow('is a bigint');
40
+ expect(() => cacheKeyOf({ tags: new Set(['a']) })).toThrow('is a Set');
41
+ });
42
+
43
+ it('throws on a value that refers back to itself, but accepts the same object twice', () => {
44
+ const shared = { name: 'Ada' };
45
+ expect(() => cacheKeyOf({ author: shared, editor: shared })).not.toThrow();
46
+ const loop: Record<string, unknown> = {};
47
+ loop.self = loop;
48
+ expect(() => cacheKeyOf(loop)).toThrow('refers back to itself');
49
+ });
50
+ });
51
+
52
+ describe('buildEnvKey', () => {
53
+ afterEach(() => {
54
+ vi.unstubAllEnvs();
55
+ });
56
+
57
+ it('carries drafts mode and the year, which a page shows without reading them from its data', () => {
58
+ vi.stubEnv('SHAPIO_DRAFTS', 'true');
59
+ expect(buildEnvKey()).toEqual({ drafts: true, year: new Date().getFullYear() });
60
+ vi.stubEnv('SHAPIO_DRAFTS', '');
61
+ expect(buildEnvKey().drafts).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,57 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isDraftsMode } from './config.js';
3
+
4
+ /**
5
+ * Keys for Astro's incremental build (`experimental.incrementalBuild` in astro.config.mjs). A page returned by
6
+ * `getStaticPaths()` with a `cacheKey` is restored from the previous build, not rendered, when its key and its
7
+ * code (Astro hashes the page's import graph) are both unchanged. So the key must cover every piece of data the
8
+ * page renders: its props, what the layout reads (`layoutInputs`), and the build environment (`buildEnvKey`).
9
+ * Whole objects go in, never a hand-picked list of fields, which would drift from what the page renders.
10
+ */
11
+
12
+ /** Plain data in a stable form: object keys sorted, arrays in order, `undefined` as null, dates as ISO text. */
13
+ const normalize = (value: unknown, path: string, seen: Set<object>): unknown => {
14
+ if (value === undefined || value === null) {
15
+ return null;
16
+ }
17
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
18
+ return value;
19
+ }
20
+ if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
21
+ throw new TypeError(`cacheKeyOf: ${path} is a ${typeof value}, not plain data`);
22
+ }
23
+ if (value instanceof Date) {
24
+ return value.toISOString();
25
+ }
26
+ const object = value;
27
+ if (seen.has(object)) {
28
+ throw new TypeError(`cacheKeyOf: ${path} refers back to itself`);
29
+ }
30
+ seen.add(object);
31
+ try {
32
+ if (Array.isArray(object)) {
33
+ return object.map((item, index) => normalize(item, `${path}[${index}]`, seen));
34
+ }
35
+ const prototype = Object.getPrototypeOf(object) as unknown;
36
+ if (prototype !== Object.prototype && prototype !== null) {
37
+ throw new TypeError(`cacheKeyOf: ${path} is a ${object.constructor.name}, not plain data`);
38
+ }
39
+ const entries = Object.entries(object).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
40
+ return Object.fromEntries(entries.map(([key, item]) => [key, normalize(item, `${path}.${key}`, seen)]));
41
+ } finally {
42
+ seen.delete(object);
43
+ }
44
+ };
45
+
46
+ /** A stable digest of the data a page renders: same inputs, same key, whatever the order of object keys. */
47
+ export const cacheKeyOf = (...inputs: unknown[]): string =>
48
+ createHash('sha256')
49
+ .update(JSON.stringify(normalize(inputs, 'inputs', new Set())))
50
+ .digest('hex')
51
+ .slice(0, 32);
52
+
53
+ /**
54
+ * What a page shows that comes from neither its data nor its code: settings read from the environment and the
55
+ * clock when it renders, which Astro's code and config hashes do not see.
56
+ */
57
+ export const buildEnvKey = () => ({ drafts: isDraftsMode(), year: new Date().getFullYear() });
@@ -10,10 +10,13 @@
10
10
  * instead of published content (no snapshot), and pages carry a "Drafts" badge. The token must be a delivery
11
11
  * token whose role grants Read drafts: SHAPIO_DEV_DELIVERY_TOKEN when set (`npm run seed` writes one), else
12
12
  * SHAPIO_DELIVERY_TOKEN. Never set it in a production environment.
13
+ *
14
+ * Settings come from `process.env` only (astro.config.mjs loads `.env` into it). Reading Vite's env object as a
15
+ * whole instead would make Vite write every variable named in this file into the compiled code, so a changed
16
+ * SHAPIO_SNAPSHOT would change the code Astro hashes and re-render every page (see src/lib/cacheKey.ts).
13
17
  */
14
18
  const read = (name: string): string | undefined => {
15
- const fromVite = (import.meta as { env?: Record<string, string | undefined> }).env?.[name];
16
- const value = fromVite ?? process.env[name];
19
+ const value = process.env[name];
17
20
  return value === undefined || value === '' ? undefined : value;
18
21
  };
19
22
 
@@ -50,5 +53,20 @@ export const publicShapioUrl = () => read('PUBLIC_SHAPIO_URL') ?? shapioUrl();
50
53
 
51
54
  export const siteKey = () => read('SHAPIO_SITE');
52
55
 
53
- /** True under `astro dev` (Vite's import.meta.env.DEV); false in builds and in the scripts. */
54
- export const isDev = () => (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true;
56
+ type ViteImportMeta = ImportMeta & { env: { DEV?: boolean } };
57
+
58
+ /**
59
+ * True under `astro dev`; false in builds and in the scripts. Written out in full (`import.meta.env.DEV`) so Vite
60
+ * replaces it with a constant; outside Vite (the scripts, run by Node) there is no env object, which is the
61
+ * TypeError caught here, and that is not dev.
62
+ */
63
+ export const isDev = (): boolean => {
64
+ try {
65
+ return (import.meta as ViteImportMeta).env.DEV === true;
66
+ } catch (error) {
67
+ if (error instanceof TypeError) {
68
+ return false;
69
+ }
70
+ throw error;
71
+ }
72
+ };
@@ -0,0 +1,13 @@
1
+ import { buildEnvKey } from './cacheKey.js';
2
+ import { getSite, getSiteSettings } from './shapio.js';
3
+ import type { Locale } from './site.js';
4
+
5
+ /**
6
+ * Everything layouts/Base.astro reads when it renders, besides its props: the site settings and the site's SEO
7
+ * defaults (both read once per build) and the build environment. The layout reads through this function and
8
+ * every keyed page folds it into its `cacheKey`, so a read added to the layout is in the key too.
9
+ */
10
+ export const layoutInputs = async (locale: Locale) => {
11
+ const [settings, site] = await Promise.all([getSiteSettings(locale), getSite()]);
12
+ return { settings, site, env: buildEnvKey() };
13
+ };
@@ -64,17 +64,22 @@ export const pinnedSnapshot = async (): Promise<number | undefined> => {
64
64
  return resolver();
65
65
  };
66
66
 
67
- /** Every published entry of a collection (by plural API ID) in one locale, page by page, at the pinned snapshot. */
68
- const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
67
+ /**
68
+ * Every published entry of a collection (by plural API ID) in one locale, page by page, at the pinned snapshot.
69
+ * The defaults (server-rendered HTML, SEO fields resolved) apply unless the query overrides them: a collection
70
+ * without SEO fields (a redirect type, say) is read with `{ seo: 'raw' }`, since Shapio refuses `seo=resolved`
71
+ * for a model that has none.
72
+ */
73
+ export const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
69
74
  const snapshot = await pinnedSnapshot();
70
75
  const entries: T[] = [];
71
76
  for (let page = 1; ; page += 1) {
72
77
  const result = await shapio().delivery.list<T>(routeKey, {
73
- ...query,
74
78
  // The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
75
79
  // its SEO fields with the site's defaults filled in (`seo=resolved`).
76
80
  richText: 'html',
77
81
  seo: 'resolved',
82
+ ...query,
78
83
  snapshot,
79
84
  page,
80
85
  pageSize: PAGE_SIZE,
@@ -1,20 +1,29 @@
1
1
  ---
2
2
  import Base from '../../layouts/Base.astro';
3
+ import { cacheKeyOf } from '../../lib/cacheKey.js';
3
4
  import { isDev } from '../../lib/config.js';
5
+ import { layoutInputs } from '../../lib/layoutInputs.js';
4
6
  import { renderPage } from '../../lib/render.js';
5
7
  import { findBySlug, renderTimeEntry } from '../../lib/renderTime.js';
6
8
  import { listPages } from '../../lib/shapio.js';
7
9
  import { HOME_SLUG, LOCALES, otherLocale, pagePath, type Locale } from '../../lib/site.js';
8
10
  import type { Page } from '../../lib/types.js';
9
11
 
10
- /** Every published page in every locale; `home` is the locale's front page (`/en/`, `/fr/`). */
12
+ /**
13
+ * Every published page in every locale; `home` is the locale's front page (`/en/`, `/fr/`). The page renders
14
+ * only its props and what the layout reads, so those are its cacheKey: a build restores it from the previous
15
+ * build while they are unchanged (src/lib/cacheKey.ts).
16
+ */
11
17
  export const getStaticPaths = async () => {
12
18
  const paths = [];
13
19
  for (const locale of LOCALES) {
20
+ const layout = await layoutInputs(locale);
14
21
  for (const page of await listPages(locale)) {
22
+ const props = { page, locale };
15
23
  paths.push({
16
24
  params: { locale, slug: page.slug === HOME_SLUG ? undefined : page.slug },
17
- props: { page, locale },
25
+ props,
26
+ cacheKey: cacheKeyOf(props, layout),
18
27
  });
19
28
  }
20
29
  }
@@ -1,17 +1,26 @@
1
1
  ---
2
2
  import Base from '../../../layouts/Base.astro';
3
+ import { cacheKeyOf } from '../../../lib/cacheKey.js';
3
4
  import { isDev } from '../../../lib/config.js';
5
+ import { layoutInputs } from '../../../lib/layoutInputs.js';
4
6
  import { renderArticle } from '../../../lib/render.js';
5
7
  import { findBySlug, renderTimeEntry } from '../../../lib/renderTime.js';
6
8
  import { listArticles } from '../../../lib/shapio.js';
7
9
  import { articlePath, LOCALES, otherLocale, stringsFor, type Locale } from '../../../lib/site.js';
8
10
  import type { Article } from '../../../lib/types.js';
9
11
 
12
+ /**
13
+ * Every published article in every locale. The page renders only its props (the author arrives populated inside
14
+ * the article; the strings are in the code) and what the layout reads, so those are its cacheKey: a build
15
+ * restores it from the previous build while they are unchanged (src/lib/cacheKey.ts).
16
+ */
10
17
  export const getStaticPaths = async () => {
11
18
  const paths = [];
12
19
  for (const locale of LOCALES) {
20
+ const layout = await layoutInputs(locale);
13
21
  for (const article of await listArticles(locale)) {
14
- paths.push({ params: { locale, slug: article.slug }, props: { locale, article } });
22
+ const props = { locale, article };
23
+ paths.push({ params: { locale, slug: article.slug }, props, cacheKey: cacheKeyOf(props, layout) });
15
24
  }
16
25
  }
17
26
  return paths;
@@ -7,6 +7,7 @@ import { listArticles } from '../../../lib/shapio.js';
7
7
  import { articlesPath, LOCALES, otherLocale, stringsFor, type Locale } from '../../../lib/site.js';
8
8
  import type { Article } from '../../../lib/types.js';
9
9
 
10
+ // No cacheKey: the listing renders on every build (it shows every article, so most publishes change it).
10
11
  export const getStaticPaths = async () =>
11
12
  Promise.all(
12
13
  LOCALES.map(async (locale) => ({ params: { locale }, props: { locale, articles: await listArticles(locale) } })),
@@ -3,7 +3,7 @@ import Base from '../../layouts/Base.astro';
3
3
  import { getSiteSettings } from '../../lib/shapio.js';
4
4
  import { colophonPath, LOCALES, otherLocale, stringsFor, type Locale } from '../../lib/site.js';
5
5
 
6
- /** The singleton page: the siteSettings entry's colophon, one per locale. */
6
+ /** The singleton page: the siteSettings entry's colophon, one per locale. No cacheKey: it renders on every build. */
7
7
  export const getStaticPaths = () => LOCALES.map((locale) => ({ params: { locale }, props: { locale } }));
8
8
 
9
9
  type Props = { locale: Locale };
@@ -18,9 +18,9 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@shapio/client": "^0.5.1",
22
- "@shapio/local": "0.5.1",
23
- "@shapio/visual": "^0.5.1",
21
+ "@shapio/client": "^0.5.3",
22
+ "@shapio/local": "0.5.3",
23
+ "@shapio/visual": "^0.5.3",
24
24
  "next": "16.3.8",
25
25
  "react": "19.3.0",
26
26
  "react-dom": "19.3.0"
@@ -58,16 +58,21 @@ const snapshot = async (): Promise<number | undefined> =>
58
58
  }))()
59
59
  : liveSnapshot();
60
60
 
61
- /** Every published entry of a collection in one locale, page by page, at the pinned snapshot. */
62
- const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
61
+ /**
62
+ * Every published entry of a collection in one locale, page by page, at the pinned snapshot.
63
+ * The defaults (server-rendered HTML, SEO fields resolved) apply unless the query overrides them: a collection
64
+ * without SEO fields (a redirect type, say) is read with `{ seo: 'raw' }`, since Shapio refuses `seo=resolved`
65
+ * for a model that has none.
66
+ */
67
+ export const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
63
68
  const entries: T[] = [];
64
69
  for (let page = 1; ; page += 1) {
65
70
  const result = await shapio().delivery.list<T>(routeKey, {
66
- ...query,
67
71
  // The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
68
72
  // its SEO fields with the site's defaults filled in.
69
73
  richText: 'html',
70
74
  seo: 'resolved',
75
+ ...query,
71
76
  snapshot: await snapshot(),
72
77
  page,
73
78
  pageSize: PAGE_SIZE,
@@ -23,8 +23,8 @@
23
23
  "test": "vitest run"
24
24
  },
25
25
  "dependencies": {
26
- "@shapio/client": "^0.5.1",
27
- "@shapio/visual": "^0.5.1"
26
+ "@shapio/client": "^0.5.3",
27
+ "@shapio/visual": "^0.5.3"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@sveltejs/adapter-static": "4.0.0",
@@ -57,16 +57,21 @@ const once = <T>(key: string, read: () => Promise<T>): Promise<T> => {
57
57
  return cached();
58
58
  };
59
59
 
60
- /** Every published entry of a collection in one locale, page by page, at the pinned snapshot. */
61
- const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
60
+ /**
61
+ * Every published entry of a collection in one locale, page by page, at the pinned snapshot.
62
+ * The defaults (server-rendered HTML, SEO fields resolved) apply unless the query overrides them: a collection
63
+ * without SEO fields (a redirect type, say) is read with `{ seo: 'raw' }`, since Shapio refuses `seo=resolved`
64
+ * for a model that has none.
65
+ */
66
+ export const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
62
67
  const entries: T[] = [];
63
68
  for (let page = 1; ; page += 1) {
64
69
  const result = await shapio().delivery.list<T>(routeKey, {
65
- ...query,
66
70
  // The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
67
71
  // its SEO fields with the site's defaults filled in.
68
72
  richText: 'html',
69
73
  seo: 'resolved',
74
+ ...query,
70
75
  snapshot: await snapshot(),
71
76
  page,
72
77
  pageSize: PAGE_SIZE,