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.
- 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/scripts/smokeHttp.ts +29 -1
- 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 +10 -0
- package/templates/next/README.md +37 -11
- package/templates/next/next.config.ts +17 -12
- package/templates/next/package.json +4 -2
- package/templates/next/scripts/seed.ts +77 -24
- package/templates/next/scripts/smokeDrafts.ts +109 -0
- package/templates/next/scripts/smokeHttp.ts +29 -1
- package/templates/next/src/app/[locale]/layout.tsx +3 -0
- package/templates/next/src/app/api/revalidate/route.ts +3 -1
- 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 +35 -1
- package/templates/next/src/lib/revalidation.ts +30 -12
- package/templates/next/src/lib/revalidationTags.test.ts +43 -0
- package/templates/next/src/lib/revalidationTags.ts +30 -0
- package/templates/next/src/lib/shapio.ts +42 -13
- package/templates/next/src/lib/shapioClient.ts +19 -0
- 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/scripts/smokeHttp.ts +29 -1
- 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
|
@@ -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())))();
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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({
|
|
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=
|
package/templates/next/README.md
CHANGED
|
@@ -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
|
|
29
|
-
| `src/
|
|
30
|
-
| `src/
|
|
31
|
-
| `src/
|
|
32
|
-
| `src/app/
|
|
33
|
-
| `src/
|
|
34
|
-
| `
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
21
|
-
"@shapio/
|
|
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"
|