@caelo-cms/shared 0.10.71 → 0.11.0

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 (202) hide show
  1. package/dist/ai-tools.d.ts +125 -50
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +50 -38
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +110 -30
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +26 -7
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +203 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +178 -76
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-var-aliases.d.ts +20 -0
  14. package/dist/css-var-aliases.d.ts.map +1 -0
  15. package/dist/css-var-aliases.js +95 -0
  16. package/dist/css-var-aliases.js.map +1 -0
  17. package/dist/css-var-scan.d.ts +0 -16
  18. package/dist/css-var-scan.d.ts.map +1 -1
  19. package/dist/css-var-scan.js +24 -0
  20. package/dist/css-var-scan.js.map +1 -1
  21. package/dist/edit-markers.d.ts +41 -5
  22. package/dist/edit-markers.d.ts.map +1 -1
  23. package/dist/edit-markers.js +47 -50
  24. package/dist/edit-markers.js.map +1 -1
  25. package/dist/id-safe-truncate.d.ts +15 -0
  26. package/dist/id-safe-truncate.d.ts.map +1 -0
  27. package/dist/id-safe-truncate.js +47 -0
  28. package/dist/id-safe-truncate.js.map +1 -0
  29. package/dist/index.d.ts +5 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/interactions.js +2 -2
  34. package/dist/interactions.js.map +1 -1
  35. package/dist/link-canonicalise.d.ts.map +1 -1
  36. package/dist/link-canonicalise.js +7 -1
  37. package/dist/link-canonicalise.js.map +1 -1
  38. package/dist/link-refs.d.ts +1 -1
  39. package/dist/link-refs.js +3 -3
  40. package/dist/lists/address.d.ts +34 -0
  41. package/dist/lists/address.d.ts.map +1 -0
  42. package/dist/lists/address.js +43 -0
  43. package/dist/lists/address.js.map +1 -0
  44. package/dist/lists/default-types.d.ts +10 -0
  45. package/dist/lists/default-types.d.ts.map +1 -0
  46. package/dist/lists/default-types.js +91 -0
  47. package/dist/lists/default-types.js.map +1 -0
  48. package/dist/lists/fields.d.ts +152 -0
  49. package/dist/lists/fields.d.ts.map +1 -0
  50. package/dist/lists/fields.js +258 -0
  51. package/dist/lists/fields.js.map +1 -0
  52. package/dist/lists/index.d.ts +14 -0
  53. package/dist/lists/index.d.ts.map +1 -0
  54. package/dist/lists/index.js +15 -0
  55. package/dist/lists/index.js.map +1 -0
  56. package/dist/lists/item-attr.d.ts +26 -0
  57. package/dist/lists/item-attr.d.ts.map +1 -0
  58. package/dist/lists/item-attr.js +31 -0
  59. package/dist/lists/item-attr.js.map +1 -0
  60. package/dist/lists/items.d.ts +105 -0
  61. package/dist/lists/items.d.ts.map +1 -0
  62. package/dist/lists/items.js +304 -0
  63. package/dist/lists/items.js.map +1 -0
  64. package/dist/lists/list-type.d.ts +401 -0
  65. package/dist/lists/list-type.d.ts.map +1 -0
  66. package/dist/lists/list-type.js +275 -0
  67. package/dist/lists/list-type.js.map +1 -0
  68. package/dist/lists/model.d.ts +14 -0
  69. package/dist/lists/model.d.ts.map +1 -0
  70. package/dist/lists/model.js +15 -0
  71. package/dist/lists/model.js.map +1 -0
  72. package/dist/lists/page-type.d.ts +115 -0
  73. package/dist/lists/page-type.d.ts.map +1 -0
  74. package/dist/lists/page-type.js +66 -0
  75. package/dist/lists/page-type.js.map +1 -0
  76. package/dist/lists/refs.d.ts +13 -0
  77. package/dist/lists/refs.d.ts.map +1 -0
  78. package/dist/lists/refs.js +18 -0
  79. package/dist/lists/refs.js.map +1 -0
  80. package/dist/lists/render-data.d.ts +114 -0
  81. package/dist/lists/render-data.d.ts.map +1 -0
  82. package/dist/lists/render-data.js +3 -0
  83. package/dist/lists/render-data.js.map +1 -0
  84. package/dist/lists/render-deps.d.ts +68 -0
  85. package/dist/lists/render-deps.d.ts.map +1 -0
  86. package/dist/lists/render-deps.js +91 -0
  87. package/dist/lists/render-deps.js.map +1 -0
  88. package/dist/lists/resolve-values.d.ts +63 -0
  89. package/dist/lists/resolve-values.d.ts.map +1 -0
  90. package/dist/lists/resolve-values.js +181 -0
  91. package/dist/lists/resolve-values.js.map +1 -0
  92. package/dist/lists/resolve.d.ts +106 -0
  93. package/dist/lists/resolve.d.ts.map +1 -0
  94. package/dist/lists/resolve.js +878 -0
  95. package/dist/lists/resolve.js.map +1 -0
  96. package/dist/lists/scope.d.ts +81 -0
  97. package/dist/lists/scope.d.ts.map +1 -0
  98. package/dist/lists/scope.js +3 -0
  99. package/dist/lists/scope.js.map +1 -0
  100. package/dist/markup-structure.d.ts +8 -0
  101. package/dist/markup-structure.d.ts.map +1 -1
  102. package/dist/markup-structure.js +2 -0
  103. package/dist/markup-structure.js.map +1 -1
  104. package/dist/media.d.ts +1 -1
  105. package/dist/nested-module-render.d.ts +25 -20
  106. package/dist/nested-module-render.d.ts.map +1 -1
  107. package/dist/nested-module-render.js +122 -31
  108. package/dist/nested-module-render.js.map +1 -1
  109. package/dist/page-log.d.ts +1 -1
  110. package/dist/preview-compose.d.ts +24 -21
  111. package/dist/preview-compose.d.ts.map +1 -1
  112. package/dist/preview-compose.js +34 -118
  113. package/dist/preview-compose.js.map +1 -1
  114. package/dist/preview-scanner.d.ts +5 -2
  115. package/dist/preview-scanner.d.ts.map +1 -1
  116. package/dist/preview-scanner.js +5 -3
  117. package/dist/preview-scanner.js.map +1 -1
  118. package/dist/source-text-transport.d.ts +17 -0
  119. package/dist/source-text-transport.d.ts.map +1 -0
  120. package/dist/source-text-transport.js +71 -0
  121. package/dist/source-text-transport.js.map +1 -0
  122. package/dist/template-engine.d.ts +44 -31
  123. package/dist/template-engine.d.ts.map +1 -1
  124. package/dist/template-engine.js +75 -174
  125. package/dist/template-engine.js.map +1 -1
  126. package/dist/template-sections.d.ts +69 -0
  127. package/dist/template-sections.d.ts.map +1 -0
  128. package/dist/template-sections.js +423 -0
  129. package/dist/template-sections.js.map +1 -0
  130. package/dist/uuid-diagnose.d.ts +9 -0
  131. package/dist/uuid-diagnose.d.ts.map +1 -0
  132. package/dist/uuid-diagnose.js +47 -0
  133. package/dist/uuid-diagnose.js.map +1 -0
  134. package/dist/version.d.ts +2 -2
  135. package/dist/version.d.ts.map +1 -1
  136. package/dist/version.js +1 -1
  137. package/dist/version.js.map +1 -1
  138. package/package.json +8 -1
  139. package/src/__tests__/redos-hardening.test.ts +50 -16
  140. package/src/ai-tools-add-module-modes.test.ts +50 -0
  141. package/src/ai-tools.test.ts +10 -0
  142. package/src/ai-tools.ts +53 -39
  143. package/src/build-page.test.ts +34 -3
  144. package/src/build-page.ts +29 -6
  145. package/src/content.test.ts +111 -3
  146. package/src/content.ts +188 -77
  147. package/src/css-var-aliases.test.ts +61 -0
  148. package/src/css-var-aliases.ts +109 -0
  149. package/src/css-var-scan.ts +9 -0
  150. package/src/edit-markers.test.ts +37 -21
  151. package/src/edit-markers.ts +52 -20
  152. package/src/id-safe-truncate.test.ts +35 -0
  153. package/src/id-safe-truncate.ts +46 -0
  154. package/src/index.ts +5 -1
  155. package/src/interactions.ts +2 -2
  156. package/src/link-canonicalise.test.ts +36 -6
  157. package/src/link-canonicalise.ts +6 -1
  158. package/src/link-refs.test.ts +66 -42
  159. package/src/link-refs.ts +3 -3
  160. package/src/lists/address.ts +75 -0
  161. package/src/lists/default-types.ts +102 -0
  162. package/src/lists/fields.ts +285 -0
  163. package/src/lists/index.ts +16 -0
  164. package/src/lists/item-attr.test.ts +36 -0
  165. package/src/lists/item-attr.ts +49 -0
  166. package/src/lists/items.ts +402 -0
  167. package/src/lists/list-fixtures.test.ts +302 -0
  168. package/src/lists/list-type.ts +306 -0
  169. package/src/lists/lists.test.ts +328 -0
  170. package/src/lists/model.ts +16 -0
  171. package/src/lists/page-type.ts +71 -0
  172. package/src/lists/refs.ts +21 -0
  173. package/src/lists/render-data.ts +125 -0
  174. package/src/lists/render-deps.test.ts +132 -0
  175. package/src/lists/render-deps.ts +144 -0
  176. package/src/lists/resolve-values.ts +224 -0
  177. package/src/lists/resolve.test.ts +295 -0
  178. package/src/lists/resolve.ts +1148 -0
  179. package/src/lists/scope.ts +69 -0
  180. package/src/lists-compose-parity.test.ts +262 -0
  181. package/src/markup-structure.ts +10 -0
  182. package/src/nested-module-render.test.ts +111 -77
  183. package/src/nested-module-render.ts +85 -48
  184. package/src/preview-compose.test.ts +74 -19
  185. package/src/preview-compose.ts +59 -154
  186. package/src/preview-scanner.test.ts +8 -4
  187. package/src/preview-scanner.ts +5 -3
  188. package/src/source-text-transport.test.ts +63 -0
  189. package/src/source-text-transport.ts +70 -0
  190. package/src/template-engine-lists.test.ts +553 -0
  191. package/src/template-engine.test.ts +62 -80
  192. package/src/template-engine.ts +102 -245
  193. package/src/template-sections.ts +500 -0
  194. package/src/uuid-diagnose.test.ts +28 -0
  195. package/src/uuid-diagnose.ts +50 -0
  196. package/src/version.ts +1 -1
  197. package/dist/structured-sets.d.ts +0 -70
  198. package/dist/structured-sets.d.ts.map +0 -1
  199. package/dist/structured-sets.js +0 -92
  200. package/dist/structured-sets.js.map +0 -1
  201. package/src/structured-sets.ts +0 -114
  202. package/src/template-engine-data-lists.test.ts +0 -90
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Page types (epic #1107, `docs/lists.md` §5). A page type says what a
5
+ * page IS — News, Product, Doc — while the template stays its layout.
6
+ * It declares the page's properties (same field system as list entries),
7
+ * the templates a page of this type may use, and the vocabularies whose
8
+ * terms apply. Page queries filter on it; the AI reads "this module sits
9
+ * on three News pages" from it (CLAUDE.md §1A). Replaces `templates.kind`.
10
+ */
11
+
12
+ import { z } from "zod";
13
+ import { checkFieldDef, fieldDefSchema, type ListIssue } from "./fields.js";
14
+
15
+ export const pageTypeDefSchema = z
16
+ .object({
17
+ slug: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/),
18
+ name: z.string().min(1).max(200),
19
+ purpose: z.string().min(1).max(1000),
20
+ /** Template ids a page of this type may bind to. */
21
+ templates: z.array(z.string().uuid()).min(1).max(64),
22
+ defaultTemplate: z.string().uuid(),
23
+ properties: z.array(fieldDefSchema).max(32).default([]),
24
+ vocabularies: z
25
+ .array(
26
+ z
27
+ .object({
28
+ /** Vocabulary list id. */
29
+ list: z.string().uuid(),
30
+ cardinality: z.enum(["one", "many"]),
31
+ required: z.boolean().optional(),
32
+ })
33
+ .strict(),
34
+ )
35
+ .max(16)
36
+ .default([]),
37
+ })
38
+ .strict();
39
+ export type PageTypeDef = z.infer<typeof pageTypeDefSchema>;
40
+
41
+ /** Static checks beyond the Zod shape. */
42
+ export function checkPageTypeDef(def: PageTypeDef): ListIssue[] {
43
+ const issues: ListIssue[] = [];
44
+ if (!def.templates.includes(def.defaultTemplate)) {
45
+ issues.push({
46
+ path: "defaultTemplate",
47
+ message: "defaultTemplate must be one of templates.",
48
+ });
49
+ }
50
+ const seen = new Set<string>();
51
+ def.properties.forEach((f, i) => {
52
+ const at = `properties[${i}]`;
53
+ if (seen.has(f.name)) issues.push({ path: at, message: `duplicate property "${f.name}".` });
54
+ seen.add(f.name);
55
+ if (f.type === "module") {
56
+ issues.push({
57
+ path: at,
58
+ message: "page properties cannot embed modules — place the module on the page instead.",
59
+ });
60
+ }
61
+ issues.push(...checkFieldDef(f, at));
62
+ });
63
+ const lists = new Set<string>();
64
+ def.vocabularies.forEach((v, i) => {
65
+ if (lists.has(v.list)) {
66
+ issues.push({ path: `vocabularies[${i}]`, message: "vocabulary listed twice." });
67
+ }
68
+ lists.add(v.list);
69
+ });
70
+ return issues;
71
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * `caelo:list/<id>` — the reference a module `list` field with
5
+ * `bind: "shared"` stores (epic #1107, `docs/lists.md` §7). Its own file
6
+ * because the module field schema (`content.ts`) and the resolver both
7
+ * need it, and the resolver imports far more than the schema should.
8
+ */
9
+
10
+ /** `caelo:list/<id>` */
11
+ export const LIST_REF_RE = /^caelo:list\/([0-9a-f-]{36})$/;
12
+
13
+ /** Parse `caelo:list/<id>`; null when the string is not one. */
14
+ export function parseListRef(s: string): string | null {
15
+ return LIST_REF_RE.exec(s)?.[1] ?? null;
16
+ }
17
+
18
+ /** Format a shared-list reference. */
19
+ export function formatListRef(listId: string): string {
20
+ return `caelo:list/${listId}`;
21
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * What a render needs to resolve lists, loaded in one batch per preview
5
+ * or per build (epic #1107, `docs/lists.md` §10). The admin side builds it
6
+ * (`admin-core/src/lists/load.ts`: draft overlay in previews, published
7
+ * state in builds); the shared resolver (`resolve.ts`) consumes it. After
8
+ * loading, everything is pure and synchronous — the same split as
9
+ * `SiteLinkIndex` for links.
10
+ */
11
+
12
+ import type { LinkPageVariant } from "../link-refs.js";
13
+ import type { ListItem } from "./items.js";
14
+ import type { ListTypeDef } from "./list-type.js";
15
+ import type { PageTypeDef } from "./page-type.js";
16
+
17
+ /** A list type as loaded (draft view in previews). */
18
+ export interface LoadedListType {
19
+ readonly id: string;
20
+ readonly def: ListTypeDef;
21
+ }
22
+
23
+ /** A shared list as loaded. */
24
+ export interface LoadedList {
25
+ readonly id: string;
26
+ readonly slug: string;
27
+ readonly name: string;
28
+ readonly typeSlug: string;
29
+ readonly items: readonly ListItem[];
30
+ }
31
+
32
+ /** A page type as loaded. */
33
+ export interface LoadedPageType {
34
+ readonly id: string;
35
+ readonly def: PageTypeDef;
36
+ }
37
+
38
+ /**
39
+ * One page as queries, system sources and `{{page.*}}` see it. Every
40
+ * live page of the render's scope is listed; `inBuild` says whether a
41
+ * query may return it (published in this build; in previews: status
42
+ * published in the draft view).
43
+ */
44
+ export interface ListPageRecord {
45
+ readonly id: string;
46
+ readonly title: string;
47
+ readonly parentId: string | null;
48
+ readonly pageTypeSlug: string;
49
+ readonly properties: Readonly<Record<string, unknown>>;
50
+ /** `caelo:term/<listId>/<itemId>` */
51
+ readonly terms: readonly string[];
52
+ /** ISO timestamp, null until the first Publish live. */
53
+ readonly firstPublishedAt: string | null;
54
+ readonly updatedAt: string;
55
+ /** SEO meta description, or null. */
56
+ readonly summary: string | null;
57
+ /** `caelo:media/<id>` social image, or null. */
58
+ readonly image: string | null;
59
+ readonly inBuild: boolean;
60
+ }
61
+
62
+ /** Media metadata for `{{f.alt}}`, `{{f.width}}`, `{{f.height}}`. URLs come from the link resolver. */
63
+ export interface ListMediaRecord {
64
+ readonly id: string;
65
+ readonly alt: string | null;
66
+ readonly width: number | null;
67
+ readonly height: number | null;
68
+ }
69
+
70
+ /**
71
+ * A plugin's `list-resolve` answer for one output variant. Keys are
72
+ * value addresses (`address.ts`). An overlay may replace values and hide
73
+ * entries; it can never add, remove or reorder entries.
74
+ */
75
+ export interface ListVariantOverlay {
76
+ readonly values: ReadonlyMap<string, unknown>;
77
+ /** Addresses of hidden entries: `list/<listId>/item/<itemId>` or `instance/<id>/<field>/item/<itemId>`. */
78
+ readonly hidden: ReadonlySet<string>;
79
+ }
80
+
81
+ /** An entry a plugin list source produced for one page. Values follow the source's node definition. */
82
+ export interface PluginSourceEntry {
83
+ readonly id: string;
84
+ readonly values: Readonly<Record<string, unknown>>;
85
+ }
86
+
87
+ export interface ListRenderData {
88
+ /** Keyed by list type slug. */
89
+ readonly listTypes: ReadonlyMap<string, LoadedListType>;
90
+ /** Keyed by list id. */
91
+ readonly lists: ReadonlyMap<string, LoadedList>;
92
+ /** Keyed by page type slug. */
93
+ readonly pageTypes: ReadonlyMap<string, LoadedPageType>;
94
+ /** Keyed by page id. */
95
+ readonly pages: ReadonlyMap<string, ListPageRecord>;
96
+ /** Keyed by media id. */
97
+ readonly media: ReadonlyMap<string, ListMediaRecord>;
98
+ /** The `link-resolve` page partition (same map the link index uses). */
99
+ readonly pageVariants?: ReadonlyMap<string, LinkPageVariant>;
100
+ /** `list-resolve` overlays keyed by variant. */
101
+ readonly overlays?: ReadonlyMap<string, ListVariantOverlay>;
102
+ /** `site_settings.site_language`, for `{{date.formatted}}`. */
103
+ readonly siteLanguage: string;
104
+ }
105
+
106
+ /** Render dependencies one page collected (build manifest `render-deps.json`). */
107
+ export interface ListRenderDeps {
108
+ readonly lists: readonly string[];
109
+ readonly listTypes: readonly string[];
110
+ /** Normalised query filters (page type, terms, parent) the page rendered. */
111
+ readonly queries: readonly {
112
+ readonly pageType: string | null;
113
+ readonly terms: readonly string[];
114
+ readonly parent: string | null;
115
+ }[];
116
+ /** System sources that depend on the page tree (breadcrumbs, children, siblings, term_pages). */
117
+ readonly pageTree: boolean;
118
+ /**
119
+ * Pages a system source (breadcrumbs, children, siblings) rendered as
120
+ * entries. Those entries expose the page's values (`props`, `terms`,
121
+ * `updated_at`), so editing any of them must rebuild this page too —
122
+ * `pageTree` alone only covers title / parent / build membership.
123
+ */
124
+ readonly pages: readonly string[];
125
+ }
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Incremental build selection over `render-deps.json` (epic #1107,
5
+ * `docs/lists.md` §10): exactly the pages a change reaches.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { PAGE, PAGES, TERM } from "./list-fixtures.test.js";
10
+ import type { ListRenderDeps } from "./render-data.js";
11
+ import {
12
+ mergeRenderDeps,
13
+ parseRenderDeps,
14
+ type RenderDepsPageAttrs,
15
+ renderDepsAttrs,
16
+ selectDependentPages,
17
+ } from "./render-deps.js";
18
+
19
+ const NONE: ListRenderDeps = { lists: [], listTypes: [], queries: [], pageTree: false, pages: [] };
20
+ const LIST_A = "00000000-0000-4000-8000-00000000aaaa";
21
+
22
+ const deps = mergeRenderDeps(
23
+ null,
24
+ new Map<string, ListRenderDeps>([
25
+ // The home page shows the header menu and "latest news".
26
+ [
27
+ PAGE.home,
28
+ {
29
+ ...NONE,
30
+ lists: [LIST_A],
31
+ listTypes: ["menu"],
32
+ queries: [{ pageType: "news", terms: [], parent: null }],
33
+ },
34
+ ],
35
+ // About shows breadcrumbs.
36
+ [PAGE.about, { ...NONE, pageTree: true }],
37
+ // Team shows "related: events".
38
+ [PAGE.team, { ...NONE, queries: [{ pageType: null, terms: [TERM.event], parent: null }] }],
39
+ [PAGE.news, NONE],
40
+ ]),
41
+ PAGES,
42
+ );
43
+ const current = (patch: Partial<Record<string, Partial<RenderDepsPageAttrs>>> = {}) =>
44
+ new Map(PAGES.map((p) => [p.id, { ...renderDepsAttrs(p), ...(patch[p.id] ?? {}) }] as const));
45
+
46
+ describe("selectDependentPages", () => {
47
+ it("a changed list rebuilds the pages that read it", () => {
48
+ const got = selectDependentPages(
49
+ deps,
50
+ { pageIds: [], listIds: [LIST_A], listTypeSlugs: [] },
51
+ current(),
52
+ );
53
+ expect([...got]).toEqual([PAGE.home]);
54
+ });
55
+
56
+ it("a changed list type rebuilds the pages that read lists of it", () => {
57
+ const got = selectDependentPages(
58
+ deps,
59
+ { pageIds: [], listIds: [], listTypeSlugs: ["menu"] },
60
+ current(),
61
+ );
62
+ expect([...got]).toEqual([PAGE.home]);
63
+ });
64
+
65
+ it("publishing a News page rebuilds exactly the pages whose query matches it", () => {
66
+ // The draft enters the build: it now matches "latest news" (home);
67
+ // its title / build membership change hits the page-tree reader (about).
68
+ const got = selectDependentPages(
69
+ deps,
70
+ { pageIds: [PAGE.newsDraft], listIds: [], listTypeSlugs: [] },
71
+ current({ [PAGE.newsDraft]: { inBuild: true } }),
72
+ );
73
+ expect([...got].sort()).toEqual([PAGE.home, PAGE.about].sort());
74
+ });
75
+
76
+ it("a page that LEFT a filter reaches the pages that rendered it", () => {
77
+ // Meetup loses the "event" term: Team's related query showed it before.
78
+ const got = selectDependentPages(
79
+ deps,
80
+ { pageIds: [PAGE.newsB], listIds: [], listTypeSlugs: [] },
81
+ current({ [PAGE.newsB]: { terms: [] } }),
82
+ );
83
+ expect(got.has(PAGE.team)).toBe(true);
84
+ expect(got.has(PAGE.home)).toBe(true);
85
+ expect(got.has(PAGE.about)).toBe(false);
86
+ });
87
+
88
+ it("a content edit that changes no filter attribute reaches no page-tree reader", () => {
89
+ const got = selectDependentPages(
90
+ deps,
91
+ { pageIds: [PAGE.home], listIds: [], listTypeSlugs: [] },
92
+ current(),
93
+ );
94
+ expect(got.size).toBe(0);
95
+ });
96
+ });
97
+
98
+ describe("render-deps.json", () => {
99
+ it("a property-only edit of a page a system source rendered rebuilds that reader (#1121 review)", () => {
100
+ // Team lists its children; News is one of them. Editing News' props
101
+ // changes no tree attribute, but Team shows News' values.
102
+ const withChildren = mergeRenderDeps(
103
+ deps,
104
+ new Map([[PAGE.team, { ...NONE, pageTree: true, pages: [PAGE.news] }]]),
105
+ PAGES,
106
+ );
107
+ const got = selectDependentPages(
108
+ withChildren,
109
+ { pageIds: [PAGE.news], listIds: [], listTypeSlugs: [] },
110
+ current(),
111
+ );
112
+ expect(got.has(PAGE.team)).toBe(true);
113
+ expect(got.has(PAGE.about)).toBe(false);
114
+ });
115
+
116
+ it("round-trips and merges an incremental build into the previous file", () => {
117
+ const parsed = parseRenderDeps(JSON.stringify(deps));
118
+ expect(parsed).toEqual(deps);
119
+ const merged = mergeRenderDeps(
120
+ parsed,
121
+ new Map([[PAGE.news, { ...NONE, pageTree: true }]]),
122
+ PAGES,
123
+ );
124
+ expect(merged.pages[PAGE.news]?.pageTree).toBe(true);
125
+ expect(merged.pages[PAGE.home]).toEqual(deps.pages[PAGE.home] as ListRenderDeps);
126
+ });
127
+
128
+ it("an unknown version reads as no deps (a full build follows)", () => {
129
+ expect(parseRenderDeps(JSON.stringify({ version: 0, pages: {}, index: {} }))).toBeNull();
130
+ expect(parseRenderDeps(null)).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,144 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Render dependencies of a build (epic #1107, `docs/lists.md` §10): which
5
+ * lists, list types, page queries and page-tree sources every page read,
6
+ * written by the static generator as `render-deps.json`, and the
7
+ * selection an incremental build runs on it — every page whose deps
8
+ * intersect the change.
9
+ *
10
+ * A query matches by its filter (page type, terms, parent), so a page
11
+ * that ENTERED, LEFT or CHANGED inside a filter reaches every page that
12
+ * rendered that query. That is why the file keeps each page's filter
13
+ * attributes as of the build: "left the filter" can only be seen against
14
+ * what the page was before.
15
+ *
16
+ * Cost: one pass over the recorded deps per change set — O(changes ×
17
+ * deps), never a re-render of the site to find out.
18
+ */
19
+
20
+ import type { ListPageRecord, ListRenderDeps } from "./render-data.js";
21
+
22
+ export const RENDER_DEPS_FILE = "render-deps.json";
23
+ export const RENDER_DEPS_VERSION = 2;
24
+
25
+ /** A page's filter-relevant attributes as of a build. */
26
+ export interface RenderDepsPageAttrs {
27
+ readonly pageType: string;
28
+ readonly terms: readonly string[];
29
+ readonly parent: string | null;
30
+ readonly title: string;
31
+ readonly inBuild: boolean;
32
+ }
33
+
34
+ /** `render-deps.json`. */
35
+ export interface RenderDepsFile {
36
+ readonly version: typeof RENDER_DEPS_VERSION;
37
+ /** Per rendered page: what its render read. */
38
+ readonly pages: Readonly<Record<string, ListRenderDeps>>;
39
+ /** Per known page: its filter attributes when the build ran. */
40
+ readonly index: Readonly<Record<string, RenderDepsPageAttrs>>;
41
+ }
42
+
43
+ /** The attributes {@link RenderDepsFile.index} keeps for a page. */
44
+ export function renderDepsAttrs(p: ListPageRecord): RenderDepsPageAttrs {
45
+ return {
46
+ pageType: p.pageTypeSlug,
47
+ terms: [...p.terms].sort(),
48
+ parent: p.parentId,
49
+ title: p.title,
50
+ inBuild: p.inBuild,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Merge the deps of a (possibly incremental) build into the previous
56
+ * file: rebuilt pages replace their entry, the index is the current one.
57
+ */
58
+ export function mergeRenderDeps(
59
+ previous: RenderDepsFile | null,
60
+ rebuilt: ReadonlyMap<string, ListRenderDeps>,
61
+ pages: Iterable<ListPageRecord>,
62
+ ): RenderDepsFile {
63
+ const out: Record<string, ListRenderDeps> = { ...(previous?.pages ?? {}) };
64
+ for (const [id, deps] of rebuilt) out[id] = deps;
65
+ const index: Record<string, RenderDepsPageAttrs> = {};
66
+ for (const p of pages) index[p.id] = renderDepsAttrs(p);
67
+ for (const id of Object.keys(out)) if (!index[id]) delete out[id];
68
+ return { version: RENDER_DEPS_VERSION, pages: out, index };
69
+ }
70
+
71
+ /** Parse a stored deps file; null when it is absent or of another version. */
72
+ export function parseRenderDeps(raw: string | null): RenderDepsFile | null {
73
+ if (raw === null) return null;
74
+ const v = JSON.parse(raw) as Partial<RenderDepsFile>;
75
+ if (v.version !== RENDER_DEPS_VERSION || !v.pages || !v.index) return null;
76
+ return v as RenderDepsFile;
77
+ }
78
+
79
+ /** What changed since the build the deps file describes. */
80
+ export interface RenderChange {
81
+ readonly pageIds: readonly string[];
82
+ readonly listIds: readonly string[];
83
+ readonly listTypeSlugs: readonly string[];
84
+ }
85
+
86
+ /**
87
+ * The pages an incremental build must rebuild beyond the changed pages
88
+ * themselves:
89
+ *
90
+ * - every page that read a changed list or list type;
91
+ * - for every changed page: every page whose query filter matches the
92
+ * page's attributes before OR after the change (entered, left or
93
+ * changed inside the filter);
94
+ * - every page with page-tree deps (breadcrumbs, children, siblings,
95
+ * term pages) when a changed page's title, parent or build membership
96
+ * changed;
97
+ * - every page whose system source rendered a changed page as an entry
98
+ * (its `props`, `terms`, `updated_at` show there).
99
+ *
100
+ * `current` is the page index as the new build sees it.
101
+ */
102
+ export function selectDependentPages(
103
+ deps: RenderDepsFile,
104
+ change: RenderChange,
105
+ current: ReadonlyMap<string, RenderDepsPageAttrs>,
106
+ ): Set<string> {
107
+ const out = new Set<string>();
108
+ const lists = new Set(change.listIds);
109
+ const types = new Set(change.listTypeSlugs);
110
+ const changedPages = new Set(change.pageIds);
111
+ const states: RenderDepsPageAttrs[] = [];
112
+ let treeChanged = false;
113
+ for (const id of change.pageIds) {
114
+ const before = deps.index[id];
115
+ const after = current.get(id);
116
+ if (before) states.push(before);
117
+ if (after) states.push(after);
118
+ if (
119
+ !before ||
120
+ !after ||
121
+ before.title !== after.title ||
122
+ before.parent !== after.parent ||
123
+ before.inBuild !== after.inBuild
124
+ ) {
125
+ treeChanged = true;
126
+ }
127
+ }
128
+ const matches = (q: ListRenderDeps["queries"][number], p: RenderDepsPageAttrs): boolean =>
129
+ (q.pageType === null || q.pageType === p.pageType) &&
130
+ (q.parent === null || q.parent === p.parent) &&
131
+ (q.terms.length === 0 || q.terms.some((t) => p.terms.includes(t)));
132
+ for (const [pageId, d] of Object.entries(deps.pages)) {
133
+ if (
134
+ d.lists.some((l) => lists.has(l)) ||
135
+ d.listTypes.some((t) => types.has(t)) ||
136
+ (treeChanged && d.pageTree) ||
137
+ d.pages.some((id) => changedPages.has(id)) ||
138
+ (states.length > 0 && d.queries.some((q) => states.some((s) => matches(q, s))))
139
+ ) {
140
+ out.add(pageId);
141
+ }
142
+ }
143
+ return out;
144
+ }