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.
Files changed (50) 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/src/layouts/Base.astro +8 -0
  8. package/templates/astro/src/lib/config.ts +13 -1
  9. package/templates/astro/src/lib/renderTime.test.ts +31 -0
  10. package/templates/astro/src/lib/renderTime.ts +24 -0
  11. package/templates/astro/src/lib/shapio.ts +88 -64
  12. package/templates/astro/src/lib/site.ts +4 -0
  13. package/templates/astro/src/lib/snapshotResolver.test.ts +64 -0
  14. package/templates/astro/src/lib/snapshotResolver.ts +52 -0
  15. package/templates/astro/src/lib/types.ts +0 -9
  16. package/templates/astro/src/pages/[locale]/[...slug].astro +12 -1
  17. package/templates/astro/src/pages/[locale]/articles/[slug].astro +12 -1
  18. package/templates/astro/src/pages/[locale]/articles/index.astro +6 -1
  19. package/templates/astro/src/pages/build.json.ts +9 -2
  20. package/templates/astro/src/styles/site.css +14 -0
  21. package/templates/next/.env.example +6 -0
  22. package/templates/next/README.md +9 -0
  23. package/templates/next/next.config.ts +5 -1
  24. package/templates/next/package.json +4 -3
  25. package/templates/next/scripts/seed.ts +77 -24
  26. package/templates/next/scripts/smokeDrafts.ts +109 -0
  27. package/templates/next/src/app/[locale]/layout.tsx +3 -0
  28. package/templates/next/src/app/site.css +14 -0
  29. package/templates/next/src/components/DraftsBadge/index.tsx +10 -0
  30. package/templates/next/src/lib/config.ts +13 -1
  31. package/templates/next/src/lib/revalidation.ts +4 -1
  32. package/templates/next/src/lib/shapio.ts +24 -4
  33. package/templates/next/src/lib/shapioClient.ts +6 -4
  34. package/templates/next/src/lib/site.ts +4 -0
  35. package/templates/next/src/lib/snapshotResolver.test.ts +64 -0
  36. package/templates/next/src/lib/snapshotResolver.ts +52 -0
  37. package/templates/sveltekit/.env.example +6 -0
  38. package/templates/sveltekit/README.md +9 -0
  39. package/templates/sveltekit/package.json +7 -4
  40. package/templates/sveltekit/scripts/seed.ts +77 -24
  41. package/templates/sveltekit/scripts/smokeDrafts.ts +109 -0
  42. package/templates/sveltekit/src/env.ts +2 -0
  43. package/templates/sveltekit/src/lib/server/config.ts +11 -1
  44. package/templates/sveltekit/src/lib/server/shapio.ts +28 -13
  45. package/templates/sveltekit/src/lib/server/snapshotResolver.test.ts +64 -0
  46. package/templates/sveltekit/src/lib/server/snapshotResolver.ts +52 -0
  47. package/templates/sveltekit/src/lib/site.css +14 -0
  48. package/templates/sveltekit/src/lib/site.ts +4 -0
  49. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +3 -2
  50. package/templates/sveltekit/src/routes/[locale]/+layout.svelte +3 -0
@@ -22,6 +22,8 @@ const STRINGS = {
22
22
  previewLoading: 'Loading the draft…',
23
23
  previewFailed: 'The preview could not be loaded',
24
24
  skipToContent: 'Skip to content',
25
+ drafts: 'Drafts',
26
+ draftsNote: 'Drafts mode: this server shows saved drafts, not the published site.',
25
27
  },
26
28
  fr: {
27
29
  siteName: 'Studio Northwind',
@@ -36,6 +38,8 @@ const STRINGS = {
36
38
  previewLoading: 'Chargement du brouillon…',
37
39
  previewFailed: "L'aperçu n'a pas pu être chargé",
38
40
  skipToContent: 'Aller au contenu',
41
+ drafts: 'Brouillons',
42
+ draftsNote: 'Mode brouillons : ce serveur montre les brouillons enregistrés, pas le site publié.',
39
43
  },
40
44
  } as const satisfies Record<Locale, Record<string, string>>;
