create-shapio 0.4.1 → 0.5.1

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 (56) hide show
  1. package/package.json +1 -1
  2. package/templates/astro/.env.example +6 -0
  3. package/templates/astro/README.md +9 -0
  4. package/templates/astro/package.json +7 -4
  5. package/templates/astro/scripts/seed.ts +77 -24
  6. package/templates/astro/scripts/smokeDrafts.ts +109 -0
  7. package/templates/astro/scripts/smokeHttp.ts +29 -1
  8. package/templates/astro/src/layouts/Base.astro +8 -0
  9. package/templates/astro/src/lib/config.ts +13 -1
  10. package/templates/astro/src/lib/renderTime.test.ts +31 -0
  11. package/templates/astro/src/lib/renderTime.ts +24 -0
  12. package/templates/astro/src/lib/shapio.ts +88 -64
  13. package/templates/astro/src/lib/site.ts +4 -0
  14. package/templates/astro/src/lib/snapshotResolver.test.ts +64 -0
  15. package/templates/astro/src/lib/snapshotResolver.ts +52 -0
  16. package/templates/astro/src/lib/types.ts +0 -9
  17. package/templates/astro/src/pages/[locale]/[...slug].astro +12 -1
  18. package/templates/astro/src/pages/[locale]/articles/[slug].astro +12 -1
  19. package/templates/astro/src/pages/[locale]/articles/index.astro +6 -1
  20. package/templates/astro/src/pages/build.json.ts +9 -2
  21. package/templates/astro/src/styles/site.css +14 -0
  22. package/templates/next/.env.example +10 -0
  23. package/templates/next/README.md +37 -11
  24. package/templates/next/next.config.ts +17 -12
  25. package/templates/next/package.json +4 -2
  26. package/templates/next/scripts/seed.ts +77 -24
  27. package/templates/next/scripts/smokeDrafts.ts +109 -0
  28. package/templates/next/scripts/smokeHttp.ts +29 -1
  29. package/templates/next/src/app/[locale]/layout.tsx +3 -0
  30. package/templates/next/src/app/api/revalidate/route.ts +3 -1
  31. package/templates/next/src/app/site.css +14 -0
  32. package/templates/next/src/components/DraftsBadge/index.tsx +10 -0
  33. package/templates/next/src/lib/config.ts +35 -1
  34. package/templates/next/src/lib/revalidation.ts +30 -12
  35. package/templates/next/src/lib/revalidationTags.test.ts +43 -0
  36. package/templates/next/src/lib/revalidationTags.ts +30 -0
  37. package/templates/next/src/lib/shapio.ts +42 -13
  38. package/templates/next/src/lib/shapioClient.ts +19 -0
  39. package/templates/next/src/lib/site.ts +4 -0
  40. package/templates/next/src/lib/snapshotResolver.test.ts +64 -0
  41. package/templates/next/src/lib/snapshotResolver.ts +52 -0
  42. package/templates/sveltekit/.env.example +6 -0
  43. package/templates/sveltekit/README.md +9 -0
  44. package/templates/sveltekit/package.json +7 -4
  45. package/templates/sveltekit/scripts/seed.ts +77 -24
  46. package/templates/sveltekit/scripts/smokeDrafts.ts +109 -0
  47. package/templates/sveltekit/scripts/smokeHttp.ts +29 -1
  48. package/templates/sveltekit/src/env.ts +2 -0
  49. package/templates/sveltekit/src/lib/server/config.ts +11 -1
  50. package/templates/sveltekit/src/lib/server/shapio.ts +28 -13
  51. package/templates/sveltekit/src/lib/server/snapshotResolver.test.ts +64 -0
  52. package/templates/sveltekit/src/lib/server/snapshotResolver.ts +52 -0
  53. package/templates/sveltekit/src/lib/site.css +14 -0
  54. package/templates/sveltekit/src/lib/site.ts +4 -0
  55. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +3 -2
  56. package/templates/sveltekit/src/routes/[locale]/+layout.svelte +3 -0
@@ -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,4 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { parseArgs } from 'node:util';
3
5
 
4
6
  /**
@@ -13,6 +15,10 @@ import { parseArgs } from 'node:util';
13
15
  * `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
14
16
  * Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
15
17
  * and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
18
+ *
19
+ * `--save <dir>` also writes each checked page's rendered markup to `<dir>` (scripts and the framework's own
20
+ * asset links removed: they name build-specific chunks), so two builds of a site can be compared with
21
+ * `diff -r`, e.g. the Next.js starter reading over HTTP and in process.
16
22
  */
