create-shapio 0.5.0 → 0.5.2
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 +19 -1
- package/templates/astro/astro.config.mjs +7 -0
- package/templates/astro/package.json +8 -4
- package/templates/astro/scripts/incrementalBuild.ts +15 -3
- package/templates/astro/scripts/incrementalCheck.ts +218 -0
- package/templates/astro/scripts/seed.ts +77 -24
- package/templates/astro/scripts/smokeDrafts.ts +109 -0
- package/templates/astro/src/layouts/Base.astro +11 -3
- package/templates/astro/src/lib/cacheKey.test.ts +63 -0
- package/templates/astro/src/lib/cacheKey.ts +57 -0
- package/templates/astro/src/lib/config.ts +33 -3
- package/templates/astro/src/lib/layoutInputs.ts +13 -0
- 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 +23 -3
- package/templates/astro/src/pages/[locale]/articles/[slug].astro +22 -2
- package/templates/astro/src/pages/[locale]/articles/index.astro +7 -1
- package/templates/astro/src/pages/[locale]/colophon.astro +1 -1
- package/templates/astro/src/pages/build.json.ts +9 -2
- package/templates/astro/src/styles/site.css +14 -0
- package/templates/next/.env.example +6 -0
- package/templates/next/README.md +9 -0
- package/templates/next/next.config.ts +5 -1
- package/templates/next/package.json +4 -3
- package/templates/next/scripts/seed.ts +77 -24
- package/templates/next/scripts/smokeDrafts.ts +109 -0
- package/templates/next/src/app/[locale]/layout.tsx +3 -0
- package/templates/next/src/app/site.css +14 -0
- package/templates/next/src/components/DraftsBadge/index.tsx +10 -0
- package/templates/next/src/lib/config.ts +13 -1
- package/templates/next/src/lib/revalidation.ts +4 -1
- package/templates/next/src/lib/shapio.ts +24 -4
- package/templates/next/src/lib/shapioClient.ts +6 -4
- package/templates/next/src/lib/site.ts +4 -0
- package/templates/next/src/lib/snapshotResolver.test.ts +64 -0
- package/templates/next/src/lib/snapshotResolver.ts +52 -0
- package/templates/sveltekit/.env.example +6 -0
- package/templates/sveltekit/README.md +9 -0
- package/templates/sveltekit/package.json +7 -4
- package/templates/sveltekit/scripts/seed.ts +77 -24
- package/templates/sveltekit/scripts/smokeDrafts.ts +109 -0
- package/templates/sveltekit/src/env.ts +2 -0
- package/templates/sveltekit/src/lib/server/config.ts +11 -1
- package/templates/sveltekit/src/lib/server/shapio.ts +28 -13
- package/templates/sveltekit/src/lib/server/snapshotResolver.test.ts +64 -0
- package/templates/sveltekit/src/lib/server/snapshotResolver.ts +52 -0
- package/templates/sveltekit/src/lib/site.css +14 -0
- package/templates/sveltekit/src/lib/site.ts +4 -0
- package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +3 -2
- package/templates/sveltekit/src/routes/[locale]/+layout.svelte +3 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { buildEnvKey, cacheKeyOf } from './cacheKey.js';
|
|
3
|
+
|
|
4
|
+
describe('cacheKeyOf', () => {
|
|
5
|
+
it('is a 32-character hex digest', () => {
|
|
6
|
+
expect(cacheKeyOf({ title: 'Hello' })).toMatch(/^[0-9a-f]{32}$/);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('does not depend on the order of object keys, at any depth', () => {
|
|
10
|
+
expect(cacheKeyOf({ a: 1, b: { c: 2, d: [{ e: 3, f: 4 }] } })).toBe(
|
|
11
|
+
cacheKeyOf({ b: { d: [{ f: 4, e: 3 }], c: 2 }, a: 1 }),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('changes when any nested value changes', () => {
|
|
16
|
+
const page = { title: 'Home', sections: [{ heading: 'Hi', image: { url: '/a.webp' } }] };
|
|
17
|
+
const edited = { title: 'Home', sections: [{ heading: 'Hi', image: { url: '/b.webp' } }] };
|
|
18
|
+
expect(cacheKeyOf(page)).not.toBe(cacheKeyOf(edited));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('is sensitive to array order', () => {
|
|
22
|
+
expect(cacheKeyOf([1, 2])).not.toBe(cacheKeyOf([2, 1]));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('tells its inputs apart (a value moved from one argument to the next is a different key)', () => {
|
|
26
|
+
expect(cacheKeyOf({ a: 1 }, { b: 2 })).not.toBe(cacheKeyOf({ a: 1, b: 2 }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reads undefined as null and a date as its ISO text', () => {
|
|
30
|
+
expect(cacheKeyOf({ a: undefined })).toBe(cacheKeyOf({ a: null }));
|
|
31
|
+
expect(cacheKeyOf({ at: new Date('2026-10-07T00:00:00.000Z') })).toBe(
|
|
32
|
+
cacheKeyOf({ at: '2026-10-07T00:00:00.000Z' }),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws on what is not plain data, naming where it is', () => {
|
|
37
|
+
expect(() => cacheKeyOf({ render: () => '' })).toThrow('inputs[0].render is a function');
|
|
38
|
+
expect(() => cacheKeyOf({ id: Symbol('x') })).toThrow('is a symbol');
|
|
39
|
+
expect(() => cacheKeyOf({ size: 1n })).toThrow('is a bigint');
|
|
40
|
+
expect(() => cacheKeyOf({ tags: new Set(['a']) })).toThrow('is a Set');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws on a value that refers back to itself, but accepts the same object twice', () => {
|
|
44
|
+
const shared = { name: 'Ada' };
|
|
45
|
+
expect(() => cacheKeyOf({ author: shared, editor: shared })).not.toThrow();
|
|
46
|
+
const loop: Record<string, unknown> = {};
|
|
47
|
+
loop.self = loop;
|
|
48
|
+
expect(() => cacheKeyOf(loop)).toThrow('refers back to itself');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('buildEnvKey', () => {
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.unstubAllEnvs();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('carries drafts mode and the year, which a page shows without reading them from its data', () => {
|
|
58
|
+
vi.stubEnv('SHAPIO_DRAFTS', 'true');
|
|
59
|
+
expect(buildEnvKey()).toEqual({ drafts: true, year: new Date().getFullYear() });
|
|
60
|
+
vi.stubEnv('SHAPIO_DRAFTS', '');
|
|
61
|
+
expect(buildEnvKey().drafts).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isDraftsMode } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keys for Astro's incremental build (`experimental.incrementalBuild` in astro.config.mjs). A page returned by
|
|
6
|
+
* `getStaticPaths()` with a `cacheKey` is restored from the previous build, not rendered, when its key and its
|
|
7
|
+
* code (Astro hashes the page's import graph) are both unchanged. So the key must cover every piece of data the
|
|
8
|
+
* page renders: its props, what the layout reads (`layoutInputs`), and the build environment (`buildEnvKey`).
|
|
9
|
+
* Whole objects go in, never a hand-picked list of fields, which would drift from what the page renders.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Plain data in a stable form: object keys sorted, arrays in order, `undefined` as null, dates as ISO text. */
|
|
13
|
+
const normalize = (value: unknown, path: string, seen: Set<object>): unknown => {
|
|
14
|
+
if (value === undefined || value === null) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
|
|
21
|
+
throw new TypeError(`cacheKeyOf: ${path} is a ${typeof value}, not plain data`);
|
|
22
|
+
}
|
|
23
|
+
if (value instanceof Date) {
|
|
24
|
+
return value.toISOString();
|
|
25
|
+
}
|
|
26
|
+
const object = value;
|
|
27
|
+
if (seen.has(object)) {
|
|
28
|
+
throw new TypeError(`cacheKeyOf: ${path} refers back to itself`);
|
|
29
|
+
}
|
|
30
|
+
seen.add(object);
|
|
31
|
+
try {
|
|
32
|
+
if (Array.isArray(object)) {
|
|
33
|
+
return object.map((item, index) => normalize(item, `${path}[${index}]`, seen));
|
|
34
|
+
}
|
|
35
|
+
const prototype = Object.getPrototypeOf(object) as unknown;
|
|
36
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
37
|
+
throw new TypeError(`cacheKeyOf: ${path} is a ${object.constructor.name}, not plain data`);
|
|
38
|
+
}
|
|
39
|
+
const entries = Object.entries(object).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
40
|
+
return Object.fromEntries(entries.map(([key, item]) => [key, normalize(item, `${path}.${key}`, seen)]));
|
|
41
|
+
} finally {
|
|
42
|
+
seen.delete(object);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** A stable digest of the data a page renders: same inputs, same key, whatever the order of object keys. */
|
|
47
|
+
export const cacheKeyOf = (...inputs: unknown[]): string =>
|
|
48
|
+
createHash('sha256')
|
|
49
|
+
.update(JSON.stringify(normalize(inputs, 'inputs', new Set())))
|
|
50
|
+
.digest('hex')
|
|
51
|
+
.slice(0, 32);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a page shows that comes from neither its data nor its code: settings read from the environment and the
|
|
55
|
+
* clock when it renders, which Astro's code and config hashes do not see.
|
|
56
|
+
*/
|
|
57
|
+
export const buildEnvKey = () => ({ drafts: isDraftsMode(), year: new Date().getFullYear() });
|
|
@@ -6,17 +6,29 @@
|
|
|
6
6
|
* without it the build pins the latest snapshot when it starts.
|
|
7
7
|
* - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
|
|
8
8
|
* - PUBLIC_SHAPIO_URL: the origin the browser calls for previews (defaults to SHAPIO_URL).
|
|
9
|
+
* - SHAPIO_DRAFTS: `true` turns on drafts mode, for your development server: every read shows saved drafts
|
|
10
|
+
* instead of published content (no snapshot), and pages carry a "Drafts" badge. The token must be a delivery
|
|
11
|
+
* token whose role grants Read drafts: SHAPIO_DEV_DELIVERY_TOKEN when set (`npm run seed` writes one), else
|
|
12
|
+
* SHAPIO_DELIVERY_TOKEN. Never set it in a production environment.
|
|
13
|
+
*
|
|
14
|
+
* Settings come from `process.env` only (astro.config.mjs loads `.env` into it). Reading Vite's env object as a
|
|
15
|
+
* whole instead would make Vite write every variable named in this file into the compiled code, so a changed
|
|
16
|
+
* SHAPIO_SNAPSHOT would change the code Astro hashes and re-render every page (see src/lib/cacheKey.ts).
|
|
9
17
|
*/
|
|
10
18
|
const read = (name: string): string | undefined => {
|
|
11
|
-
const
|
|
12
|
-
const value = fromVite ?? process.env[name];
|
|
19
|
+
const value = process.env[name];
|
|
13
20
|
return value === undefined || value === '' ? undefined : value;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
export const shapioUrl = () => read('SHAPIO_URL') ?? 'http://localhost:4300';
|
|
17
24
|
|
|
25
|
+
/** Drafts mode (SHAPIO_DRAFTS=true): the site reads saved drafts. Never set in production. */
|
|
26
|
+
export const isDraftsMode = () => read('SHAPIO_DRAFTS') === 'true';
|
|
27
|
+
|
|
18
28
|
export const deliveryToken = () => {
|
|
19
|
-
|
|
29
|
+
// Drafts mode reads with the development token (Read drafts) when there is one.
|
|
30
|
+
const token =
|
|
31
|
+
(isDraftsMode() ? read('SHAPIO_DEV_DELIVERY_TOKEN') : undefined) ?? read('SHAPIO_DELIVERY_TOKEN');
|
|
20
32
|
if (!token) {
|
|
21
33
|
throw new Error(
|
|
22
34
|
'Set SHAPIO_DELIVERY_TOKEN (a Shapio delivery token) to build the site; `npm run seed` writes one to .env',
|
|
@@ -40,3 +52,21 @@ export const configuredSnapshot = (): number | undefined => {
|
|
|
40
52
|
export const publicShapioUrl = () => read('PUBLIC_SHAPIO_URL') ?? shapioUrl();
|
|
41
53
|
|
|
42
54
|
export const siteKey = () => read('SHAPIO_SITE');
|
|
55
|
+
|
|
56
|
+
type ViteImportMeta = ImportMeta & { env: { DEV?: boolean } };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True under `astro dev`; false in builds and in the scripts. Written out in full (`import.meta.env.DEV`) so Vite
|
|
60
|
+
* replaces it with a constant; outside Vite (the scripts, run by Node) there is no env object, which is the
|
|
61
|
+
* TypeError caught here, and that is not dev.
|
|
62
|
+
*/
|
|
63
|
+
export const isDev = (): boolean => {
|
|
64
|
+
try {
|
|
65
|
+
return (import.meta as ViteImportMeta).env.DEV === true;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof TypeError) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { buildEnvKey } from './cacheKey.js';
|
|
2
|
+
import { getSite, getSiteSettings } from './shapio.js';
|
|
3
|
+
import type { Locale } from './site.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Everything layouts/Base.astro reads when it renders, besides its props: the site settings and the site's SEO
|
|
7
|
+
* defaults (both read once per build) and the build environment. The layout reads through this function and
|
|
8
|
+
* every keyed page folds it into its `cacheKey`, so a read added to the layout is in the key too.
|
|
9
|
+
*/
|
|
10
|
+
export const layoutInputs = async (locale: Locale) => {
|
|
11
|
+
const [settings, site] = await Promise.all([getSiteSettings(locale), getSite()]);
|
|
12
|
+
return { settings, site, env: buildEnvKey() };
|
|
13
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { findBySlug, renderTimeEntry } from './renderTime.js';
|
|
3
|
+
|
|
4
|
+
describe('renderTimeEntry', () => {
|
|
5
|
+
it('renders the getStaticPaths props in a build, without reading', async () => {
|
|
6
|
+
const read = vi.fn();
|
|
7
|
+
expect(await renderTimeEntry({ dev: false, props: { slug: 'about' }, read })).toEqual({ slug: 'about' });
|
|
8
|
+
expect(read).not.toHaveBeenCalled();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('reads the entry when it renders in dev, over stale props', async () => {
|
|
12
|
+
const read = vi.fn().mockResolvedValue({ slug: 'about', title: 'New' });
|
|
13
|
+
expect(await renderTimeEntry({ dev: true, props: { slug: 'about', title: 'Old' }, read })).toEqual({
|
|
14
|
+
slug: 'about',
|
|
15
|
+
title: 'New',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('is undefined in dev when the entry is gone, so the page answers 404', async () => {
|
|
20
|
+
const read = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
expect(await renderTimeEntry({ dev: true, props: { slug: 'about' }, read })).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('findBySlug', () => {
|
|
26
|
+
it('finds the entry with the slug, or nothing', () => {
|
|
27
|
+
const entries = [{ slug: 'home' }, { slug: 'about' }];
|
|
28
|
+
expect(findBySlug(entries, 'about')).toBe(entries[1]);
|
|
29
|
+
expect(findBySlug(entries, 'gone')).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a dynamic page renders. A build renders what getStaticPaths handed over as props (every page at the
|
|
3
|
+
* build's one snapshot). `astro dev` keeps getStaticPaths results until it restarts, so there the page reads
|
|
4
|
+
* its entry from Shapio when it renders, from `Astro.params`: a publish shows on reload, and an entry that is
|
|
5
|
+
* gone (unpublished or deleted) is undefined, which the page answers with a 404.
|
|
6
|
+
*/
|
|
7
|
+
type RenderTimeSource<T> = {
|
|
8
|
+
/** True under `astro dev`. */
|
|
9
|
+
dev: boolean;
|
|
10
|
+
/** What getStaticPaths handed over. */
|
|
11
|
+
props: T | undefined;
|
|
12
|
+
/** Reads the entry now; undefined when it is not published. */
|
|
13
|
+
read: () => Promise<T | undefined>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const renderTimeEntry = async <T>({
|
|
17
|
+
dev,
|
|
18
|
+
props,
|
|
19
|
+
read,
|
|
20
|
+
}: RenderTimeSource<T>): Promise<T | undefined> => (dev ? read() : props);
|
|
21
|
+
|
|
22
|
+
/** The entry with this slug, if any. */
|
|
23
|
+
export const findBySlug = <T extends { slug: string }>(entries: readonly T[], slug: string): T | undefined =>
|
|
24
|
+
entries.find((entry) => entry.slug === slug);
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { createClient, ShapioApiError, type DeliverySite } from '@shapio/client';
|
|
2
|
-
import { configuredSnapshot, deliveryToken, shapioUrl, siteKey } from './config.js';
|
|
1
|
+
import { createClient, ShapioApiError, type DeliveryListQuery, type DeliverySite } from '@shapio/client';
|
|
2
|
+
import { configuredSnapshot, deliveryToken, isDev, isDraftsMode, shapioUrl, siteKey } from './config.js';
|
|
3
3
|
import type { Locale } from './site.js';
|
|
4
|
-
import
|
|
4
|
+
import { cachedRead, cacheWindow, createSnapshotResolver } from './snapshotResolver.js';
|
|
5
|
+
import type { Article, Page, SiteSettings } from './types.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The site's read side: Shapio's delivery API through `@shapio/client`, pinned to one publication snapshot
|
|
8
9
|
* for the whole build. The snapshot is read once, when the build starts (or taken from SHAPIO_SNAPSHOT), and
|
|
9
10
|
* sent with every request, so a page built a minute later still shows the same moment: a publish during the
|
|
10
|
-
* build never yields a site that mixes old and new content.
|
|
11
|
+
* build never yields a site that mixes old and new content. Under `astro dev` the snapshot and the site settings
|
|
12
|
+
* are read again once the last read is a second old, so a publish shows on reload (src/lib/snapshotResolver.ts).
|
|
13
|
+
* Drafts mode (SHAPIO_DRAFTS=true) reads saved drafts, which have no snapshot: no read sends one.
|
|
11
14
|
*/
|
|
12
15
|
const PAGE_SIZE = 100;
|
|
13
16
|
|
|
@@ -19,8 +22,8 @@ const ARTICLES = 'articles';
|
|
|
19
22
|
* The fields each listing renders, sent as `fields=`: the response carries only these, and Shapio's field
|
|
20
23
|
* usage shows exactly what the site reads (a whole-model read would count every field as used).
|
|
21
24
|
*/
|
|
22
|
-
const PAGE_FIELDS = 'title,slug,description,sections,seo';
|
|
23
|
-
const ARTICLE_FIELDS = 'title,slug,excerpt,body,cover,author,publishedOn,seo';
|
|
25
|
+
const PAGE_FIELDS = ['title', 'slug', 'description', 'sections', 'seo'];
|
|
26
|
+
const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn', 'seo'];
|
|
24
27
|
|
|
25
28
|
/** The `siteSettings` singleton is read by its API ID (singletons have no plural). */
|
|
26
29
|
const SITE_SETTINGS = 'siteSettings';
|
|
@@ -28,40 +31,54 @@ const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
|
|
|
28
31
|
|
|
29
32
|
let client: ReturnType<typeof createClient> | undefined;
|
|
30
33
|
const shapio = () =>
|
|
31
|
-
(client ??= createClient({
|
|
34
|
+
(client ??= createClient({
|
|
35
|
+
baseUrl: shapioUrl(),
|
|
36
|
+
token: deliveryToken(),
|
|
37
|
+
site: siteKey(),
|
|
38
|
+
drafts: isDraftsMode(),
|
|
39
|
+
}));
|
|
32
40
|
|
|
33
|
-
let
|
|
41
|
+
let resolver: (() => Promise<number>) | undefined;
|
|
34
42
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The snapshot every request reads: one for the whole build; in dev, the current one (re-read after 1 s); in
|
|
45
|
+
* drafts mode, none (drafts cannot be pinned).
|
|
46
|
+
*/
|
|
47
|
+
export const pinnedSnapshot = async (): Promise<number | undefined> => {
|
|
48
|
+
if (isDraftsMode()) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const dev = isDev();
|
|
52
|
+
resolver ??= createSnapshotResolver({
|
|
53
|
+
dev,
|
|
54
|
+
configured: configuredSnapshot(),
|
|
55
|
+
current: async () => {
|
|
56
|
+
const { snapshot } = await shapio().snapshots.current();
|
|
57
|
+
if (!dev) {
|
|
58
|
+
// Shared with any other copy of this module in the build (one value per build).
|
|
59
|
+
process.env.SHAPIO_SNAPSHOT = String(snapshot);
|
|
60
|
+
}
|
|
61
|
+
return snapshot;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return resolver();
|
|
65
|
+
};
|
|
50
66
|
|
|
51
67
|
/** Every published entry of a collection (by plural API ID) in one locale, page by page, at the pinned snapshot. */
|
|
52
|
-
const listAll = async <T>(
|
|
53
|
-
routeKey: string,
|
|
54
|
-
locale: Locale,
|
|
55
|
-
extra: Record<string, string> = {},
|
|
56
|
-
): Promise<T[]> => {
|
|
68
|
+
const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
|
|
57
69
|
const snapshot = await pinnedSnapshot();
|
|
58
70
|
const entries: T[] = [];
|
|
59
71
|
for (let page = 1; ; page += 1) {
|
|
60
|
-
const result = await shapio().
|
|
72
|
+
const result = await shapio().delivery.list<T>(routeKey, {
|
|
73
|
+
...query,
|
|
61
74
|
// The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
|
|
62
75
|
// its SEO fields with the site's defaults filled in (`seo=resolved`).
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
richText: 'html',
|
|
77
|
+
seo: 'resolved',
|
|
78
|
+
snapshot,
|
|
79
|
+
page,
|
|
80
|
+
pageSize: PAGE_SIZE,
|
|
81
|
+
});
|
|
65
82
|
entries.push(...result.data);
|
|
66
83
|
if (page >= result.meta.pagination.pageCount) {
|
|
67
84
|
return entries;
|
|
@@ -69,55 +86,62 @@ const listAll = async <T>(
|
|
|
69
86
|
}
|
|
70
87
|
};
|
|
71
88
|
|
|
72
|
-
export const listPages = (locale: Locale) => listAll<Page>(PAGES, locale,
|
|
89
|
+
export const listPages = (locale: Locale) => listAll<Page>(PAGES, { locale, fields: PAGE_FIELDS });
|
|
73
90
|
|
|
74
91
|
export const listArticles = (locale: Locale) =>
|
|
75
|
-
listAll<Article>(ARTICLES,
|
|
92
|
+
listAll<Article>(ARTICLES, {
|
|
93
|
+
locale,
|
|
76
94
|
fields: ARTICLE_FIELDS,
|
|
77
|
-
populate: 'author',
|
|
78
|
-
sort: 'publishedOn:desc',
|
|
95
|
+
populate: ['author'],
|
|
96
|
+
sort: [{ field: 'publishedOn', direction: 'desc' }],
|
|
79
97
|
});
|
|
80
98
|
|
|
81
|
-
const
|
|
99
|
+
const readSiteSettings = async (locale: Locale): Promise<SiteSettings | null> => {
|
|
100
|
+
try {
|
|
101
|
+
const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
|
|
102
|
+
locale,
|
|
103
|
+
snapshot: await pinnedSnapshot(),
|
|
104
|
+
fields: SITE_SETTINGS_FIELDS,
|
|
105
|
+
richText: 'html',
|
|
106
|
+
});
|
|
107
|
+
return data;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof ShapioApiError && error.status === 404) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const settingsByLocale = new Map<Locale, () => Promise<SiteSettings | null>>();
|
|
82
117
|
|
|
83
118
|
/** The site settings singleton in one locale at the pinned snapshot; null until it is published. */
|
|
84
119
|
export const getSiteSettings = (locale: Locale): Promise<SiteSettings | null> => {
|
|
85
120
|
let settings = settingsByLocale.get(locale);
|
|
86
121
|
if (!settings) {
|
|
87
|
-
settings = (
|
|
88
|
-
try {
|
|
89
|
-
const snapshot = await pinnedSnapshot();
|
|
90
|
-
const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
|
|
91
|
-
locale,
|
|
92
|
-
snapshot,
|
|
93
|
-
fields: SITE_SETTINGS_FIELDS,
|
|
94
|
-
richText: 'html',
|
|
95
|
-
});
|
|
96
|
-
return data;
|
|
97
|
-
} catch (error) {
|
|
98
|
-
if (error instanceof ShapioApiError && error.status === 404) {
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
})();
|
|
122
|
+
settings = cachedRead(() => readSiteSettings(locale), cacheWindow(isDev()));
|
|
104
123
|
settingsByLocale.set(locale, settings);
|
|
105
124
|
}
|
|
106
|
-
return settings;
|
|
125
|
+
return settings();
|
|
107
126
|
};
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
const readSite = async (): Promise<DeliverySite | null> => {
|
|
129
|
+
try {
|
|
130
|
+
return await shapio().site.get();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof ShapioApiError && error.status === 404) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let site: (() => Promise<DeliverySite | null>) | undefined;
|
|
110
140
|
|
|
111
141
|
/**
|
|
112
142
|
* The site and its SEO defaults (name and title template per locale, default image, Twitter handle), once per
|
|
113
|
-
* build. Null when the Shapio predates them: the pages still render, with plain
|
|
143
|
+
* build (in dev, re-read after 1 s). Null when the Shapio predates them: the pages still render, with plain
|
|
144
|
+
* titles.
|
|
114
145
|
*/
|
|
115
146
|
export const getSite = (): Promise<DeliverySite | null> =>
|
|
116
|
-
(site ??=
|
|
117
|
-
.site.get()
|
|
118
|
-
.catch((error: unknown) => {
|
|
119
|
-
if (error instanceof ShapioApiError && error.status === 404) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
throw error;
|
|
123
|
-
}));
|
|
147
|
+
(site ??= cachedRead(readSite, cacheWindow(isDev())))();
|
|
@@ -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,18 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Base from '../../layouts/Base.astro';
|
|
3
|
+
import { cacheKeyOf } from '../../lib/cacheKey.js';
|
|
4
|
+
import { isDev } from '../../lib/config.js';
|
|
5
|
+
import { layoutInputs } from '../../lib/layoutInputs.js';
|
|
3
6
|
import { renderPage } from '../../lib/render.js';
|
|
7
|
+
import { findBySlug, renderTimeEntry } from '../../lib/renderTime.js';
|
|
4
8
|
import { listPages } from '../../lib/shapio.js';
|
|
5
9
|
import { HOME_SLUG, LOCALES, otherLocale, pagePath, type Locale } from '../../lib/site.js';
|
|
6
10
|
import type { Page } from '../../lib/types.js';
|
|
7
11
|
|
|
8
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Every published page in every locale; `home` is the locale's front page (`/en/`, `/fr/`). The page renders
|
|
14
|
+
* only its props and what the layout reads, so those are its cacheKey: a build restores it from the previous
|
|
15
|
+
* build while they are unchanged (src/lib/cacheKey.ts).
|
|
16
|
+
*/
|
|
9
17
|
export const getStaticPaths = async () => {
|
|
10
18
|
const paths = [];
|
|
11
19
|
for (const locale of LOCALES) {
|
|
20
|
+
const layout = await layoutInputs(locale);
|
|
12
21
|
for (const page of await listPages(locale)) {
|
|
22
|
+
const props = { page, locale };
|
|
13
23
|
paths.push({
|
|
14
24
|
params: { locale, slug: page.slug === HOME_SLUG ? undefined : page.slug },
|
|
15
|
-
props
|
|
25
|
+
props,
|
|
26
|
+
cacheKey: cacheKeyOf(props, layout),
|
|
16
27
|
});
|
|
17
28
|
}
|
|
18
29
|
}
|
|
@@ -20,7 +31,16 @@ export const getStaticPaths = async () => {
|
|
|
20
31
|
};
|
|
21
32
|
|
|
22
33
|
type Props = { page: Page; locale: Locale };
|
|
23
|
-
const {
|
|
34
|
+
const { locale } = Astro.props;
|
|
35
|
+
// In `astro dev`, read at render time (src/lib/renderTime.ts); a build renders the props above.
|
|
36
|
+
const page = await renderTimeEntry({
|
|
37
|
+
dev: isDev(),
|
|
38
|
+
props: Astro.props.page,
|
|
39
|
+
read: async () => findBySlug(await listPages(locale), Astro.params.slug ?? HOME_SLUG),
|
|
40
|
+
});
|
|
41
|
+
if (!page) {
|
|
42
|
+
return Astro.rewrite('/404/');
|
|
43
|
+
}
|
|
24
44
|
---
|
|
25
45
|
|
|
26
46
|
<Base title={page.title} seo={page.seo} locale={locale} alternatePath={pagePath(otherLocale(locale), page.slug)}>
|