create-shapio 0.5.0 → 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.
Files changed (57) hide show
  1. package/package.json +1 -1
  2. package/templates/astro/.env.example +6 -0
  3. package/templates/astro/README.md +19 -1
  4. package/templates/astro/astro.config.mjs +7 -0
  5. package/templates/astro/package.json +8 -4
  6. package/templates/astro/scripts/incrementalBuild.ts +15 -3
  7. package/templates/astro/scripts/incrementalCheck.ts +218 -0
  8. package/templates/astro/scripts/seed.ts +77 -24
  9. package/templates/astro/scripts/smokeDrafts.ts +109 -0
  10. package/templates/astro/src/layouts/Base.astro +11 -3
  11. package/templates/astro/src/lib/cacheKey.test.ts +63 -0
  12. package/templates/astro/src/lib/cacheKey.ts +57 -0
  13. package/templates/astro/src/lib/config.ts +33 -3
  14. package/templates/astro/src/lib/layoutInputs.ts +13 -0
  15. package/templates/astro/src/lib/renderTime.test.ts +31 -0
  16. package/templates/astro/src/lib/renderTime.ts +24 -0
  17. package/templates/astro/src/lib/shapio.ts +88 -64
  18. package/templates/astro/src/lib/site.ts +4 -0
  19. package/templates/astro/src/lib/snapshotResolver.test.ts +64 -0
  20. package/templates/astro/src/lib/snapshotResolver.ts +52 -0
  21. package/templates/astro/src/lib/types.ts +0 -9
  22. package/templates/astro/src/pages/[locale]/[...slug].astro +23 -3
  23. package/templates/astro/src/pages/[locale]/articles/[slug].astro +22 -2
  24. package/templates/astro/src/pages/[locale]/articles/index.astro +7 -1
  25. package/templates/astro/src/pages/[locale]/colophon.astro +1 -1
  26. package/templates/astro/src/pages/build.json.ts +9 -2
  27. package/templates/astro/src/styles/site.css +14 -0
  28. package/templates/next/.env.example +6 -0
  29. package/templates/next/README.md +9 -0
  30. package/templates/next/next.config.ts +5 -1
  31. package/templates/next/package.json +4 -3
  32. package/templates/next/scripts/seed.ts +77 -24
  33. package/templates/next/scripts/smokeDrafts.ts +109 -0
  34. package/templates/next/src/app/[locale]/layout.tsx +3 -0
  35. package/templates/next/src/app/site.css +14 -0
  36. package/templates/next/src/components/DraftsBadge/index.tsx +10 -0
  37. package/templates/next/src/lib/config.ts +13 -1
  38. package/templates/next/src/lib/revalidation.ts +4 -1
  39. package/templates/next/src/lib/shapio.ts +24 -4
  40. package/templates/next/src/lib/shapioClient.ts +6 -4
  41. package/templates/next/src/lib/site.ts +4 -0
  42. package/templates/next/src/lib/snapshotResolver.test.ts +64 -0
  43. package/templates/next/src/lib/snapshotResolver.ts +52 -0
  44. package/templates/sveltekit/.env.example +6 -0
  45. package/templates/sveltekit/README.md +9 -0
  46. package/templates/sveltekit/package.json +7 -4
  47. package/templates/sveltekit/scripts/seed.ts +77 -24
  48. package/templates/sveltekit/scripts/smokeDrafts.ts +109 -0
  49. package/templates/sveltekit/src/env.ts +2 -0
  50. package/templates/sveltekit/src/lib/server/config.ts +11 -1
  51. package/templates/sveltekit/src/lib/server/shapio.ts +28 -13
  52. package/templates/sveltekit/src/lib/server/snapshotResolver.test.ts +64 -0
  53. package/templates/sveltekit/src/lib/server/snapshotResolver.ts +52 -0
  54. package/templates/sveltekit/src/lib/site.css +14 -0
  55. package/templates/sveltekit/src/lib/site.ts +4 -0
  56. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +3 -2
  57. package/templates/sveltekit/src/routes/[locale]/+layout.svelte +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shapio",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",
