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
@@ -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 type { Article, DeliveryList, Page, SiteSettings } from './types.js';
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({ baseUrl: shapioUrl(), token: deliveryToken(), site: siteKey() }));
34
+ (client ??= createClient({
35
+ baseUrl: shapioUrl(),
36
+ token: deliveryToken(),
37
+ site: siteKey(),
38
+ drafts: isDraftsMode(),
39
+ }));
32
40
 
33
- let pinned: Promise<number> | undefined;
41
+ let resolver: (() => Promise<number>) | undefined;
34
42
 
35
- /** The snapshot every request of this build reads. */
36
- export const pinnedSnapshot = (): Promise<number> =>
37
- (pinned ??= (async () => {
38
- const configured = configuredSnapshot();
39
- if (configured !== undefined) {
40
- return configured;
41
- }
42
- const { snapshot } = await shapio().snapshots.current();
43
- // Shared with any other copy of this module in the build (one value per build).
44
- process.env.SHAPIO_SNAPSHOT = String(snapshot);
45
- return snapshot;
46
- })());
47
-
48
- const query = (params: Record<string, string | number>) =>
49
- new URLSearchParams(Object.entries(params).map(([key, value]) => [key, String(value)])).toString();
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().request<DeliveryList<T>>(
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
- `/api/content/${routeKey}?${query({ locale, snapshot, page, pageSize: PAGE_SIZE, richText: 'html', seo: 'resolved', ...extra })}`,
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, { fields: PAGE_FIELDS });
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, locale, {
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 settingsByLocale = new Map<Locale, Promise<SiteSettings | null>>();
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 = (async () => {
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
- let site: Promise<DeliverySite | null> | undefined;
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 titles.
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 ??= shapio()
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())))();
@@ -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
+ }
@@ -15,3 +15,13 @@ NEXT_TELEMETRY_DISABLED=1
15
15
  # NEXT_PUBLIC_SHAPIO_URL=https://cms.example.com
16
16
  # Optional: this site's public origin; pages then carry a canonical URL and og:url.
17
17
  # SITE_URL=https://www.example.com
18
+ # Optional: read Shapio's delivery API in this server process instead of over HTTP (@shapio/local), from
19
+ # Shapio's own database. A read-only database role is enough. Previews still use SHAPIO_URL.
20
+ # SHAPIO_MODE=in-process
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=
@@ -22,16 +22,26 @@ npm test # unit tests (path mapping, signature check)
22
22
  `next.config.ts` pins the latest one when the build starts); `SHAPIO_SITE` picks the site on a multi-site
23
23
  instance. Next.js telemetry is off: `NEXT_TELEMETRY_DISABLED=1` in the npm scripts and `.env.example`.
24
24
 
25
- | File | Role |
26
- | ---------------------------- | ------------------------------------------------------------------- |
27
- | `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors` |
28
- | `src/lib/shapio.ts` | reads the delivery API with `@shapio/client` at the pinned snapshot |
29
- | `src/app/[locale]/…` | the layout (site settings), pages, articles and the colophon |
30
- | `src/components/…` | sections, articles and responsive images |
31
- | `src/app/preview/` | draft preview with visual editing (`@shapio/visual`) |
32
- | `src/app/api/revalidate/` | the webhook target: on-demand revalidation of changed pages |
33
- | `src/lib/revalidation*.ts` | the snapshot diff and which pages a change touches |
34
- | `shapio/`, `scripts/seed.ts` | the models and the seed |
25
+ | File | Role |
26
+ | ---------------------------- | ------------------------------------------------------------------ |
27
+ | `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors` |
28
+ | `src/lib/shapio.ts` | reads the delivery API at the pinned snapshot |
29
+ | `src/lib/shapioClient.ts` | over HTTP (`@shapio/client`) or in process (`@shapio/local`) |
30
+ | `src/app/[locale]/…` | the layout (site settings), pages, articles and the colophon |
31
+ | `src/components/…` | sections, articles and responsive images |
32
+ | `src/app/preview/` | draft preview with visual editing (`@shapio/visual`) |
33
+ | `src/app/api/revalidate/` | the webhook target: on-demand revalidation of changed pages |
34
+ | `src/lib/revalidation*.ts` | the snapshot diff, and which pages and cache tags a change touches |
35
+ | `shapio/`, `scripts/seed.ts` | the models and the seed |
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).
35
45
 
36
46
  Preview: the seed creates a deployment connection named **Preview** that opens drafts at
