create-shapio 0.5.0 → 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.
- package/package.json +1 -1
- package/templates/astro/.env.example +6 -0
- package/templates/astro/README.md +9 -0
- package/templates/astro/package.json +7 -4
- package/templates/astro/scripts/seed.ts +77 -24
- package/templates/astro/scripts/smokeDrafts.ts +109 -0
- package/templates/astro/src/layouts/Base.astro +8 -0
- package/templates/astro/src/lib/config.ts +13 -1
- package/templates/astro/src/lib/renderTime.test.ts +31 -0
- package/templates/astro/src/lib/renderTime.ts +24 -0
- package/templates/astro/src/lib/shapio.ts +88 -64
- package/templates/astro/src/lib/site.ts +4 -0
- package/templates/astro/src/lib/snapshotResolver.test.ts +64 -0
- package/templates/astro/src/lib/snapshotResolver.ts +52 -0
- package/templates/astro/src/lib/types.ts +0 -9
- package/templates/astro/src/pages/[locale]/[...slug].astro +12 -1
- package/templates/astro/src/pages/[locale]/articles/[slug].astro +12 -1
- package/templates/astro/src/pages/[locale]/articles/index.astro +6 -1
- package/templates/astro/src/pages/build.json.ts +9 -2
- package/templates/astro/src/styles/site.css +14 -0
- package/templates/next/.env.example +6 -0
- package/templates/next/README.md +9 -0
- package/templates/next/next.config.ts +5 -1
- package/templates/next/package.json +4 -3
- package/templates/next/scripts/seed.ts +77 -24
- package/templates/next/scripts/smokeDrafts.ts +109 -0
- package/templates/next/src/app/[locale]/layout.tsx +3 -0
- package/templates/next/src/app/site.css +14 -0
- package/templates/next/src/components/DraftsBadge/index.tsx +10 -0
- package/templates/next/src/lib/config.ts +13 -1
- package/templates/next/src/lib/revalidation.ts +4 -1
- package/templates/next/src/lib/shapio.ts +24 -4
- package/templates/next/src/lib/shapioClient.ts +6 -4
- package/templates/next/src/lib/site.ts +4 -0
- package/templates/next/src/lib/snapshotResolver.test.ts +64 -0
- package/templates/next/src/lib/snapshotResolver.ts +52 -0
- package/templates/sveltekit/.env.example +6 -0
- package/templates/sveltekit/README.md +9 -0
- package/templates/sveltekit/package.json +7 -4
- package/templates/sveltekit/scripts/seed.ts +77 -24
- package/templates/sveltekit/scripts/smokeDrafts.ts +109 -0
- package/templates/sveltekit/src/env.ts +2 -0
- package/templates/sveltekit/src/lib/server/config.ts +11 -1
- package/templates/sveltekit/src/lib/server/shapio.ts +28 -13
- package/templates/sveltekit/src/lib/server/snapshotResolver.test.ts +64 -0
- package/templates/sveltekit/src/lib/server/snapshotResolver.ts +52 -0
- package/templates/sveltekit/src/lib/site.css +14 -0
- package/templates/sveltekit/src/lib/site.ts +4 -0
- package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +3 -2
- package/templates/sveltekit/src/routes/[locale]/+layout.svelte +3 -0
package/package.json
CHANGED
|
@@ -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=
|
|
@@ -19,6 +19,15 @@ npm run smoke:http # checks the served pages over HTTP (in another terminal, wh
|
|
|
19
19
|
`SHAPIO_DELIVERY_TOKEN` to `.env`. Other settings (`SHAPIO_SNAPSHOT`, `SHAPIO_SITE`, `PUBLIC_SHAPIO_URL`) are
|
|
20
20
|
in `.env.example`. Astro's telemetry is turned off in the npm scripts.
|
|
21
21
|
|
|
22
|
+
Drafts mode, for your development server: set `SHAPIO_DRAFTS=true` in `.env` and `npm run dev` shows saved
|
|
23
|
+
drafts instead of published content, fresh on every reload, with a **Drafts** badge on every page. Save in
|
|
24
|
+
Shapio, reload, and the change is there; publishing still rebuilds the live site. It reads with
|
|
25
|
+
`SHAPIO_DEV_DELIVERY_TOKEN`, the seed's `<site> dev` token, whose delivery role grants Read drafts (a token
|
|
26
|
+
without it fails with `DRAFTS_FORBIDDEN`). Never put that token or the flag in a production environment.
|
|
27
|
+
`npm run smoke:drafts` checks a running drafts-mode dev server
|
|
28
|
+
(http://localhost:4321; needs `SHAPIO_ADMIN_EMAIL`/`PASSWORD` to save a test change, which it puts back). Guide:
|
|
29
|
+
[drafts mode](https://github.com/mybrokengnome/shapio/blob/main/documentation/starters.md#drafts-mode).
|
|
30
|
+
|
|
22
31
|
Preview: the seed creates a deployment connection named **Preview** that opens drafts at
|
|
23
32
|
`http://localhost:4321/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
|
|
24
33
|
`CORS_ORIGINS`. In Shapio's preview pane, clicking the title, body or cover focuses that field and saves
|
|
@@ -13,19 +13,22 @@
|
|
|
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
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
21
|
+
"test": "vitest run"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
|
-
"@shapio/client": "^0.5.
|
|
23
|
-
"@shapio/visual": "^0.5.
|
|
24
|
+
"@shapio/client": "^0.5.1",
|
|
25
|
+
"@shapio/visual": "^0.5.1",
|
|
24
26
|
"astro": "7.3.5"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
27
29
|
"@types/node": "24.19.1",
|
|
28
30
|
"tsx": "4.23.15",
|
|
29
|
-
"typescript": "6.0.3"
|
|
31
|
+
"typescript": "6.0.3",
|
|
32
|
+
"vitest": "5.0.3"
|
|
30
33
|
}
|
|
31
34
|
}
|
|
@@ -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
|
-
|
|
312
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
modelId,
|
|
320
|
-
|
|
321
|
-
|
|
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 ===
|
|
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:
|
|
329
|
-
name:
|
|
330
|
-
description:
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
406
|
-
url: string
|
|
407
|
-
site: string | undefined
|
|
408
|
-
deliveryToken: string
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
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,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../styles/site.css';
|
|
3
3
|
import type { SeoResolved } from '@shapio/client';
|
|
4
|
+
import { isDraftsMode } from '../lib/config.js';
|
|
4
5
|
import { seoHead, type SeoHead } from '../lib/seo.js';
|
|
5
6
|
import { getSite, getSiteSettings } from '../lib/shapio.js';
|
|
6
7
|
import {
|
|
@@ -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
|
+
isDraftsMode() ? (
|
|
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>
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* without it the build pins the latest snapshot when it starts.
|
|
7
7
|
* - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
|
|
8
8
|
* - PUBLIC_SHAPIO_URL: the origin the browser calls for previews (defaults to SHAPIO_URL).
|
|
9
|
+
* - SHAPIO_DRAFTS: `true` turns on drafts mode, for your development server: every read shows saved drafts
|
|
10
|
+
* instead of published content (no snapshot), and pages carry a "Drafts" badge. The token must be a delivery
|
|
11
|
+
* token whose role grants Read drafts: SHAPIO_DEV_DELIVERY_TOKEN when set (`npm run seed` writes one), else
|
|
12
|
+
* SHAPIO_DELIVERY_TOKEN. Never set it in a production environment.
|
|
9
13
|
*/
|
|
10
14
|
const read = (name: string): string | undefined => {
|
|
11
15
|
const fromVite = (import.meta as { env?: Record<string, string | undefined> }).env?.[name];
|
|
@@ -15,8 +19,13 @@ const read = (name: string): string | undefined => {
|
|
|
15
19
|
|
|
16
20
|
export const shapioUrl = () => read('SHAPIO_URL') ?? 'http://localhost:4300';
|
|
17
21
|
|
|
22
|
+
/** Drafts mode (SHAPIO_DRAFTS=true): the site reads saved drafts. Never set in production. */
|
|
23
|
+
export const isDraftsMode = () => read('SHAPIO_DRAFTS') === 'true';
|
|
24
|
+
|
|
18
25
|
export const deliveryToken = () => {
|
|
19
|
-
|
|
26
|
+
// Drafts mode reads with the development token (Read drafts) when there is one.
|
|
27
|
+
const token =
|
|
28
|
+
(isDraftsMode() ? read('SHAPIO_DEV_DELIVERY_TOKEN') : undefined) ?? read('SHAPIO_DELIVERY_TOKEN');
|
|
20
29
|
if (!token) {
|
|
21
30
|
throw new Error(
|
|
22
31
|
'Set SHAPIO_DELIVERY_TOKEN (a Shapio delivery token) to build the site; `npm run seed` writes one to .env',
|
|
@@ -40,3 +49,6 @@ export const configuredSnapshot = (): number | undefined => {
|
|
|
40
49
|
export const publicShapioUrl = () => read('PUBLIC_SHAPIO_URL') ?? shapioUrl();
|
|
41
50
|
|
|
42
51
|
export const siteKey = () => read('SHAPIO_SITE');
|
|
52
|
+
|
|
53
|
+
/** True under `astro dev` (Vite's import.meta.env.DEV); false in builds and in the scripts. */
|
|
54
|
+
export const isDev = () => (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { findBySlug, renderTimeEntry } from './renderTime.js';
|
|
3
|
+
|
|
4
|
+
describe('renderTimeEntry', () => {
|
|
5
|
+
it('renders the getStaticPaths props in a build, without reading', async () => {
|
|
6
|
+
const read = vi.fn();
|
|
7
|
+
expect(await renderTimeEntry({ dev: false, props: { slug: 'about' }, read })).toEqual({ slug: 'about' });
|
|
8
|
+
expect(read).not.toHaveBeenCalled();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('reads the entry when it renders in dev, over stale props', async () => {
|
|
12
|
+
const read = vi.fn().mockResolvedValue({ slug: 'about', title: 'New' });
|
|
13
|
+
expect(await renderTimeEntry({ dev: true, props: { slug: 'about', title: 'Old' }, read })).toEqual({
|
|
14
|
+
slug: 'about',
|
|
15
|
+
title: 'New',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('is undefined in dev when the entry is gone, so the page answers 404', async () => {
|
|
20
|
+
const read = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
expect(await renderTimeEntry({ dev: true, props: { slug: 'about' }, read })).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('findBySlug', () => {
|
|
26
|
+
it('finds the entry with the slug, or nothing', () => {
|
|
27
|
+
const entries = [{ slug: 'home' }, { slug: 'about' }];
|
|
28
|
+
expect(findBySlug(entries, 'about')).toBe(entries[1]);
|
|
29
|
+
expect(findBySlug(entries, 'gone')).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a dynamic page renders. A build renders what getStaticPaths handed over as props (every page at the
|
|
3
|
+
* build's one snapshot). `astro dev` keeps getStaticPaths results until it restarts, so there the page reads
|
|
4
|
+
* its entry from Shapio when it renders, from `Astro.params`: a publish shows on reload, and an entry that is
|
|
5
|
+
* gone (unpublished or deleted) is undefined, which the page answers with a 404.
|
|
6
|
+
*/
|
|
7
|
+
type RenderTimeSource<T> = {
|
|
8
|
+
/** True under `astro dev`. */
|
|
9
|
+
dev: boolean;
|
|
10
|
+
/** What getStaticPaths handed over. */
|
|
11
|
+
props: T | undefined;
|
|
12
|
+
/** Reads the entry now; undefined when it is not published. */
|
|
13
|
+
read: () => Promise<T | undefined>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const renderTimeEntry = async <T>({
|
|
17
|
+
dev,
|
|
18
|
+
props,
|
|
19
|
+
read,
|
|
20
|
+
}: RenderTimeSource<T>): Promise<T | undefined> => (dev ? read() : props);
|
|
21
|
+
|
|
22
|
+
/** The entry with this slug, if any. */
|
|
23
|
+
export const findBySlug = <T extends { slug: string }>(entries: readonly T[], slug: string): T | undefined =>
|
|
24
|
+
entries.find((entry) => entry.slug === slug);
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { createClient, ShapioApiError, type DeliverySite } from '@shapio/client';
|
|
2
|
-
import { configuredSnapshot, deliveryToken, shapioUrl, siteKey } from './config.js';
|
|
1
|
+
import { createClient, ShapioApiError, type DeliveryListQuery, type DeliverySite } from '@shapio/client';
|
|
2
|
+
import { configuredSnapshot, deliveryToken, isDev, isDraftsMode, shapioUrl, siteKey } from './config.js';
|
|
3
3
|
import type { Locale } from './site.js';
|
|
4
|
-
import
|
|
4
|
+
import { cachedRead, cacheWindow, createSnapshotResolver } from './snapshotResolver.js';
|
|
5
|
+
import type { Article, Page, SiteSettings } from './types.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The site's read side: Shapio's delivery API through `@shapio/client`, pinned to one publication snapshot
|
|
8
9
|
* for the whole build. The snapshot is read once, when the build starts (or taken from SHAPIO_SNAPSHOT), and
|
|
9
10
|
* sent with every request, so a page built a minute later still shows the same moment: a publish during the
|
|
10
|
-
* build never yields a site that mixes old and new content.
|
|
11
|
+
* build never yields a site that mixes old and new content. Under `astro dev` the snapshot and the site settings
|
|
12
|
+
* are read again once the last read is a second old, so a publish shows on reload (src/lib/snapshotResolver.ts).
|
|
13
|
+
* Drafts mode (SHAPIO_DRAFTS=true) reads saved drafts, which have no snapshot: no read sends one.
|
|
11
14
|
*/
|
|
12
15
|
const PAGE_SIZE = 100;
|
|
13
16
|
|
|
@@ -19,8 +22,8 @@ const ARTICLES = 'articles';
|
|
|
19
22
|
* The fields each listing renders, sent as `fields=`: the response carries only these, and Shapio's field
|
|
20
23
|
* usage shows exactly what the site reads (a whole-model read would count every field as used).
|
|
21
24
|
*/
|
|
22
|
-
const PAGE_FIELDS = 'title,slug,description,sections,seo';
|
|
23
|
-
const ARTICLE_FIELDS = 'title,slug,excerpt,body,cover,author,publishedOn,seo';
|
|
25
|
+
const PAGE_FIELDS = ['title', 'slug', 'description', 'sections', 'seo'];
|
|
26
|
+
const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn', 'seo'];
|
|
24
27
|
|
|
25
28
|
/** The `siteSettings` singleton is read by its API ID (singletons have no plural). */
|
|
26
29
|
const SITE_SETTINGS = 'siteSettings';
|
|
@@ -28,40 +31,54 @@ const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
|
|
|
28
31
|
|
|
29
32
|
let client: ReturnType<typeof createClient> | undefined;
|
|
30
33
|
const shapio = () =>
|
|
31
|
-
(client ??= createClient({
|
|
34
|
+
(client ??= createClient({
|
|
35
|
+
baseUrl: shapioUrl(),
|
|
36
|
+
token: deliveryToken(),
|
|
37
|
+
site: siteKey(),
|
|
38
|
+
drafts: isDraftsMode(),
|
|
39
|
+
}));
|
|
32
40
|
|
|
33
|
-
let
|
|
41
|
+
let resolver: (() => Promise<number>) | undefined;
|
|
34
42
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The snapshot every request reads: one for the whole build; in dev, the current one (re-read after 1 s); in
|
|
45
|
+
* drafts mode, none (drafts cannot be pinned).
|
|
46
|
+
*/
|
|
47
|
+
export const pinnedSnapshot = async (): Promise<number | undefined> => {
|
|
48
|
+
if (isDraftsMode()) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const dev = isDev();
|
|
52
|
+
resolver ??= createSnapshotResolver({
|
|
53
|
+
dev,
|
|
54
|
+
configured: configuredSnapshot(),
|
|
55
|
+
current: async () => {
|
|
56
|
+
const { snapshot } = await shapio().snapshots.current();
|
|
57
|
+
if (!dev) {
|
|
58
|
+
// Shared with any other copy of this module in the build (one value per build).
|
|
59
|
+
process.env.SHAPIO_SNAPSHOT = String(snapshot);
|
|
60
|
+
}
|
|
61
|
+
return snapshot;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return resolver();
|
|
65
|
+
};
|
|
50
66
|
|
|
51
67
|
/** Every published entry of a collection (by plural API ID) in one locale, page by page, at the pinned snapshot. */
|
|
52
|
-
const listAll = async <T>(
|
|
53
|
-
routeKey: string,
|
|
54
|
-
locale: Locale,
|
|
55
|
-
extra: Record<string, string> = {},
|
|
56
|
-
): Promise<T[]> => {
|
|
68
|
+
const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
|
|
57
69
|
const snapshot = await pinnedSnapshot();
|
|
58
70
|
const entries: T[] = [];
|
|
59
71
|
for (let page = 1; ; page += 1) {
|
|
60
|
-
const result = await shapio().
|
|
72
|
+
const result = await shapio().delivery.list<T>(routeKey, {
|
|
73
|
+
...query,
|
|
61
74
|
// The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
|
|
62
75
|
// its SEO fields with the site's defaults filled in (`seo=resolved`).
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
richText: 'html',
|
|
77
|
+
seo: 'resolved',
|
|
78
|
+
snapshot,
|
|
79
|
+
page,
|
|
80
|
+
pageSize: PAGE_SIZE,
|
|
81
|
+
});
|
|
65
82
|
entries.push(...result.data);
|
|
66
83
|
if (page >= result.meta.pagination.pageCount) {
|
|
67
84
|
return entries;
|
|
@@ -69,55 +86,62 @@ const listAll = async <T>(
|
|
|
69
86
|
}
|
|
70
87
|
};
|
|
71
88
|
|
|
72
|
-
export const listPages = (locale: Locale) => listAll<Page>(PAGES, locale,
|
|
89
|
+
export const listPages = (locale: Locale) => listAll<Page>(PAGES, { locale, fields: PAGE_FIELDS });
|
|
73
90
|
|
|
74
91
|
export const listArticles = (locale: Locale) =>
|
|
75
|
-
listAll<Article>(ARTICLES,
|
|
92
|
+
listAll<Article>(ARTICLES, {
|
|
93
|
+
locale,
|
|
76
94
|
fields: ARTICLE_FIELDS,
|
|
77
|
-
populate: 'author',
|
|
78
|
-
sort: 'publishedOn:desc',
|
|
95
|
+
populate: ['author'],
|
|
96
|
+
sort: [{ field: 'publishedOn', direction: 'desc' }],
|
|
79
97
|
});
|
|
80
98
|
|
|
81
|
-
const
|
|
99
|
+
const readSiteSettings = async (locale: Locale): Promise<SiteSettings | null> => {
|
|
100
|
+
try {
|
|
101
|
+
const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
|
|
102
|
+
locale,
|
|
103
|
+
snapshot: await pinnedSnapshot(),
|
|
104
|
+
fields: SITE_SETTINGS_FIELDS,
|
|
105
|
+
richText: 'html',
|
|
106
|
+
});
|
|
107
|
+
return data;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof ShapioApiError && error.status === 404) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const settingsByLocale = new Map<Locale, () => Promise<SiteSettings | null>>();
|
|
82
117
|
|
|
83
118
|
/** The site settings singleton in one locale at the pinned snapshot; null until it is published. */
|
|
84
119
|
export const getSiteSettings = (locale: Locale): Promise<SiteSettings | null> => {
|
|
85
120
|
let settings = settingsByLocale.get(locale);
|
|
86
121
|
if (!settings) {
|
|
87
|
-
settings = (
|
|
88
|
-
try {
|
|
89
|
-
const snapshot = await pinnedSnapshot();
|
|
90
|
-
const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
|
|
91
|
-
locale,
|
|
92
|
-
snapshot,
|
|
93
|
-
fields: SITE_SETTINGS_FIELDS,
|
|
94
|
-
richText: 'html',
|
|
95
|
-
});
|
|
96
|
-
return data;
|
|
97
|
-
} catch (error) {
|
|
98
|
-
if (error instanceof ShapioApiError && error.status === 404) {
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
})();
|
|
122
|
+
settings = cachedRead(() => readSiteSettings(locale), cacheWindow(isDev()));
|
|
104
123
|
settingsByLocale.set(locale, settings);
|
|
105
124
|
}
|
|
106
|
-
return settings;
|
|
125
|
+
return settings();
|
|
107
126
|
};
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
const readSite = async (): Promise<DeliverySite | null> => {
|
|
129
|
+
try {
|
|
130
|
+
return await shapio().site.get();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof ShapioApiError && error.status === 404) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let site: (() => Promise<DeliverySite | null>) | undefined;
|
|
110
140
|
|
|
111
141
|
/**
|
|
112
142
|
* The site and its SEO defaults (name and title template per locale, default image, Twitter handle), once per
|
|
113
|
-
* build. Null when the Shapio predates them: the pages still render, with plain
|
|
143
|
+
* build (in dev, re-read after 1 s). Null when the Shapio predates them: the pages still render, with plain
|
|
144
|
+
* titles.
|
|
114
145
|
*/
|
|
115
146
|
export const getSite = (): Promise<DeliverySite | null> =>
|
|
116
|
-
(site ??=
|
|
117
|
-
.site.get()
|
|
118
|
-
.catch((error: unknown) => {
|
|
119
|
-
if (error instanceof ShapioApiError && error.status === 404) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
throw error;
|
|
123
|
-
}));
|
|
147
|
+
(site ??= cachedRead(readSite, cacheWindow(isDev())))();
|