41
45
 
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { cachedRead, createSnapshotResolver, DEV_WINDOW_MS } from './snapshotResolver.js';
3
+
4
+ const clock = (start = 0) => {
5
+ let time = start;
6
+ return { now: () => time, advance: (ms: number) => (time += ms) };
7
+ };
8
+
9
+ describe('createSnapshotResolver', () => {
10
+ it('pins the first snapshot for the whole build', async () => {
11
+ const { now, advance } = clock();
12
+ const current = vi.fn().mockResolvedValueOnce(7).mockResolvedValue(8);
13
+ const snapshot = createSnapshotResolver({ dev: false, configured: undefined, current, now });
14
+
15
+ expect(await snapshot()).toBe(7);
16
+ advance(60 * 60 * 1000);
17
+ expect(await snapshot()).toBe(7);
18
+ expect(current).toHaveBeenCalledTimes(1);
19
+ });
20
+
21
+ it('reuses a dev read within the window and reads the current snapshot again after it', async () => {
22
+ const { now, advance } = clock();
23
+ const current = vi.fn().mockResolvedValueOnce(7).mockResolvedValue(8);
24
+ const snapshot = createSnapshotResolver({ dev: true, configured: undefined, current, now });
25
+
26
+ expect(await snapshot()).toBe(7);
27
+ advance(DEV_WINDOW_MS - 1);
28
+ expect(await snapshot()).toBe(7);
29
+ expect(current).toHaveBeenCalledTimes(1);
30
+
31
+ advance(1);
32
+ expect(await snapshot()).toBe(8);
33
+ expect(current).toHaveBeenCalledTimes(2);
34
+ });
35
+
36
+ it('keeps SHAPIO_SNAPSHOT in dev and in builds, without asking Shapio', async () => {
37
+ const current = vi.fn().mockResolvedValue(8);
38
+ for (const dev of [true, false]) {
39
+ const snapshot = createSnapshotResolver({ dev, configured: 3, current });
40
+ expect(await snapshot()).toBe(3);
41
+ }
42
+ expect(current).not.toHaveBeenCalled();
43
+ });
44
+ });
45
+
46
+ describe('cachedRead', () => {
47
+ it('shares one read between calls that overlap it', async () => {
48
+ const read = vi.fn().mockResolvedValue('value');
49
+ const cached = cachedRead(read, DEV_WINDOW_MS, clock().now);
50
+
51
+ const [first, second] = await Promise.all([cached(), cached()]);
52
+ expect([first, second]).toEqual(['value', 'value']);
53
+ expect(read).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ it('does not keep a failed read', async () => {
57
+ const read = vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValue('value');
58
+ const cached = cachedRead(read, Number.POSITIVE_INFINITY, clock().now);
59
+
60
+ await expect(cached()).rejects.toThrow('down');
61
+ expect(await cached()).toBe('value');
62
+ expect(read).toHaveBeenCalledTimes(2);
63
+ });
64
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which publication snapshot the site reads, and for how long a read is reused. A build pins one snapshot
3
+ * for all its pages (SHAPIO_SNAPSHOT, else the current one when the build starts). The dev server reads the
4
+ * current snapshot again once the last read is older than DEV_WINDOW_MS, so a publish shows on reload while
5
+ * the reads of one page render still share a snapshot.
6
+ */
7
+ export const DEV_WINDOW_MS = 1000;
8
+
9
+ /** How long a cached read is reused: for the whole process in a build, DEV_WINDOW_MS in dev. */
10
+ export const cacheWindow = (dev: boolean): number => (dev ? DEV_WINDOW_MS : Number.POSITIVE_INFINITY);
11
+
12
+ /** `read`, reused for `windowMs` after it starts. A read that fails is not kept: the next call tries again. */
13
+ export const cachedRead = <T>(
14
+ read: () => Promise<T>,
15
+ windowMs: number,
16
+ now: () => number = Date.now,
17
+ ): (() => Promise<T>) => {
18
+ let cached: { value: Promise<T>; at: number } | undefined;
19
+ return () => {
20
+ const at = now();
21
+ if (cached && at - cached.at < windowMs) {
22
+ return cached.value;
23
+ }
24
+ const entry = { value: read(), at };
25
+ cached = entry;
26
+ entry.value.catch(() => {
27
+ if (cached === entry) {
28
+ cached = undefined;
29
+ }
30
+ });
31
+ return entry.value;
32
+ };
33
+ };
34
+
35
+ type SnapshotResolverOptions = {
36
+ /** True under the dev server. */
37
+ dev: boolean;
38
+ /** SHAPIO_SNAPSHOT: when set, the site shows exactly this snapshot, in dev too. */
39
+ configured: number | undefined;
40
+ /** Reads Shapio's current publication snapshot. */
41
+ current: () => Promise<number>;
42
+ now?: () => number;
43
+ };
44
+
45
+ /** The snapshot every delivery read sends. */
46
+ export const createSnapshotResolver = ({
47
+ dev,
48
+ configured,
49
+ current,
50
+ now,
51
+ }: SnapshotResolverOptions): (() => Promise<number>) =>
52
+ configured === undefined ? cachedRead(current, cacheWindow(dev), now) : () => Promise.resolve(configured);
@@ -91,13 +91,4 @@ export type SiteSettings = EntryBase & {
91
91
  colophon: RichText | null;
92
92
  };
93
93
 
94
- export type DeliveryList<T> = {
95
- data: T[];
96
- meta: {
97
- locale: string;
98
- snapshot: number;
99
- pagination: { page: number; pageSize: number; total: number; pageCount: number };
100
- };
101
- };
102
-
103
94
  export type DeliveryItem<T> = { data: T; meta: { locale: string; snapshot: number } };
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import Base from '../../layouts/Base.astro';
3
+ import { isDev } from '../../lib/config.js';
3
4
  import { renderPage } from '../../lib/render.js';
5
+ import { findBySlug, renderTimeEntry } from '../../lib/renderTime.js';
4
6
  import { listPages } from '../../lib/shapio.js';
5
7
  import { HOME_SLUG, LOCALES, otherLocale, pagePath, type Locale } from '../../lib/site.js';
6
8
  import type { Page } from '../../lib/types.js';
@@ -20,7 +22,16 @@ export const getStaticPaths = async () => {
20
22
  };
21
23
 
22
24
  type Props = { page: Page; locale: Locale };
23
- const { page, locale } = Astro.props;
25
+ const { locale } = Astro.props;
26
+ // In `astro dev`, read at render time (src/lib/renderTime.ts); a build renders the props above.
27
+ const page = await renderTimeEntry({
28
+ dev: isDev(),
29
+ props: Astro.props.page,
30
+ read: async () => findBySlug(await listPages(locale), Astro.params.slug ?? HOME_SLUG),
31
+ });
32
+ if (!page) {
33
+ return Astro.rewrite('/404/');
34
+ }
24
35
  ---
25
36
 
26
37
  <Base title={page.title} seo={page.seo} locale={locale} alternatePath={pagePath(otherLocale(locale), page.slug)}>
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import Base from '../../../layouts/Base.astro';
3
+ import { isDev } from '../../../lib/config.js';
3
4
  import { renderArticle } from '../../../lib/render.js';
5
+ import { findBySlug, renderTimeEntry } from '../../../lib/renderTime.js';
4
6
  import { listArticles } from '../../../lib/shapio.js';
5
7
  import { articlePath, LOCALES, otherLocale, stringsFor, type Locale } from '../../../lib/site.js';
6
8
  import type { Article } from '../../../lib/types.js';
@@ -16,7 +18,16 @@ export const getStaticPaths = async () => {
16
18
  };
17
19
 
18
20
  type Props = { locale: Locale; article: Article };
19
- const { locale, article } = Astro.props;
21
+ const { locale } = Astro.props;
22
+ // In `astro dev`, read at render time (src/lib/renderTime.ts); a build renders the props above.
23
+ const article = await renderTimeEntry({
24
+ dev: isDev(),
25
+ props: Astro.props.article,
26
+ read: async () => findBySlug(await listArticles(locale), Astro.params.slug ?? ''),
27
+ });
28
+ if (!article) {
29
+ return Astro.rewrite('/404/');
30
+ }
20
31
  const context = { locale, strings: stringsFor(locale) };
21
32
  ---
22
33
 
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import Base from '../../../layouts/Base.astro';
3
+ import { isDev } from '../../../lib/config.js';
3
4
  import { renderArticleCard } from '../../../lib/render.js';
5
+ import { renderTimeEntry } from '../../../lib/renderTime.js';
4
6
  import { listArticles } from '../../../lib/shapio.js';
5
7
  import { articlesPath, LOCALES, otherLocale, stringsFor, type Locale } from '../../../lib/site.js';
6
8
  import type { Article } from '../../../lib/types.js';
@@ -11,7 +13,10 @@ export const getStaticPaths = async () =>
11
13
  );
12
14
 
13
15
  type Props = { locale: Locale; articles: Article[] };
14
- const { locale, articles } = Astro.props;
16
+ const { locale } = Astro.props;
17
+ // In `astro dev`, read at render time (src/lib/renderTime.ts); a build renders the props above.
18
+ const articles =
19
+ (await renderTimeEntry({ dev: isDev(), props: Astro.props.articles, read: () => listArticles(locale) })) ?? [];
15
20
  const strings = stringsFor(locale);
16
21
  const context = { locale, strings };
17
22
  ---
@@ -1,9 +1,16 @@
1
1
  import { pinnedSnapshot } from '../lib/shapio.js';
2
2
  import { LOCALES } from '../lib/site.js';
3
3
 
4
- /** `/build.json`: which publication snapshot this build shows (for checks and deployment debugging). */
4
+ /**
5
+ * `/build.json`: which publication snapshot this build shows (for checks and deployment debugging); null in
6
+ * drafts mode, which shows saved drafts.
7
+ */
5
8
  export const GET = async () =>
6
9
  new Response(
7
- JSON.stringify({ snapshot: await pinnedSnapshot(), locales: LOCALES, builtAt: new Date().toISOString() }),
10
+ JSON.stringify({
11
+ snapshot: (await pinnedSnapshot()) ?? null,
12
+ locales: LOCALES,
13
+ builtAt: new Date().toISOString(),
14
+ }),
8
15
  { headers: { 'content-type': 'application/json' } },
9
16
  );
@@ -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
+ }
@@ -19,3 +19,9 @@ NEXT_TELEMETRY_DISABLED=1
19
19
  # Shapio's own database. A read-only database role is enough. Previews still use SHAPIO_URL.
20
20
  # SHAPIO_MODE=in-process
21
21
  # DATABASE_URL=postgres://shapio_site:password@localhost:5432/shapio
22
+ # Optional, development only: drafts mode. Every read shows saved drafts instead of published content, fresh
23
+ # on every request, with a "Drafts" badge on each page. Needs a delivery token whose role grants Read drafts:
24
+ # SHAPIO_DEV_DELIVERY_TOKEN (`npm run seed` writes one), else SHAPIO_DELIVERY_TOKEN. Never put that token or
25
+ # this flag in a production environment.
26
+ # SHAPIO_DRAFTS=true
27
+ # SHAPIO_DEV_DELIVERY_TOKEN=
@@ -34,6 +34,15 @@ instance. Next.js telemetry is off: `NEXT_TELEMETRY_DISABLED=1` in the npm scrip
34
34
  | `src/lib/revalidation*.ts` | the snapshot diff, and which pages and cache tags a change touches |
35
35
  | `shapio/`, `scripts/seed.ts` | the models and the seed |
36
36
 
37
+ Drafts mode, for your development server: set `SHAPIO_DRAFTS=true` in `.env` and `npm run dev` shows saved
38
+ drafts instead of published content, fresh on every reload, with a **Drafts** badge on every page. Save in
39
+ Shapio, reload, and the change is there; publishing still rebuilds the live site. It reads with
40
+ `SHAPIO_DEV_DELIVERY_TOKEN`, the seed's `<site> dev` token, whose delivery role grants Read drafts (a token
41
+ without it fails with `DRAFTS_FORBIDDEN`). Never put that token or the flag in a production environment.
42
+ `npm run smoke:drafts` checks a running drafts-mode dev server
43
+ (http://localhost:3000; needs `SHAPIO_ADMIN_EMAIL`/`PASSWORD` to save a test change, which it puts back). Guide:
44
+ [drafts mode](https://github.com/mybrokengnome/shapio/blob/main/documentation/starters.md#drafts-mode).
45
+
37
46
  Preview: the seed creates a deployment connection named **Preview** that opens drafts at
38
47
  `http://localhost:3000/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
39
48
  `CORS_ORIGINS`. In Shapio's preview pane, clicking the title, body or cover focuses that field and saves
@@ -7,12 +7,16 @@ import { createShapioClient } from './src/lib/shapioClient';
7
7
  * the snapshot is read here, once, before any page renders, and handed to every worker as SHAPIO_SNAPSHOT
8
8
  * (inlined into the build). A set SHAPIO_SNAPSHOT wins and pins the site for good; otherwise `next start` moves
9
9
  * forward from this snapshot as Shapio's webhook calls /api/revalidate (src/lib/liveSnapshot.ts). `next dev` and
10
- * `next start` do not read Shapio here.
10
+ * `next start` do not read Shapio here. Drafts mode (SHAPIO_DRAFTS=true) pins nothing: drafts have no
11
+ * snapshot.
11
12
  */
12
13
  const pinSnapshot = async (): Promise<string> => {
13
14
  if (process.env.SHAPIO_SNAPSHOT) {
14
15
  return process.env.SHAPIO_SNAPSHOT;
15
16
  }
17
+ if (process.env.SHAPIO_DRAFTS === 'true') {
18
+ return '';
19
+ }
16
20
  // Over HTTP or in process, as the pages read (SHAPIO_MODE).
17
21
  const client = createShapioClient();
18
22
  try {
@@ -13,13 +13,14 @@
13
13
  "start": "NEXT_TELEMETRY_DISABLED=1 next start",
14
14
  "seed": "node --env-file-if-exists=.env --import tsx scripts/seed.ts --site-url http://localhost:3000 --revalidate-path /api/revalidate/",
15
15
  "smoke": "node --env-file-if-exists=.env --import tsx scripts/smokeHttp.ts --url http://localhost:3000",
16
+ "smoke:drafts": "node --env-file-if-exists=.env --import tsx scripts/smokeDrafts.ts --url http://localhost:3000",
16
17
  "typecheck": "tsc --noEmit -p tsconfig.json",
17
18
  "test": "vitest run"
18
19
  },
19
20
  "dependencies": {
20
- "@shapio/client": "^0.5.0",
21
- "@shapio/local": "0.5.0",
22
- "@shapio/visual": "^0.5.0",
21
+ "@shapio/client": "^0.5.1",
22
+ "@shapio/local": "0.5.1",
23
+ "@shapio/visual": "^0.5.1",
23
24
  "next": "16.3.8",
24
25
  "react": "19.3.0",
25
26
  "react-dom": "19.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
+ });
@@ -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
  );
@@ -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
+ );