create-shapio 0.5.8 → 0.5.9
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/package.json +2 -2
- package/templates/astro/scripts/incrementalBuild.ts +16 -8
- package/templates/astro/src/lib/preview.test.ts +31 -0
- package/templates/astro/src/lib/preview.ts +31 -8
- package/templates/next/package.json +3 -3
- package/templates/next/src/lib/revalidationTags.test.ts +19 -0
- package/templates/next/src/lib/revalidationTags.ts +4 -5
- package/templates/sveltekit/package.json +2 -2
package/package.json
CHANGED
|
@@ -74,21 +74,29 @@ const slugAt = async (change: SnapshotChange, locale: string, snapshot: number):
|
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* The
|
|
78
|
-
*
|
|
79
|
-
*
|
|
77
|
+
* The route key of the change's family root. A type that extends another is listed under a nested route key
|
|
78
|
+
* (`articles/guides`); its entries are in the root's list and use the root's pages, so a Guide that extends
|
|
79
|
+
* Article rebuilds article pages.
|
|
80
80
|
*/
|
|
81
|
-
const
|
|
81
|
+
const familyOf = (change: SnapshotChange): string => change.routeKey.split('/')[0] ?? change.routeKey;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The families whose entries have a route of their own, found by slug (by their root's route key). Any other
|
|
85
|
+
* model (a redirect type a user adds, say) maps to no route here: it may have no `slug` field, and Astro's
|
|
86
|
+
* incremental build still decides page by page what to rebuild.
|
|
87
|
+
*/
|
|
88
|
+
const SLUG_ROUTED_FAMILIES = new Set(['pages', 'articles']);
|
|
82
89
|
|
|
83
90
|
/** Site routes (src/pages) an entry change touches. */
|
|
84
91
|
const routesOf = async (change: SnapshotChange, from: number, to: number): Promise<string[]> => {
|
|
85
92
|
const routes: string[] = [];
|
|
93
|
+
const family = familyOf(change);
|
|
86
94
|
for (const { locale, change: kind } of change.locales) {
|
|
87
|
-
if (
|
|
95
|
+
if (family === 'authors') {
|
|
88
96
|
routes.push(`/${locale}/articles/`);
|
|
89
97
|
continue;
|
|
90
98
|
}
|
|
91
|
-
if (!
|
|
99
|
+
if (!SLUG_ROUTED_FAMILIES.has(family)) {
|
|
92
100
|
continue;
|
|
93
101
|
}
|
|
94
102
|
// An unpublished entry's route is where it was; the others are where they are now.
|
|
@@ -96,9 +104,9 @@ const routesOf = async (change: SnapshotChange, from: number, to: number): Promi
|
|
|
96
104
|
if (slug === null) {
|
|
97
105
|
continue;
|
|
98
106
|
}
|
|
99
|
-
if (
|
|
107
|
+
if (family === 'pages') {
|
|
100
108
|
routes.push(slug === 'home' ? `/${locale}/` : `/${locale}/${slug}/`);
|
|
101
|
-
} else if (
|
|
109
|
+
} else if (family === 'articles') {
|
|
102
110
|
routes.push(`/${locale}/articles/${slug}/`, `/${locale}/articles/`);
|
|
103
111
|
}
|
|
104
112
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parsePreviewUrl } from './preview.js';
|
|
3
|
+
|
|
4
|
+
const url = (model: string) =>
|
|
5
|
+
new URL(`http://localhost:4321/preview/?model=${encodeURIComponent(model)}&id=e1&locale=fr#token=shpv_x`);
|
|
6
|
+
|
|
7
|
+
describe('parsePreviewUrl', () => {
|
|
8
|
+
it('reads an allowlisted route key', () => {
|
|
9
|
+
expect(parsePreviewUrl(url('articles'))).toEqual({
|
|
10
|
+
token: 'shpv_x',
|
|
11
|
+
modelKey: 'articles',
|
|
12
|
+
family: 'articles',
|
|
13
|
+
entryId: 'e1',
|
|
14
|
+
locale: 'fr',
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('accepts a type nested under an allowlisted one and renders it as its family', () => {
|
|
19
|
+
expect(parsePreviewUrl(url('articles/guides'))).toMatchObject({
|
|
20
|
+
modelKey: 'articles/guides',
|
|
21
|
+
family: 'articles',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('refuses other models and anything that is not a route key', () => {
|
|
26
|
+
expect(parsePreviewUrl(url('authors'))).toBeUndefined();
|
|
27
|
+
expect(parsePreviewUrl(url('authors/articles'))).toBeUndefined();
|
|
28
|
+
expect(parsePreviewUrl(url('articles/../admin'))).toBeUndefined();
|
|
29
|
+
expect(parsePreviewUrl(url('articles/'))).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -6,36 +6,59 @@ import type { Article, DeliveryItem, Page } from './types.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Preview: Shapio opens `/preview/?model={modelKey}&id={entryId}&locale={locale}#token={token}` (the
|
|
8
8
|
* connection's preview URL template). `{modelKey}` is the model's route key, as in the delivery and preview
|
|
9
|
-
* APIs: the plural API ID of a collection (`pages`, `articles`)
|
|
9
|
+
* APIs: the plural API ID of a collection (`pages`, `articles`), nested under its parent's for a type that
|
|
10
|
+
* extends another (`articles/guides`). The token sits in the fragment, so it never reaches a server log or a
|
|
10
11
|
* Referer header; it is a scoped, expiring preview token, never an admin credential. The draft is read from
|
|
11
12
|
* `/api/preview/content/...` and rendered with the same functions as the published pages.
|
|
12
13
|
*/
|
|
13
14
|
export const PREVIEW_MODELS = ['pages', 'articles'] as const;
|
|
14
15
|
|
|
16
|
+
/** One API ID: what each segment of a route key must be. */
|
|
17
|
+
const API_ID = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
18
|
+
|
|
15
19
|
export type PreviewRequest = {
|
|
16
20
|
token: string;
|
|
17
|
-
|
|
21
|
+
/** The route key from the URL: one of PREVIEW_MODELS, or a type nested under one (`articles/guides`). */
|
|
22
|
+
modelKey: string;
|
|
23
|
+
/** The family root the draft renders as: a type that extends Article renders as an article. */
|
|
24
|
+
family: (typeof PREVIEW_MODELS)[number];
|
|
18
25
|
entryId: string;
|
|
19
26
|
locale: string;
|
|
20
27
|
};
|
|
21
28
|
|
|
29
|
+
/** The allowlisted family of a route key, or undefined for anything else. */
|
|
30
|
+
const familyOf = (modelKey: string): PreviewRequest['family'] | undefined => {
|
|
31
|
+
const segments = modelKey.split('/');
|
|
32
|
+
const root = segments[0] ?? '';
|
|
33
|
+
return segments.every((segment) => API_ID.test(segment)) &&
|
|
34
|
+
(PREVIEW_MODELS as readonly string[]).includes(root)
|
|
35
|
+
? (root as PreviewRequest['family'])
|
|
36
|
+
: undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
22
39
|
/** Reads the request from a preview URL; undefined when something is missing or unknown. */
|
|
23
40
|
export const parsePreviewUrl = (url: URL): PreviewRequest | undefined => {
|
|
24
41
|
const fragment = new URLSearchParams(url.hash.slice(1));
|
|
25
42
|
const token = fragment.get('token') ?? url.searchParams.get('token');
|
|
26
|
-
const modelKey = url.searchParams.get('model');
|
|
43
|
+
const modelKey = url.searchParams.get('model') ?? '';
|
|
27
44
|
const entryId = url.searchParams.get('id');
|
|
28
|
-
|
|
45
|
+
const family = familyOf(modelKey);
|
|
46
|
+
if (!token || !entryId || !family) {
|
|
29
47
|
return undefined;
|
|
30
48
|
}
|
|
31
49
|
return {
|
|
32
50
|
token,
|
|
33
|
-
modelKey
|
|
51
|
+
modelKey,
|
|
52
|
+
family,
|
|
34
53
|
entryId,
|
|
35
54
|
locale: url.searchParams.get('locale') ?? DEFAULT_LOCALE,
|
|
36
55
|
};
|
|
37
56
|
};
|
|
38
57
|
|
|
58
|
+
/** The preview API path of a route key: each segment encoded, the `/` between them kept. */
|
|
59
|
+
const previewPath = (modelKey: string, entryId: string) =>
|
|
60
|
+
`/api/preview/content/${modelKey.split('/').map(encodeURIComponent).join('/')}/${encodeURIComponent(entryId)}`;
|
|
61
|
+
|
|
39
62
|
/** Fetches the draft through the preview API and renders it. */
|
|
40
63
|
export const loadPreview = async (
|
|
41
64
|
shapioUrl: string,
|
|
@@ -45,14 +68,14 @@ export const loadPreview = async (
|
|
|
45
68
|
const params = new URLSearchParams({
|
|
46
69
|
locale: request.locale,
|
|
47
70
|
richText: 'html',
|
|
48
|
-
...(request.
|
|
71
|
+
...(request.family === 'articles' ? { populate: 'author' } : {}),
|
|
49
72
|
});
|
|
50
73
|
const { data } = await client.request<DeliveryItem<Page | Article>>(
|
|
51
|
-
|
|
74
|
+
`${previewPath(request.modelKey, request.entryId)}?${params.toString()}`,
|
|
52
75
|
);
|
|
53
76
|
const context = { locale: request.locale, strings: stringsFor(request.locale) };
|
|
54
77
|
return {
|
|
55
78
|
title: data.title,
|
|
56
|
-
html: request.
|
|
79
|
+
html: request.family === 'pages' ? renderPage(data as Page) : renderArticle(data as Article, context),
|
|
57
80
|
};
|
|
58
81
|
};
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"test": "vitest run"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@shapio/client": "^0.5.
|
|
22
|
-
"@shapio/local": "0.5.
|
|
23
|
-
"@shapio/visual": "^0.5.
|
|
21
|
+
"@shapio/client": "^0.5.9",
|
|
22
|
+
"@shapio/local": "0.5.9",
|
|
23
|
+
"@shapio/visual": "^0.5.9",
|
|
24
24
|
"next": "16.3.8",
|
|
25
25
|
"react": "19.3.0",
|
|
26
26
|
"react-dom": "19.3.0"
|
|
@@ -23,6 +23,25 @@ describe('changeTags', () => {
|
|
|
23
23
|
]);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("revalidates a child kind's entry under its own route key and every ancestor's", () => {
|
|
27
|
+
expect(
|
|
28
|
+
changeTags(
|
|
29
|
+
[
|
|
30
|
+
{ routeKey: 'posts/guides', id: 'g1' },
|
|
31
|
+
{ routeKey: 'posts', id: 'p1' },
|
|
32
|
+
],
|
|
33
|
+
false,
|
|
34
|
+
undefined,
|
|
35
|
+
),
|
|
36
|
+
).toEqual([
|
|
37
|
+
'shapio:posts',
|
|
38
|
+
'shapio:posts/guides',
|
|
39
|
+
'shapio:posts/guides:g1',
|
|
40
|
+
'shapio:posts:g1',
|
|
41
|
+
'shapio:posts:p1',
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
26
45
|
it('revalidates the site tag (every read) after a schema change', () => {
|
|
27
46
|
expect(changeTags([{ routeKey: 'articles', id: 'a1' }], true, 'marketing')).toEqual([
|
|
28
47
|
'shapio:site:marketing',
|
|
@@ -12,8 +12,9 @@ export const siteTags = (siteKey: string | undefined): string[] => [shapioTags.s
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The tags to revalidate for the entries that changed between two snapshots: each entry's model and the entry
|
|
15
|
-
* itself
|
|
16
|
-
*
|
|
15
|
+
* itself, under its route key and every ancestor's (a Guide that extends Post is also in the Posts list, so
|
|
16
|
+
* `posts/guides` revalidates `posts` too). A schema change (fields added, converted or removed) can change any
|
|
17
|
+
* read, so it revalidates the site tag, which every read carries.
|
|
17
18
|
*/
|
|
18
19
|
export const changeTags = (
|
|
19
20
|
changes: readonly ChangedEntryRef[],
|
|
@@ -23,8 +24,6 @@ export const changeTags = (
|
|
|
23
24
|
if (schemaChanged) {
|
|
24
25
|
return siteTags(siteKey);
|
|
25
26
|
}
|
|
26
|
-
const tags = new Set(
|
|
27
|
-
changes.flatMap(({ routeKey, id }) => [shapioTags.model(routeKey), shapioTags.entry(routeKey, id)]),
|
|
28
|
-
);
|
|
27
|
+
const tags = new Set(changes.flatMap(({ routeKey, id }) => shapioTags.changed(routeKey, id)));
|
|
29
28
|
return [...tags].sort();
|
|
30
29
|
};
|