37
47
  `http://localhost:3000/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
@@ -39,6 +49,18 @@ Preview: the seed creates a deployment connection named **Preview** that opens d
39
49
  re-render the draft ([visual editing](https://github.com/mybrokengnome/shapio/blob/main/documentation/visual-editing.md)). `NEXT_PUBLIC_SHAPIO_URL` sets the Shapio URL the browser
40
50
  calls, when it differs from `SHAPIO_URL`.
41
51
 
52
+ ## Reading in process
53
+
54
+ By default the site reads Shapio's delivery API over HTTP. With `SHAPIO_MODE=in-process` and `DATABASE_URL` set
55
+ to Shapio's own database (PostgreSQL or MySQL), it reads the same API as function calls in the Next.js server
56
+ process (`@shapio/local`), with no HTTP hop: the same delivery token, the same answers, the same pages.
57
+ `next.config.ts` lists `@shapio/local` in `serverExternalPackages`, so each server process opens one database
58
+ pool. Previews and the browser still use `SHAPIO_URL`, and the Shapio server keeps running (it applies schema
59
+ changes and sends the revalidation webhook). Install the same `@shapio/local` version as the server's release;
60
+ the scaffolded `package.json` pins it. Reads in process do not go through `fetch`, so the cache tags below do
61
+ not apply to them; `revalidatePath` still refreshes the pages. Guide:
62
+ [in-process delivery](https://github.com/mybrokengnome/shapio/blob/main/documentation/in-process.md).
63
+
42
64
  ## Incremental rebuilds (on-demand revalidation)
43
65
 
44
66
  With `npm run start`, a publish refreshes only the pages it changed, without a rebuild:
@@ -55,7 +77,11 @@ With `npm run start`, a publish refreshes only the pages it changed, without a r
55
77
  changed between the snapshot the site shows and the current one (`/api/snapshots/changes`), and calls
56
78
  `revalidatePath` for those pages: an article's page and its locale's list, a page by its slug (`home` is
57
79
  `/en/`), the list and every article page for an author, every page for the `siteSettings` singleton or a
58
- schema change. A changed slug refreshes the old and the new address.
80
+ schema change. A changed slug refreshes the old and the new address. Reads are tagged by `@shapio/client` with
81
+ the site, the model and the entry, so the route also calls `revalidateTag` for each changed model and entry;
82
+ `site.updated` and a schema change expire the site tag, which every read carries. Content reads are pinned to
83
+ a snapshot, so they sit in Next's data cache (`force-cache`) regardless; the client sets no cache mode, so
84
+ `site.get()` stays fresh on every build (the data cache outlives `next build`).
59
85
  3. The site moves to the new snapshot: revalidated pages re-render at it, and the snapshot is kept in
60
86
  `.next/shapio-revalidate.json` so a restart carries on from it. `next build` empties `.next/`, so a new build
61
87
  starts again from its own snapshot.
@@ -1,29 +1,31 @@
1
- import { createClient } from '@shapio/client';
2
1
  import type { NextConfig } from 'next';
3
2
  import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
3
+ import { createShapioClient } from './src/lib/shapioClient';
4
4
 
5
5
  /**
6
6
  * Pins one publication snapshot for the whole `next build`: Next renders pages in several worker processes, so
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
  }
16
- const token = process.env.SHAPIO_DELIVERY_TOKEN;
17
- if (!token) {
18
- throw new Error('Set SHAPIO_DELIVERY_TOKEN to build the site; `npm run seed` writes one to .env');
17
+ if (process.env.SHAPIO_DRAFTS === 'true') {
18
+ return '';
19
+ }
20
+ // Over HTTP or in process, as the pages read (SHAPIO_MODE).
21
+ const client = createShapioClient();
22
+ try {
23
+ const { snapshot } = await client.snapshots.current();
24
+ return String(snapshot);
25
+ } finally {
26
+ // An in-process client holds database connections; this process has no further use for them.
27
+ await client.close?.();
19
28
  }
20
- const client = createClient({
21
- baseUrl: process.env.SHAPIO_URL || 'http://localhost:4300',
22
- token,
23
- site: process.env.SHAPIO_SITE || undefined,
24
- });
25
- const { snapshot } = await client.snapshots.current();
26
- return String(snapshot);
27
29
  };
28
30
 
29
31
  const originOf = (url: string) => {
@@ -47,6 +49,9 @@ const frameAncestors = () => {
47
49
 
48
50
  const nextConfig = async (phase: string): Promise<NextConfig> => ({
49
51
  trailingSlash: true,
52
+ // SHAPIO_MODE=in-process: loaded from node_modules once per server process (one database pool), not
53
+ // bundled into every route.
54
+ serverExternalPackages: ['@shapio/local'],
50
55
  headers: async () => [
51
56
  { source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: frameAncestors() }] },
52
57
  ],
@@ -13,12 +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.4.1",
21
- "@shapio/visual": "^0.4.1",
21
+ "@shapio/client": "^0.5.1",
22
+ "@shapio/local": "0.5.1",
23
+ "@shapio/visual": "^0.5.1",
22
24
  "next": "16.3.8",
23
25
  "react": "19.3.0",
24
26
  "react-dom": "19.3.0"