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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shapio",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Scaffold a new Shapio project (npx create-shapio my-cms) or a site starter (--site astro|next|sveltekit)",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://shapio.dev",
@@ -22,8 +22,8 @@
22
22
  "test": "vitest run"
23
23
  },
24
24
  "dependencies": {
25
- "@shapio/client": "^0.5.8",
26
- "@shapio/visual": "^0.5.8",
25
+ "@shapio/client": "^0.5.9",
26
+ "@shapio/visual": "^0.5.9",
27
27
  "astro": "7.3.5"
28
28
  },
29
29
  "devDependencies": {
@@ -74,21 +74,29 @@ const slugAt = async (change: SnapshotChange, locale: string, snapshot: number):
74
74
  };
75
75
 
76
76
  /**
77
- * The models whose entries have a route of their own, found by slug. Any other model (a redirect type a user
78
- * adds, say) maps to no route here: it may have no `slug` field, and Astro's incremental build still decides
79
- * page by page what to rebuild.
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 SLUG_ROUTED_MODELS = new Set(['page', 'article']);
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 (change.modelKey === 'author') {
95
+ if (family === 'authors') {
88
96
  routes.push(`/${locale}/articles/`);
89
97
  continue;
90
98
  }
91
- if (!SLUG_ROUTED_MODELS.has(change.modelKey)) {
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 (change.modelKey === 'page') {
107
+ if (family === 'pages') {
100
108
  routes.push(slug === 'home' ? `/${locale}/` : `/${locale}/${slug}/`);
101
- } else if (change.modelKey === 'article') {
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`). The token sits in the fragment, so it never reaches a server log or a
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
- modelKey: (typeof PREVIEW_MODELS)[number];
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
- if (!token || !entryId || !(PREVIEW_MODELS as readonly string[]).includes(modelKey ?? '')) {
45
+ const family = familyOf(modelKey);
46
+ if (!token || !entryId || !family) {
29
47
  return undefined;
30
48
  }
31
49
  return {
32
50
  token,
33
- modelKey: modelKey as PreviewRequest['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.modelKey === 'articles' ? { populate: 'author' } : {}),
71
+ ...(request.family === 'articles' ? { populate: 'author' } : {}),
49
72
  });
50
73
  const { data } = await client.request<DeliveryItem<Page | Article>>(
51
- `/api/preview/content/${encodeURIComponent(request.modelKey)}/${encodeURIComponent(request.entryId)}?${params.toString()}`,
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.modelKey === 'pages' ? renderPage(data as Page) : renderArticle(data as Article, context),
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.8",
22
- "@shapio/local": "0.5.8",
23
- "@shapio/visual": "^0.5.8",
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. A schema change (fields added, converted or removed) can change any read, so it revalidates the
16
- * site tag, which every read carries.
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
  };
@@ -23,8 +23,8 @@
23
23
  "test": "vitest run"
24
24
  },
25
25
  "dependencies": {
26
- "@shapio/client": "^0.5.8",
27
- "@shapio/visual": "^0.5.8"
26
+ "@shapio/client": "^0.5.9",
27
+ "@shapio/visual": "^0.5.9"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@sveltejs/adapter-static": "4.0.0",