@@ -10,3 +10,9 @@ SHAPIO_DELIVERY_TOKEN=
10
10
  # PUBLIC_SHAPIO_URL=https://cms.example.com
11
11
  # Optional: this site's public origin; pages then carry a canonical URL and og:url.
12
12
  # SITE_URL=https://www.example.com
13
+ # Optional, development only: drafts mode. Every read shows saved drafts instead of published content, fresh
14
+ # on every request, with a "Drafts" badge on each page. Needs a delivery token whose role grants Read drafts:
15
+ # SHAPIO_DEV_DELIVERY_TOKEN (`npm run seed` writes one), else SHAPIO_DELIVERY_TOKEN. Never put that token or
16
+ # this flag in a production environment.
17
+ # SHAPIO_DRAFTS=true
18
+ # SHAPIO_DEV_DELIVERY_TOKEN=
@@ -17,7 +17,25 @@ 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.
22
+
23
+ Drafts mode, for your development server: set `SHAPIO_DRAFTS=true` in `.env` and `npm run dev` shows saved
24
+ drafts instead of published content, fresh on every reload, with a **Drafts** badge on every page. Save in
25
+ Shapio, reload, and the change is there; publishing still rebuilds the live site. It reads with
26
+ `SHAPIO_DEV_DELIVERY_TOKEN`, the seed's `<site> dev` token, whose delivery role grants Read drafts (a token
27
+ without it fails with `DRAFTS_FORBIDDEN`). Never put that token or the flag in a production environment.
28
+ `npm run smoke:drafts` checks a running drafts-mode dev server
29
+ (http://localhost:4321; needs `SHAPIO_ADMIN_EMAIL`/`PASSWORD` to save a test change, which it puts back). Guide:
30
+ [drafts mode](https://github.com/mybrokengnome/shapio/blob/main/documentation/starters.md#drafts-mode).
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.
21
39
 
22
40
  Preview: the seed creates a deployment connection named **Preview** that opens drafts at
23
41
  `http://localhost:4321/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
@@ -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
  });
@@ -13,19 +13,23 @@
13
13
  "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
14
14
  "seed": "node --env-file-if-exists=.env --import tsx scripts/seed.ts --site-url http://localhost:4321",
15
15
  "smoke": "node --env-file-if-exists=.env --import tsx scripts/smoke.ts",
16
+ "smoke:drafts": "node --env-file-if-exists=.env --import tsx scripts/smokeDrafts.ts --url http://localhost:4321",
16
17
  "smoke:http": "node --import tsx scripts/smokeHttp.ts --url http://localhost:4321",
17
18
  "report-status": "node --env-file-if-exists=.env --import tsx scripts/reportStatus.ts",
18
19
  "build:incremental": "node --env-file-if-exists=.env --import tsx scripts/incrementalBuild.ts",
19
- "typecheck": "tsc -p tsconfig.json"
20
+ "check:incremental": "node --env-file-if-exists=.env --import tsx scripts/incrementalCheck.ts",
21
+ "typecheck": "tsc -p tsconfig.json",
22
+ "test": "vitest run"
20
23
  },
21
24
  "dependencies": {
22
- "@shapio/client": "^0.5.0",
23
- "@shapio/visual": "^0.5.0",
25
+ "@shapio/client": "^0.5.2",
26
+ "@shapio/visual": "^0.5.2",
24
27
  "astro": "7.3.5"
25
28
  },
26
29
  "devDependencies": {
27
30
  "@types/node": "24.19.1",
28
31
  "tsx": "4.23.15",
29
- "typescript": "6.0.3"
32
+ "typescript": "6.0.3",
33
+ "vitest": "5.0.3"
30
34
  }
31
35
  }
@@ -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');
@@ -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 everything at snapshot ${to}.`);
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(`Snapshots ${from} → ${to}: ${diff.items.length} changed entries; routes to refresh:`);
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
+ });
@@ -40,13 +40,16 @@ import { createPng } from './lib/png.js';
40
40
  * 5. with `--revalidate-path` (the Next.js starter's /api/revalidate), a webhook named "Site revalidation" that
41
41
  * sends publish, unpublish, delete, change set and schema events to SITE_URL + that path; its signing secret
42
42
  * (rotated on every run, as Shapio shows it only once) is written to .env as SHAPIO_WEBHOOK_SECRET;
43
- * 6. a delivery role and token for the build, written with SHAPIO_URL to .env in the current directory.
43
+ * 6. a delivery role and token for the build, written with SHAPIO_URL to .env in the current directory;
44
+ * 7. a second delivery role and token for development servers, "<site> dev", that may also read drafts (drafts
45
+ * mode: SHAPIO_DRAFTS=true), written to .env as SHAPIO_DEV_DELIVERY_TOKEN. Never put it in production.
44
46
  */
45
47
  const SCHEMA_DIR = resolve(import.meta.dirname, '..', 'shapio');
46
48
  const SCHEMA_KINDS = ['models', 'components'] as const;
47
49
  const SITE_MODELS = ['page', 'article', 'author', 'siteSettings'];
48
50
  const DELIVERY_ROLE_KEY = 'starter-delivery';
49
51
  const DELIVERY_TOKEN_NAME = 'starter build';
52
+ const DEV_ROLE_KEY = 'starter-dev';
50
53
  const PREVIEW_CONNECTION_NAME = 'Preview';
51
54
  /** Where Shapio opens a draft: the starter's /preview/ page, the token in the fragment (never sent to a server). */
52
55
  const PREVIEW_PATH = '/preview/?model={modelKey}&id={entryId}&locale={locale}#token={token}';
@@ -308,35 +311,71 @@ const upsertEntry = async (client: ShapioClient, spec: EntrySpec) => {
308
311
  log(`${spec.publish ? 'Published' : 'Saved draft'} ${spec.model} ${spec.slug} (en, fr)`);
309
312
  };
310
313
 
311
- /** A delivery role that reads the site's models, and a fresh token bound to it (older ones are revoked). */
312
- const createDeliveryToken = async (client: ShapioClient) => {
314
+ type DeliveryRoleSpec = {
315
+ key: string;
316
+ name: string;
317
+ description: string;
318
+ tokenName: string;
319
+ drafts: boolean;
320
+ };
321
+
322
+ /**
323
+ * A delivery role that reads the site's models (and, for development servers, their drafts), and a fresh token
324
+ * bound to it (older ones of the same name are revoked).
325
+ */
326
+ const createDeliveryToken = async (client: ShapioClient, spec: DeliveryRoleSpec) => {
313
327
  const summary = await client.admin.schema.summary();
314
328
  const modelIds = summary.definitions
315
329
  .filter((definition) => SITE_MODELS.includes(definition.apiKey))
316
330
  .map((definition) => definition.id);
317
- const permissions = modelIds.map((modelId) => ({
318
- action: 'read' as const,
319
- modelId,
320
- condition: null,
321
- fieldIds: null,
322
- }));
331
+ const grant = { condition: null, fieldIds: null };
332
+ const permissions = [
333
+ ...modelIds.map((modelId) => ({ action: 'read' as const, modelId, ...grant })),
334
+ // Read drafts always covers every model; the role still reads only the models above.
335
+ ...(spec.drafts ? [{ action: 'readDrafts' as const, modelId: null, ...grant }] : []),
336
+ ];
323
337
  const roles = await client.admin.roles.list();
324
- const existing = roles.find((role) => role.key === DELIVERY_ROLE_KEY);
338
+ const existing = roles.find((role) => role.key === spec.key);
325
339
  const role = existing
326
340
  ? await client.admin.roles.update(existing.id, { expectedVersion: existing.version, permissions })
327
341
  : await client.admin.roles.create({
328
- key: DELIVERY_ROLE_KEY,
329
- name: 'Starter site (delivery)',
330
- description: 'Reads published pages, articles, authors and the site settings for the starter build.',
342
+ key: spec.key,
343
+ name: spec.name,
344
+ description: spec.description,
331
345
  kind: 'delivery',
332
346
  permissions,
333
347
  });
334
348
  for (const token of await client.admin.tokens.list()) {
335
- if (token.name === DELIVERY_TOKEN_NAME && !token.revokedAt) {
349
+ if (token.name === spec.tokenName && !token.revokedAt) {
336
350
  await client.admin.tokens.revoke(token.id);
337
351
  }
338
352
  }
339
- return (await client.admin.tokens.create({ name: DELIVERY_TOKEN_NAME, roleId: role.id })).token;
353
+ return (await client.admin.tokens.create({ name: spec.tokenName, roleId: role.id })).token;
354
+ };
355
+
356
+ const BUILD_ROLE: DeliveryRoleSpec = {
357
+ key: DELIVERY_ROLE_KEY,
358
+ name: 'Starter site (delivery)',
359
+ description: 'Reads published pages, articles, authors and the site settings for the starter build.',
360
+ tokenName: DELIVERY_TOKEN_NAME,
361
+ drafts: false,
362
+ };
363
+
364
+ /** The development token's role: the build role plus Read drafts. Named after the site it reads. */
365
+ const devRole = (siteName: string): DeliveryRoleSpec => ({
366
+ key: DEV_ROLE_KEY,
367
+ name: 'Starter site dev (drafts)',
368
+ description:
369
+ 'Reads the starter models and their drafts, for development servers (drafts mode). Never use its tokens in production.',
370
+ tokenName: `${siteName} dev`,
371
+ drafts: true,
372
+ });
373
+
374
+ /** The name of the site the seed works on: SHAPIO_SITE, else the primary site. */
375
+ const siteNameOf = async (client: ShapioClient, siteKey: string | undefined) => {
376
+ const sites = await client.admin.sites.list();
377
+ const site = sites.find((candidate) => (siteKey ? candidate.key === siteKey : candidate.isPrimary));
378
+ return site?.name ?? siteKey ?? 'site';
340
379
  };
341
380
 
342
381
  /** The "Preview" connection, created once and pointed at this starter's /preview/ page on every run. */
@@ -402,24 +441,30 @@ const tryRevalidateWebhook = async (client: ShapioClient, siteUrl: string, path:
402
441
  }
403
442
  };
404
443
 
405
- const writeEnv = async (
406
- url: string,
407
- site: string | undefined,
408
- deliveryToken: string,
409
- webhookSecret: string | undefined,
410
- ) => {
444
+ type SeededEnv = {
445
+ url: string;
446
+ site: string | undefined;
447
+ deliveryToken: string;
448
+ devDeliveryToken: string;
449
+ webhookSecret: string | undefined;
450
+ };
451
+
452
+ const writeEnv = async ({ url, site, deliveryToken, devDeliveryToken, webhookSecret }: SeededEnv) => {
411
453
  const path = resolve('.env');
412
454
  const lines = [
413
- '# Written by the seed. The delivery token is read-only; keep this file out of git.',
455
+ '# Written by the seed. The delivery tokens are read-only; keep this file out of git.',
414
456
  `SHAPIO_URL=${url}`,
415
457
  `SHAPIO_DELIVERY_TOKEN=${deliveryToken}`,
458
+ '# Development only: drafts mode (SHAPIO_DRAFTS=true) reads with this token, which may read drafts.',
459
+ '# Never put it in a production environment.',
460
+ `SHAPIO_DEV_DELIVERY_TOKEN=${devDeliveryToken}`,
416
461
  // The site the content was seeded on: the starter reads the same one.
417
462
  ...(site ? [`SHAPIO_SITE=${site}`] : []),
418
463
  ...(webhookSecret ? [`SHAPIO_WEBHOOK_SECRET=${webhookSecret}`] : []),
419
464
  ];
420
465
  await writeFile(path, `${lines.join('\n')}\n`, { mode: 0o600 });
421
466
  log(
422
- `Wrote SHAPIO_URL, ${site ? 'SHAPIO_SITE, ' : ''}SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
467
+ `Wrote SHAPIO_URL, ${site ? 'SHAPIO_SITE, ' : ''}SHAPIO_DELIVERY_TOKEN, SHAPIO_DEV_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
423
468
  );
424
469
  };
425
470
 
@@ -444,7 +489,15 @@ const main = async () => {
444
489
  const webhookSecret = revalidatePath
445
490
  ? await tryRevalidateWebhook(admin.client, siteUrl, revalidatePath)
446
491
  : undefined;
447
- await writeEnv(admin.url, admin.site, await createDeliveryToken(admin.client), webhookSecret);
492
+ const siteName = await siteNameOf(admin.client, admin.site);
493
+ await writeEnv({
494
+ url: admin.url,
495
+ site: admin.site,
496
+ deliveryToken: await createDeliveryToken(admin.client, BUILD_ROLE),
497
+ devDeliveryToken: await createDeliveryToken(admin.client, devRole(siteName)),
498
+ webhookSecret,
499
+ });
500
+ log(`Created the "${siteName} dev" token for drafts mode (SHAPIO_DRAFTS=true); keep it off production.`);
448
501
  log('Seeded. Build the site with: npm run build');
449
502
  } finally {
450
503
  await admin.close();
@@ -0,0 +1,109 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { connectAdmin } from './lib/admin.js';
3
+
4
+ /**
5
+ * `npm run smoke:drafts`, after `npm run seed` and starting the dev server with SHAPIO_DRAFTS=true (drafts mode,
6
+ * which reads with the seed's SHAPIO_DEV_DELIVERY_TOKEN): checks that the site shows drafts, that every page
7
+ * carries the "Drafts" badge, and that a change saved in Shapio (not published) shows on the next reload.
8
+ * The saved change is put back afterwards. Needs SHAPIO_ADMIN_EMAIL and SHAPIO_ADMIN_PASSWORD (or SHAPIO_TOKEN,
9
+ * an admin API token) to save the change, as the seed does.
10
+ *
11
+ * `--url` is the running dev server's origin (default: SMOKE_URL, then http://localhost:3000).
12
+ */
13
+ const DRAFT_TITLE = 'Coming soon: our winter projects';
14
+ const ARTICLE = 'modelling-without-a-deploy';
15
+ const BADGE = 'data-shapio-drafts';
16
+ /** How long a saved draft may take to show: the dev server reads fresh, so this only absorbs a slow render. */
17
+ const SHOW_WITHIN_MS = 30_000;
18
+ const POLL_EVERY_MS = 250;
19
+
20
+ const failures: string[] = [];
21
+ const check = (condition: boolean, message: string) => {
22
+ process.stdout.write(`${condition ? 'ok ' : 'FAIL'} ${message}\n`);
23
+ if (!condition) {
24
+ failures.push(message);
25
+ }
26
+ };
27
+
28
+ const fetchPage = async (origin: string, path: string) => {
29
+ const response = await fetch(new URL(path, origin));
30
+ return { status: response.status, html: await response.text() };
31
+ };
32
+
33
+ /** Reloads `path` until it contains `needle` (a real condition, not a fixed wait); false when it never does. */
34
+ const showsWithin = async (origin: string, path: string, needle: string): Promise<boolean> => {
35
+ const deadline = Date.now() + SHOW_WITHIN_MS;
36
+ while (Date.now() < deadline) {
37
+ if ((await fetchPage(origin, path)).html.includes(needle)) {
38
+ return true;
39
+ }
40
+ await new Promise((resolve) => setTimeout(resolve, POLL_EVERY_MS));
41
+ }
42
+ return false;
43
+ };
44
+
45
+ /** Saves (does not publish) the article's English title, and returns a function that puts the old one back. */
46
+ const saveTitle = async (client: Awaited<ReturnType<typeof connectAdmin>>['client'], title: string) => {
47
+ const [item] = (
48
+ await client.admin.content.list('article', { locale: 'en', filters: { slug: { $eq: ARTICLE } } })
49
+ ).items;
50
+ if (!item) {
51
+ throw new Error(`The seeded article "${ARTICLE}" is missing; run npm run seed first`);
52
+ }
53
+ const entry = await client.admin.content.get('article', item.id, { locale: 'en' });
54
+ const original = entry.data.title;
55
+ // A save sends only the fields it changes.
56
+ const saved = await client.admin.content.update('article', item.id, {
57
+ locale: 'en',
58
+ expectedVersion: entry.version,
59
+ data: { title },
60
+ });
61
+ return async () => {
62
+ await client.admin.content.update('article', item.id, {
63
+ locale: 'en',
64
+ expectedVersion: saved.version,
65
+ data: { title: original },
66
+ });
67
+ };
68
+ };
69
+
70
+ const main = async () => {
71
+ const { values } = parseArgs({ options: { url: { type: 'string' } } });
72
+ const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:3000';
73
+
74
+ const list = await fetchPage(origin, '/en/articles/');
75
+ check(list.status === 200, `/en/articles/: HTTP ${list.status}`);
76
+ check(list.html.includes(BADGE), '/en/articles/: the Drafts badge');
77
+ check(list.html.includes(DRAFT_TITLE), 'the unpublished draft is listed (drafts mode)');
78
+ const draft = await fetchPage(origin, '/en/articles/winter-projects/');
79
+ check(
80
+ draft.status === 200 && draft.html.includes(DRAFT_TITLE),
81
+ `the unpublished draft is served (HTTP ${draft.status})`,
82
+ );
83
+
84
+ const admin = await connectAdmin(process.env);
85
+ try {
86
+ const marker = `Saved, not published ${Date.now()}`;
87
+ const restore = await saveTitle(admin.client, marker);
88
+ try {
89
+ check(
90
+ await showsWithin(origin, `/en/articles/${ARTICLE}/`, marker),
91
+ 'a change saved in Shapio shows on reload, without publishing',
92
+ );
93
+ } finally {
94
+ await restore();
95
+ }
96
+ } finally {
97
+ await admin.close();
98
+ }
99
+
100
+ if (failures.length > 0) {
101
+ throw new Error(`${failures.length} check(s) failed against ${origin}`);
102
+ }
103
+ process.stdout.write(`Drafts smoke test passed against ${origin}.\n`);
104
+ };
105
+
106
+ main().catch((error: unknown) => {
107
+ process.stderr.write(`smoke:drafts: ${error instanceof Error ? error.message : String(error)}\n`);
108
+ process.exitCode = 1;
109
+ });
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  import '../styles/site.css';
3
3
  import type { SeoResolved } from '@shapio/client';
4
+ import { layoutInputs } from '../lib/layoutInputs.js';
4
5
  import { seoHead, type SeoHead } from '../lib/seo.js';
5
- import { getSite, getSiteSettings } from '../lib/shapio.js';
6
6
  import {
7
7
  articlesPath,
8
8
  colophonPath,
@@ -30,12 +30,13 @@ const { title, seo, type, locale, alternatePath, noindex = false } = Astro.props
30
30
  const strings = stringsFor(locale);
31
31
  const other = otherLocale(locale);
32
32
  // The name, tagline and footer come from the siteSettings singleton (the built-in strings until it is published).
33
- 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);
34
35
  const siteName = settings?.siteName ?? strings.siteName;
35
36
  // Title, description, social tags and canonical: the entry's SEO fields over the site's SEO defaults.
36
37
  const head = seoHead({
37
38
  seo: seo ?? null,
38
- site: await getSite(),
39
+ site,
39
40
  locale,
40
41
  localeChain: [locale, DEFAULT_LOCALE],
41
42
  title,
@@ -90,5 +91,12 @@ const robotsNoindex = noindex || head.noindex;
90
91
  <footer class="site-footer">
91
92
  <p>{siteName} · {settings?.footer ?? 'Built with Shapio'} · Astro</p>
92
93
  </footer>
94
+ {
95
+ env.drafts ? (
96
+ <p class="drafts-badge" title={strings.draftsNote} data-shapio-drafts="">
97
+ {strings.drafts}
98
+ </p>
99
+ ) : null
100
+ }
93
101
  </body>
94
102
  </html>