17
23
  const DRAFT_TITLE = 'Coming soon: our winter projects';
18
24
  const ARTICLE = 'modelling-without-a-deploy';
@@ -85,6 +91,18 @@ const fetchPage = async (origin: string, path: string) => {
85
91
  return { status: response.status, html: await response.text() };
86
92
  };
87
93
 
94
+ /** The page as rendered: without scripts and framework asset links, which differ from build to build. */
95
+ const renderedMarkup = (html: string) =>
96
+ html
97
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
98
+ .replace(/<link\b[^>]*\/_next\/[^>]*>/g, '')
99
+ .replace(/<meta\b[^>]*name="next-size-adjust"[^>]*>/g, '');
100
+
101
+ const savePage = async (directory: string, path: string, html: string) => {
102
+ const name = path.replace(/^\/|\/$/g, '').replace(/\//g, '__') || 'index';
103
+ await writeFile(join(directory, `${name}.html`), `${renderedMarkup(html)}\n`);
104
+ };
105
+
88
106
  const REVALIDATE_PATH = '/api/revalidate/';
89
107
 
90
108
  const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
@@ -137,12 +155,22 @@ const checkRevalidation = async (origin: string) => {
137
155
 
138
156
  const main = async () => {
139
157
  const { values } = parseArgs({
140
- options: { url: { type: 'string' }, revalidate: { type: 'boolean', default: false } },
158
+ options: {
159
+ url: { type: 'string' },
160
+ revalidate: { type: 'boolean', default: false },
161
+ save: { type: 'string' },
162
+ },
141
163
  });
142
164
  const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
165
+ if (values.save) {
166
+ await mkdir(values.save, { recursive: true });
167
+ }
143
168
  for (const page of PAGES) {
144
169
  const { status, html } = await fetchPage(origin, page.path);
145
170
  check(status === 200, `${page.path}: HTTP ${status}`);
171
+ if (values.save) {
172
+ await savePage(values.save, page.path, html);
173
+ }
146
174
  for (const [needle, label] of page.expect) {
147
175
  check(html.includes(needle), `${page.path}: ${label}`);
148
176
  }
@@ -2,7 +2,9 @@ import type { Metadata } from 'next';
2
2
  import Link from 'next/link';
3
3
  import { notFound } from 'next/navigation';
4
4
  import type { ReactNode } from 'react';
5
+ import { DraftsBadge } from '../../components/DraftsBadge';
5
6
  import { LocaleSwitch } from '../../components/LocaleSwitch';
7
+ import { isDraftsMode } from '../../lib/config';
6
8
  import { getSiteSettings } from '../../lib/shapio';
7
9
  import {
8
10
  articlesPath,
@@ -73,6 +75,7 @@ const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => {
73
75
  {settings?.siteName ?? strings.siteName} · {settings?.footer ?? strings.footer} · Next.js
74
76
  </p>
75
77
  </footer>
78
+ {isDraftsMode() ? <DraftsBadge strings={strings} /> : null}
76
79
  </body>
77
80
  </html>
78
81
  );
@@ -8,7 +8,9 @@ import { readWebhookEvent } from '../../../lib/webhookEvent';
8
8
  * POST /api/revalidate/ (with the slash: `trailingSlash` redirects the other form, and Shapio's webhooks do not
9
9
  * follow redirects): the target of the Shapio webhook the seed creates (publish, unpublish, delete,
10
10
  * change set shipped, schema change, site settings). A signed delivery refreshes the pages whose content changed since the
11
- * snapshot the site shows (src/lib/revalidation.ts). A failure answers 500, so Shapio retries the delivery.
11
+ * snapshot the site shows and expires the cached reads tagged with their models and entries; `site.updated` and
12
+ * schema changes expire the site tag, which every read carries (src/lib/revalidation.ts). A failure answers 500,
13
+ * so Shapio retries the delivery.
12
14
  */
13
15
  export const POST = async (request: Request) => {
14
16
  const read = await readWebhookEvent(request, webhookSecret());
@@ -197,3 +197,17 @@ main {
197
197
  text-align: center;
198
198
  font-weight: 600;
199
199
  }
200
+ /* Drafts mode (SHAPIO_DRAFTS=true): a corner badge, so a drafts build is never mistaken for the real site. */
201
+ .drafts-badge {
202
+ position: fixed;
203
+ right: 1rem;
204
+ bottom: 1rem;
205
+ z-index: 10;
206
+ margin: 0;
207
+ background: #78350f;
208
+ color: #fef3c7;
209
+ padding: 0.25rem 0.75rem;
210
+ border-radius: 999px;
211
+ font-size: 0.875rem;
212
+ font-weight: 700;
213
+ }
@@ -0,0 +1,10 @@
1
+ import type { Strings } from '../../lib/site';
2
+
3
+ type DraftsBadgeProps = { strings: Strings };
4
+
5
+ /** The corner badge of drafts mode (SHAPIO_DRAFTS=true): this server shows saved drafts, not the published site. */
6
+ export const DraftsBadge = ({ strings }: DraftsBadgeProps) => (
7
+ <p className="drafts-badge" title={strings.draftsNote} data-shapio-drafts="">
8
+ {strings.drafts}
9
+ </p>
10
+ );
@@ -12,6 +12,14 @@
12
12
  * URL (and `og:url`) unless the entry sets its own.
13
13
  * - NEXT_PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL; inlined into
14
14
  * the client bundle at build time).
15
+ * - SHAPIO_MODE: `http` (default) reads Shapio's delivery API over HTTP; `in-process` reads the same API as
16
+ * function calls in this server process (@shapio/local), from Shapio's database at DATABASE_URL. Previews
17
+ * and the browser still use SHAPIO_URL. See documentation/in-process.md.
18
+ * - DATABASE_URL: Shapio's own database (PostgreSQL or MySQL), for SHAPIO_MODE=in-process only.
19
+ * - SHAPIO_DRAFTS: `true` turns on drafts mode, for your development server: every read shows saved drafts
20
+ * instead of published content, fresh on every request (no snapshot, no cache), and pages carry a "Drafts"
21
+ * badge. The token must be a delivery token whose role grants Read drafts: SHAPIO_DEV_DELIVERY_TOKEN when
22
+ * set (`npm run seed` writes one), else SHAPIO_DELIVERY_TOKEN. Never set it in a production environment.
15
23
  */
16
24
  const read = (name: string): string | undefined => {
17
25
  const value = process.env[name];
@@ -20,8 +28,13 @@ const read = (name: string): string | undefined => {
20
28
 
21
29
  export const shapioUrl = () => read('SHAPIO_URL') ?? 'http://localhost:4300';
22
30
 
31
+ /** Drafts mode (SHAPIO_DRAFTS=true): the site reads saved drafts. Never set in production. */
32
+ export const isDraftsMode = () => read('SHAPIO_DRAFTS') === 'true';
33
+
23
34
  export const deliveryToken = () => {
24
- const token = read('SHAPIO_DELIVERY_TOKEN');
35
+ // Drafts mode reads with the development token (Read drafts) when there is one.
36
+ const token =
37
+ (isDraftsMode() ? read('SHAPIO_DEV_DELIVERY_TOKEN') : undefined) ?? read('SHAPIO_DELIVERY_TOKEN');
25
38
  if (!token) {
26
39
  throw new Error(
27
40
  'Set SHAPIO_DELIVERY_TOKEN (a Shapio delivery token) to build the site; `npm run seed` writes one to .env',
@@ -32,6 +45,24 @@ export const deliveryToken = () => {
32
45
 
33
46
  export const siteKey = () => read('SHAPIO_SITE');
34
47
 
48
+ export type ShapioMode = 'http' | 'in-process';
49
+
50
+ export const shapioMode = (): ShapioMode => {
51
+ const mode = read('SHAPIO_MODE') ?? 'http';
52
+ if (mode !== 'http' && mode !== 'in-process') {
53
+ throw new Error(`SHAPIO_MODE must be "http" or "in-process", got "${mode}"`);
54
+ }
55
+ return mode;
56
+ };
57
+
58
+ export const databaseUrl = () => {
59
+ const url = read('DATABASE_URL');
60
+ if (!url) {
61
+ throw new Error("SHAPIO_MODE=in-process reads Shapio's database: set DATABASE_URL to it");
62
+ }
63
+ return url;
64
+ };
65
+
35
66
  export const siteUrl = () => read('SITE_URL');
36
67
 
37
68
  export const configuredSnapshot = (): number | undefined => {
@@ -54,6 +85,9 @@ export const configuredSnapshot = (): number | undefined => {
54
85
  */
55
86
  export const isSnapshotPinned = () => process.env.SHAPIO_SNAPSHOT_PINNED === 'true';
56
87
 
88
+ /** True under `next dev`. Spelled out so Next inlines it. */
89
+ export const isDevServer = () => process.env.NODE_ENV === 'development';
90
+
57
91
  /** The secret /api/revalidate checks webhook signatures with; undefined when it is not set. */
58
92
  export const webhookSecret = () => read('SHAPIO_WEBHOOK_SECRET');
59
93
 
@@ -1,8 +1,9 @@
1
1
  import { ShapioApiError, type SnapshotChange, type SnapshotChangeKind } from '@shapio/client';
2
- import { revalidatePath } from 'next/cache';
3
- import { configuredSnapshot, isSnapshotPinned } from './config';
2
+ import { revalidatePath, revalidateTag } from 'next/cache';
3
+ import { configuredSnapshot, isDraftsMode, isSnapshotPinned, siteKey } from './config';
4
4
  import { advanceTo, liveSnapshot } from './liveSnapshot';
5
5
  import { log } from './log';
6
+ import { changeTags, siteTags } from './revalidationTags';
6
7
  import {
7
8
  EVERY_PAGE,
8
9
  revalidationTargets,
@@ -13,12 +14,21 @@ import { shapio } from './shapio';
13
14
 
14
15
  /**
15
16
  * On-demand revalidation (`next start`): asks Shapio what changed between the snapshot the site shows and
16
- * the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and moves the site to
17
- * the new snapshot. The diff, not the event, is the source of truth: one call covers a change set's many
18
- * entries, retried or missed deliveries, and both locales of a publish.
17
+ * the current one (`/api/snapshots/changes`), revalidates the pages those changes touch and the cached reads
18
+ * tagged with their models and entries, and moves the site to the new snapshot. The diff, not the event, is the
19
+ * source of truth: one call covers a change set's many entries, retried or missed deliveries, and both locales
20
+ * of a publish.
19
21
  */
20
22
  export type RevalidationResult =
21
- { from: number; to: number; changed: number; revalidated: RevalidationTarget[] } | { skipped: string };
23
+ | { from: number; to: number; changed: number; revalidated: RevalidationTarget[]; tags: string[] }
24
+ | { skipped: string };
25
+
26
+ /** Expires the cached reads at once (not stale-while-revalidate): the next render reads the new content. */
27
+ const expireTags = (tags: readonly string[]) => {
28
+ for (const tag of tags) {
29
+ revalidateTag(tag, { expire: 0 });
30
+ }
31
+ };
22
32
 
23
33
  /** Models whose pages are addressed by slug. */
24
34
  const SLUGGED_MODELS: ReadonlySet<string> = new Set(['page', 'article']);
@@ -59,24 +69,29 @@ const changedEntries = async (items: readonly SnapshotChange[], from: number, to
59
69
  };
60
70
 
61
71
  const revalidateNow = async (): Promise<RevalidationResult> => {
72
+ if (isDraftsMode()) {
73
+ return { skipped: 'drafts mode (SHAPIO_DRAFTS=true) reads drafts, which are never pinned or cached' };
74
+ }
62
75
  if (isSnapshotPinned()) {
63
76
  return { skipped: `the build is pinned to snapshot ${configuredSnapshot()} by SHAPIO_SNAPSHOT` };
64
77
  }
65
78
  const from = liveSnapshot();
66
79
  const { snapshot: to } = await shapio().snapshots.current();
67
80
  if (to <= from) {
68
- return { from, to: from, changed: 0, revalidated: [] };
81
+ return { from, to: from, changed: 0, revalidated: [], tags: [] };
69
82
  }
70
83
  const diff = await shapio().snapshots.allChanges({ from, to });
71
84
  const { schemaVersions } = diff;
72
85
  const schemaChanged = schemaVersions.from === null || schemaVersions.from !== schemaVersions.to;
73
86
  const targets = revalidationTargets(await changedEntries(diff.items, from, to), schemaChanged);
87
+ const tags = changeTags(diff.items, schemaChanged, siteKey());
74
88
  await advanceTo(to, () => {
89
+ expireTags(tags);
75
90
  for (const target of targets) {
76
91
  revalidatePath(target.path, target.type);
77
92
  }
78
93
  });
79
- return { from, to, changed: diff.items.length, revalidated: targets };
94
+ return { from, to, changed: diff.items.length, revalidated: targets, tags };
80
95
  };
81
96
 
82
97
  /** One run at a time: deliveries that arrive together diff from the snapshot the previous run reached. */
@@ -89,22 +104,25 @@ export const revalidateChanges = (): Promise<RevalidationResult> => {
89
104
  log(
90
105
  'skipped' in result
91
106
  ? `Revalidation skipped: ${result.skipped}`
92
- : `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s)`,
107
+ : `Snapshots ${result.from} → ${result.to}: revalidated ${result.revalidated.length} path(s), ${result.tags.length} tag(s)`,
93
108
  );
94
109
  return result;
95
110
  });
96
111
  };
97
112
 
98
113
  /**
99
- * Site-wide changes that publish no snapshot (the site's SEO defaults: `site.updated`): every page is
100
- * revalidated at the snapshot the site already shows.
114
+ * Site-wide changes that publish no snapshot (the site's SEO defaults: `site.updated`): the site tag expires
115
+ * every cached read (`site.get()` among them) and every page is revalidated at the snapshot the site already
116
+ * shows.
101
117
  */
102
118
  export const revalidateEveryPage = (): RevalidationResult => {
103
119
  if (isSnapshotPinned()) {
104
120
  return { skipped: `the build is pinned to snapshot ${configuredSnapshot()} by SHAPIO_SNAPSHOT` };
105
121
  }
122
+ const tags = siteTags(siteKey());
123
+ expireTags(tags);
106
124
  revalidatePath(EVERY_PAGE.path, EVERY_PAGE.type);
107
125
  const at = liveSnapshot();
108
126
  log(`Site settings changed: revalidated every page at snapshot ${at}`);
109
- return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE] };
127
+ return { from: at, to: at, changed: 0, revalidated: [EVERY_PAGE], tags };
110
128
  };
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { changeTags, siteTags } from './revalidationTags';
3
+
4
+ describe('changeTags', () => {
5
+ it("tags each changed entry's model and the entry, without duplicates", () => {
6
+ expect(
7
+ changeTags(
8
+ [
9
+ { routeKey: 'articles', id: 'a1' },
10
+ { routeKey: 'articles', id: 'a2' },
11
+ { routeKey: 'siteSettings', id: 's1' },
12
+ { routeKey: 'articles', id: 'a1' },
13
+ ],
14
+ false,
15
+ 'marketing',
16
+ ),
17
+ ).toEqual([
18
+ 'shapio:articles',
19
+ 'shapio:articles:a1',
20
+ 'shapio:articles:a2',
21
+ 'shapio:siteSettings',
22
+ 'shapio:siteSettings:s1',
23
+ ]);
24
+ });
25
+
26
+ it('revalidates the site tag (every read) after a schema change', () => {
27
+ expect(changeTags([{ routeKey: 'articles', id: 'a1' }], true, 'marketing')).toEqual([
28
+ 'shapio:site:marketing',
29
+ ]);
30
+ expect(changeTags([], true, undefined)).toEqual(['shapio:site']);
31
+ });
32
+
33
+ it('has nothing to revalidate when nothing changed', () => {
34
+ expect(changeTags([], false, undefined)).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe('siteTags', () => {
39
+ it("is the client's site tag: keyed when the site is configured, bare otherwise", () => {
40
+ expect(siteTags('marketing')).toEqual(['shapio:site:marketing']);
41
+ expect(siteTags(undefined)).toEqual(['shapio:site']);
42
+ });
43
+ });
@@ -0,0 +1,30 @@
1
+ import { shapioTags, type SnapshotChange } from '@shapio/client';
2
+
3
+ /**
4
+ * What a set of Shapio changes means for Next's data cache: the arguments for `revalidateTag`, matching the
5
+ * tags `@shapio/client` puts on every delivery read (`shapioTags`). Pure, so the mapping is unit-tested;
6
+ * src/lib/revalidation.ts feeds it from the snapshot diff, which names each changed entry by its route key.
7
+ */
8
+ export type ChangedEntryRef = Pick<SnapshotChange, 'routeKey' | 'id'>;
9
+
10
+ /** Every delivery read of the site carries this tag: the site's settings, and everything after a schema change. */
11
+ export const siteTags = (siteKey: string | undefined): string[] => [shapioTags.site(siteKey)];
12
+
13
+ /**
14
+ * The tags to revalidate for the entries that changed between two snapshots: each entry's model and the entry
15
+ * itself. A schema change (fields added, converted or removed) can change any read, so it revalidates the
16
+ * site tag, which every read carries.
17
+ */
18
+ export const changeTags = (
19
+ changes: readonly ChangedEntryRef[],
20
+ schemaChanged: boolean,
21
+ siteKey: string | undefined,
22
+ ): string[] => {
23
+ if (schemaChanged) {
24
+ return siteTags(siteKey);
25
+ }
26
+ const tags = new Set(
27
+ changes.flatMap(({ routeKey, id }) => [shapioTags.model(routeKey), shapioTags.entry(routeKey, id)]),
28
+ );
29
+ return [...tags].sort();
30
+ };