create-shapio 0.5.1 → 0.5.2
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 +1 -1
- package/templates/astro/README.md +10 -1
- package/templates/astro/astro.config.mjs +7 -0
- package/templates/astro/package.json +3 -2
- package/templates/astro/scripts/incrementalBuild.ts +15 -3
- package/templates/astro/scripts/incrementalCheck.ts +218 -0
- package/templates/astro/src/layouts/Base.astro +5 -5
- package/templates/astro/src/lib/cacheKey.test.ts +63 -0
- package/templates/astro/src/lib/cacheKey.ts +57 -0
- package/templates/astro/src/lib/config.ts +22 -4
- package/templates/astro/src/lib/layoutInputs.ts +13 -0
- package/templates/astro/src/pages/[locale]/[...slug].astro +11 -2
- package/templates/astro/src/pages/[locale]/articles/[slug].astro +10 -1
- package/templates/astro/src/pages/[locale]/articles/index.astro +1 -0
- package/templates/astro/src/pages/[locale]/colophon.astro +1 -1
- package/templates/next/package.json +3 -3
- package/templates/sveltekit/package.json +2 -2
package/package.json
CHANGED
|
@@ -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`.
|
|
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.
|
|
25
|
-
"@shapio/visual": "^0.5.
|
|
25
|
+
"@shapio/client": "^0.5.2",
|
|
26
|
+
"@shapio/visual": "^0.5.2",
|
|
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]`:
|
|
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');
|
|
@@ -108,7 +115,10 @@ const main = async () => {
|
|
|
108
115
|
const say = (line: string) => process.stdout.write(`${line}\n`);
|
|
109
116
|
|
|
110
117
|
if (from === undefined) {
|
|
111
|
-
say(`No previous build: building
|
|
118
|
+
say(`No previous build: building at snapshot ${to}.`);
|
|
119
|
+
} else if (!existsSync(DIST_BUILD_INFO)) {
|
|
120
|
+
// SHAPIO_FROM_SNAPSHOT without the build it names: skipping would leave no dist/ to deploy.
|
|
121
|
+
say(`No dist/ from snapshot ${from} here: building at snapshot ${to}.`);
|
|
112
122
|
} else if (from === to) {
|
|
113
123
|
say(`Nothing published since snapshot ${from}: skipping the build.`);
|
|
114
124
|
return;
|
|
@@ -125,7 +135,9 @@ const main = async () => {
|
|
|
125
135
|
for (const change of diff.items) {
|
|
126
136
|
(await routesOf(change, from, to)).forEach((route) => routes.add(route));
|
|
127
137
|
}
|
|
128
|
-
say(
|
|
138
|
+
say(
|
|
139
|
+
`Snapshots ${from} → ${to}: ${diff.items.length} changed entries; the routes they touch (the build re-renders only pages whose data changed):`,
|
|
140
|
+
);
|
|
129
141
|
[...routes].sort().forEach((route) => say(` ${route}`));
|
|
130
142
|
}
|
|
131
143
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
+
};
|
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
22
|
-
"@shapio/local": "0.5.
|
|
23
|
-
"@shapio/visual": "^0.5.
|
|
21
|
+
"@shapio/client": "^0.5.2",
|
|
22
|
+
"@shapio/local": "0.5.2",
|
|
23
|
+
"@shapio/visual": "^0.5.2",
|
|
24
24
|
"next": "16.3.8",
|
|
25
25
|
"react": "19.3.0",
|
|
26
26
|
"react-dom": "19.3.0"
|