@caelo-cms/shared 0.10.72 → 0.11.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.
Files changed (204) hide show
  1. package/dist/ai-tools.d.ts +103 -142
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +79 -167
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +115 -35
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +31 -12
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +214 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +219 -84
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-scope-scan.js +1 -1
  14. package/dist/css-var-aliases.d.ts +20 -0
  15. package/dist/css-var-aliases.d.ts.map +1 -0
  16. package/dist/css-var-aliases.js +95 -0
  17. package/dist/css-var-aliases.js.map +1 -0
  18. package/dist/css-var-scan.d.ts +0 -16
  19. package/dist/css-var-scan.d.ts.map +1 -1
  20. package/dist/css-var-scan.js +24 -0
  21. package/dist/css-var-scan.js.map +1 -1
  22. package/dist/edit-markers.d.ts +41 -5
  23. package/dist/edit-markers.d.ts.map +1 -1
  24. package/dist/edit-markers.js +47 -50
  25. package/dist/edit-markers.js.map +1 -1
  26. package/dist/escaped-line-breaks.d.ts +44 -0
  27. package/dist/escaped-line-breaks.d.ts.map +1 -0
  28. package/dist/escaped-line-breaks.js +203 -0
  29. package/dist/escaped-line-breaks.js.map +1 -0
  30. package/dist/index.d.ts +4 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interactions.js +6 -6
  35. package/dist/interactions.js.map +1 -1
  36. package/dist/link-canonicalise.d.ts.map +1 -1
  37. package/dist/link-canonicalise.js +7 -1
  38. package/dist/link-canonicalise.js.map +1 -1
  39. package/dist/link-refs.d.ts +1 -1
  40. package/dist/link-refs.js +3 -3
  41. package/dist/lists/address.d.ts +34 -0
  42. package/dist/lists/address.d.ts.map +1 -0
  43. package/dist/lists/address.js +43 -0
  44. package/dist/lists/address.js.map +1 -0
  45. package/dist/lists/default-types.d.ts +22 -0
  46. package/dist/lists/default-types.d.ts.map +1 -0
  47. package/dist/lists/default-types.js +127 -0
  48. package/dist/lists/default-types.js.map +1 -0
  49. package/dist/lists/fields.d.ts +152 -0
  50. package/dist/lists/fields.d.ts.map +1 -0
  51. package/dist/lists/fields.js +259 -0
  52. package/dist/lists/fields.js.map +1 -0
  53. package/dist/lists/index.d.ts +14 -0
  54. package/dist/lists/index.d.ts.map +1 -0
  55. package/dist/lists/index.js +15 -0
  56. package/dist/lists/index.js.map +1 -0
  57. package/dist/lists/item-attr.d.ts +26 -0
  58. package/dist/lists/item-attr.d.ts.map +1 -0
  59. package/dist/lists/item-attr.js +31 -0
  60. package/dist/lists/item-attr.js.map +1 -0
  61. package/dist/lists/items.d.ts +105 -0
  62. package/dist/lists/items.d.ts.map +1 -0
  63. package/dist/lists/items.js +304 -0
  64. package/dist/lists/items.js.map +1 -0
  65. package/dist/lists/list-type.d.ts +401 -0
  66. package/dist/lists/list-type.d.ts.map +1 -0
  67. package/dist/lists/list-type.js +275 -0
  68. package/dist/lists/list-type.js.map +1 -0
  69. package/dist/lists/model.d.ts +14 -0
  70. package/dist/lists/model.d.ts.map +1 -0
  71. package/dist/lists/model.js +15 -0
  72. package/dist/lists/model.js.map +1 -0
  73. package/dist/lists/page-type.d.ts +115 -0
  74. package/dist/lists/page-type.d.ts.map +1 -0
  75. package/dist/lists/page-type.js +66 -0
  76. package/dist/lists/page-type.js.map +1 -0
  77. package/dist/lists/refs.d.ts +13 -0
  78. package/dist/lists/refs.d.ts.map +1 -0
  79. package/dist/lists/refs.js +18 -0
  80. package/dist/lists/refs.js.map +1 -0
  81. package/dist/lists/render-data.d.ts +123 -0
  82. package/dist/lists/render-data.d.ts.map +1 -0
  83. package/dist/lists/render-data.js +3 -0
  84. package/dist/lists/render-data.js.map +1 -0
  85. package/dist/lists/render-deps.d.ts +69 -0
  86. package/dist/lists/render-deps.d.ts.map +1 -0
  87. package/dist/lists/render-deps.js +93 -0
  88. package/dist/lists/render-deps.js.map +1 -0
  89. package/dist/lists/resolve-values.d.ts +63 -0
  90. package/dist/lists/resolve-values.d.ts.map +1 -0
  91. package/dist/lists/resolve-values.js +181 -0
  92. package/dist/lists/resolve-values.js.map +1 -0
  93. package/dist/lists/resolve.d.ts +119 -0
  94. package/dist/lists/resolve.d.ts.map +1 -0
  95. package/dist/lists/resolve.js +970 -0
  96. package/dist/lists/resolve.js.map +1 -0
  97. package/dist/lists/scope.d.ts +81 -0
  98. package/dist/lists/scope.d.ts.map +1 -0
  99. package/dist/lists/scope.js +3 -0
  100. package/dist/lists/scope.js.map +1 -0
  101. package/dist/markup-structure.d.ts +8 -0
  102. package/dist/markup-structure.d.ts.map +1 -1
  103. package/dist/markup-structure.js +2 -0
  104. package/dist/markup-structure.js.map +1 -1
  105. package/dist/media.d.ts +1 -1
  106. package/dist/nested-module-render.d.ts +38 -20
  107. package/dist/nested-module-render.d.ts.map +1 -1
  108. package/dist/nested-module-render.js +156 -31
  109. package/dist/nested-module-render.js.map +1 -1
  110. package/dist/page-log.d.ts +1 -1
  111. package/dist/preview-compose.d.ts +24 -21
  112. package/dist/preview-compose.d.ts.map +1 -1
  113. package/dist/preview-compose.js +34 -118
  114. package/dist/preview-compose.js.map +1 -1
  115. package/dist/redirect-plan.d.ts +13 -0
  116. package/dist/redirect-plan.d.ts.map +1 -1
  117. package/dist/redirect-plan.js +22 -0
  118. package/dist/redirect-plan.js.map +1 -1
  119. package/dist/seo.d.ts +21 -0
  120. package/dist/seo.d.ts.map +1 -1
  121. package/dist/seo.js +32 -0
  122. package/dist/seo.js.map +1 -1
  123. package/dist/source-text-transport.d.ts +43 -0
  124. package/dist/source-text-transport.d.ts.map +1 -0
  125. package/dist/source-text-transport.js +79 -0
  126. package/dist/source-text-transport.js.map +1 -0
  127. package/dist/template-engine.d.ts +44 -31
  128. package/dist/template-engine.d.ts.map +1 -1
  129. package/dist/template-engine.js +75 -174
  130. package/dist/template-engine.js.map +1 -1
  131. package/dist/template-sections.d.ts +69 -0
  132. package/dist/template-sections.d.ts.map +1 -0
  133. package/dist/template-sections.js +436 -0
  134. package/dist/template-sections.js.map +1 -0
  135. package/dist/version.d.ts +2 -2
  136. package/dist/version.d.ts.map +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/version.js.map +1 -1
  139. package/package.json +8 -1
  140. package/src/__tests__/redos-hardening.test.ts +51 -16
  141. package/src/ai-tools-add-module-modes.test.ts +51 -1
  142. package/src/ai-tools.test.ts +7 -10
  143. package/src/ai-tools.ts +90 -180
  144. package/src/bug-intake.test.ts +1 -1
  145. package/src/build-page.test.ts +34 -3
  146. package/src/build-page.ts +34 -11
  147. package/src/content.test.ts +137 -4
  148. package/src/content.ts +230 -85
  149. package/src/css-scope-scan.ts +1 -1
  150. package/src/css-var-aliases.test.ts +61 -0
  151. package/src/css-var-aliases.ts +109 -0
  152. package/src/css-var-scan.ts +9 -0
  153. package/src/edit-markers.test.ts +37 -21
  154. package/src/edit-markers.ts +52 -20
  155. package/src/escaped-line-breaks.ts +221 -0
  156. package/src/index.ts +4 -1
  157. package/src/interactions.ts +6 -6
  158. package/src/link-canonicalise.test.ts +36 -6
  159. package/src/link-canonicalise.ts +6 -1
  160. package/src/link-refs.test.ts +67 -42
  161. package/src/link-refs.ts +3 -3
  162. package/src/lists/address.ts +75 -0
  163. package/src/lists/default-types.ts +141 -0
  164. package/src/lists/fields.ts +286 -0
  165. package/src/lists/index.ts +16 -0
  166. package/src/lists/item-attr.test.ts +36 -0
  167. package/src/lists/item-attr.ts +49 -0
  168. package/src/lists/items.ts +402 -0
  169. package/src/lists/list-fixtures.test.ts +308 -0
  170. package/src/lists/list-type.ts +306 -0
  171. package/src/lists/lists.test.ts +328 -0
  172. package/src/lists/model.ts +16 -0
  173. package/src/lists/page-type.ts +71 -0
  174. package/src/lists/refs.ts +21 -0
  175. package/src/lists/render-data.ts +134 -0
  176. package/src/lists/render-deps.test.ts +132 -0
  177. package/src/lists/render-deps.ts +147 -0
  178. package/src/lists/resolve-values.ts +224 -0
  179. package/src/lists/resolve.test.ts +472 -0
  180. package/src/lists/resolve.ts +1246 -0
  181. package/src/lists/scope.ts +69 -0
  182. package/src/lists-compose-parity.test.ts +262 -0
  183. package/src/markup-structure.ts +10 -0
  184. package/src/nested-module-render.test.ts +147 -74
  185. package/src/nested-module-render.ts +120 -48
  186. package/src/preview-compose.test.ts +55 -19
  187. package/src/preview-compose.ts +59 -154
  188. package/src/redirect-plan.test.ts +25 -0
  189. package/src/redirect-plan.ts +24 -0
  190. package/src/seo.test.ts +58 -0
  191. package/src/seo.ts +49 -0
  192. package/src/source-text-transport.test.ts +158 -0
  193. package/src/source-text-transport.ts +81 -0
  194. package/src/template-engine-lists.test.ts +608 -0
  195. package/src/template-engine.test.ts +62 -80
  196. package/src/template-engine.ts +102 -245
  197. package/src/template-sections.ts +514 -0
  198. package/src/version.ts +1 -1
  199. package/dist/structured-sets.d.ts +0 -70
  200. package/dist/structured-sets.d.ts.map +0 -1
  201. package/dist/structured-sets.js +0 -92
  202. package/dist/structured-sets.js.map +0 -1
  203. package/src/structured-sets.ts +0 -114
  204. package/src/template-engine-data-lists.test.ts +0 -90
@@ -0,0 +1,1246 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The list resolver (epic #1107, `docs/lists.md` §6, §7, §10). Turns a
5
+ * module's `list` field — local entries, a shared list, a page query, a
6
+ * system source or a plugin source — into the entry scopes the template
7
+ * engine iterates, for ONE rendering page.
8
+ *
9
+ * Built once per preview / per build from batch-loaded
10
+ * {@link ListRenderData} and cut per page with `forPage`, the same split
11
+ * as `SiteLinkIndex`: everything after loading is pure and synchronous,
12
+ * and the editor preview and the static generator get byte-identical
13
+ * output from the same input.
14
+ *
15
+ * Per page it also records what the render read ({@link ListRenderDeps}),
16
+ * which the generator writes to `render-deps.json` so an incremental
17
+ * build can rebuild exactly the pages a change reaches.
18
+ *
19
+ * Loud, never silently empty (CLAUDE.md §2): an unbound shared field, a
20
+ * deleted list, an unknown type, a plugin source whose plugin is off and
21
+ * a malformed entry all resolve to a failure marker the engine renders
22
+ * raw.
23
+ */
24
+
25
+ import type { LinkResolver } from "../link-refs.js";
26
+ import { parseLinkRef } from "../link-refs.js";
27
+ import { SITE_TEXTS_LIST_TYPE } from "./default-types.js";
28
+ import { type FieldDef, TERM_REF_RE, validateFieldValue } from "./fields.js";
29
+ import type { ListItem } from "./items.js";
30
+ import {
31
+ effectiveMaxDepth,
32
+ LIST_QUERY_MAX_LIMIT,
33
+ type ListTypeDef,
34
+ type NodeDef,
35
+ type PageQuery,
36
+ pageQuerySchema,
37
+ type QueryKnob,
38
+ type SourceDef,
39
+ type SystemSourceName,
40
+ } from "./list-type.js";
41
+ import { parseListRef } from "./refs.js";
42
+ import type {
43
+ ListPageRecord,
44
+ ListRenderData,
45
+ ListRenderDeps,
46
+ LoadedList,
47
+ PluginSourceEntry,
48
+ } from "./render-data.js";
49
+ import {
50
+ bool,
51
+ dateValue,
52
+ EMPTY_LIST,
53
+ EMPTY_TEXT,
54
+ fieldValue,
55
+ linkValue,
56
+ listValue,
57
+ mediaValue,
58
+ positionValue,
59
+ selectFlag,
60
+ text,
61
+ untypedValue,
62
+ type ValueEnv,
63
+ } from "./resolve-values.js";
64
+ import type { ScopeEntry, ScopeValue } from "./scope.js";
65
+
66
+ /** Prefixes of the list failure markers (`docs/lists.md` §8). */
67
+ const LIST_MARKER_PREFIXES = [
68
+ "list-unbound:",
69
+ "list-missing:",
70
+ "list-type-missing:",
71
+ "list-source-unavailable:",
72
+ "list-malformed:",
73
+ "list-context-unavailable:",
74
+ ] as const;
75
+
76
+ /** True for a list failure marker — the editor's missing-content surface shows these. */
77
+ export function isListFailureMarker(marker: string): boolean {
78
+ return LIST_MARKER_PREFIXES.some((p) => marker.startsWith(p));
79
+ }
80
+
81
+ /** What the resolver needs from a module `list` field (`docs/lists.md` §7). */
82
+ export interface ListFieldSpec {
83
+ readonly name: string;
84
+ readonly listType: string;
85
+ readonly bind?: "local" | "shared" | "source";
86
+ readonly list?: string;
87
+ readonly source?: SourceDef;
88
+ readonly default?: unknown;
89
+ }
90
+
91
+ /** Result of resolving one list field for one page. */
92
+ export type ListFieldResolution =
93
+ | { readonly ok: true; readonly entries: readonly ScopeEntry[] }
94
+ | { readonly ok: false; readonly marker: string };
95
+
96
+ /**
97
+ * Where a local list is stored: the content instance holding the module
98
+ * field's value. Null for module field defaults (site chrome renders
99
+ * from defaults) — then entries cannot be addressed by a plugin overlay
100
+ * and carry `local/<field>/<itemId>` as their item attribute.
101
+ */
102
+ export interface ListHolder {
103
+ readonly contentInstanceId: string | null;
104
+ }
105
+
106
+ /** An overlay entry the resolver refused (unknown address, structural or invalid value). */
107
+ export interface ListOverlayIssue {
108
+ readonly variant: string;
109
+ readonly address: string;
110
+ readonly reason: string;
111
+ }
112
+
113
+ /** Per-page options of {@link ListRenderContext.forPage}. */
114
+ export interface ListPageOptions {
115
+ /** The page's link resolver; drives `is_current` URL comparison. */
116
+ readonly linkResolver?: LinkResolver;
117
+ /** Entries of every ACTIVE plugin list source for this page, keyed by source name. */
118
+ readonly pluginSources?: Readonly<Record<string, readonly PluginSourceEntry[]>>;
119
+ }
120
+
121
+ /** The list view of ONE rendering page, handed to the template engine. */
122
+ export interface ListPageContext {
123
+ /** The rendering page, or null for a render outside any page. */
124
+ readonly pageId: string | null;
125
+ /** Resolve a module list field with its placement value. */
126
+ resolveField(field: ListFieldSpec, value: unknown, holder: ListHolder): ListFieldResolution;
127
+ /** `{{page.*}}`: the rendering page as a record scope; null outside a page. */
128
+ pageScope(): ScopeValue | null;
129
+ /**
130
+ * `{{site_text.<key>}}`: the site texts (entries of every list of type
131
+ * {@link SITE_TEXTS_LIST_TYPE}) in the rendering page's variant. An
132
+ * unknown or duplicate key resolves to a failure marker (#1142).
133
+ */
134
+ siteTextScope(): ScopeValue;
135
+ /**
136
+ * The page ids of the breadcrumb trail this render showed (home →
137
+ * current), or null when no `breadcrumbs` source rendered. Drives the
138
+ * `BreadcrumbList` JSON-LD, so structured data always matches the
139
+ * visible trail (#1142).
140
+ */
141
+ breadcrumbTrail(): readonly string[] | null;
142
+ /**
143
+ * A section name no module field declares, read as a plugin list
144
+ * source (`{{#language_links}}` written before the field kind existed).
145
+ * Null when no installed plugin declares the name.
146
+ */
147
+ pluginSource(name: string): ListFieldResolution | null;
148
+ /** What this page's render read so far. */
149
+ deps(): ListRenderDeps;
150
+ /** Overlay values this page's render refused (also the render-wide ones). */
151
+ overlayIssues(): readonly ListOverlayIssue[];
152
+ }
153
+
154
+ /** Render-wide options of {@link createListRenderContext}. */
155
+ export interface ListRenderContextOptions {
156
+ /** Source names declared by installed-but-inactive plugins → plugin slug. */
157
+ readonly dormantPluginSources?: Readonly<Record<string, string>>;
158
+ }
159
+
160
+ /** The render-wide list context; cut per page with {@link ListRenderContext.forPage}. */
161
+ export interface ListRenderContext {
162
+ forPage(pageId: string | null, opts?: ListPageOptions): ListPageContext;
163
+ /** Overlay entries refused while preparing the render (shared lists, page props). */
164
+ readonly overlayIssues: readonly ListOverlayIssue[];
165
+ }
166
+
167
+ /** Resolve one list field for a page (thin wrapper, `docs/lists.md` §10). */
168
+ export function resolveListField(
169
+ field: ListFieldSpec,
170
+ value: unknown,
171
+ page: ListPageContext,
172
+ holder: ListHolder = { contentInstanceId: null },
173
+ ): ListFieldResolution {
174
+ return page.resolveField(field, value, holder);
175
+ }
176
+
177
+ // ─── Overlays ────────────────────────────────────────────────────────
178
+
179
+ const SEG = "[a-z0-9_-]+";
180
+ const LIST_VALUE_RE = new RegExp(`^list/(${SEG})/item/(${SEG})/(${SEG})$`);
181
+ const INSTANCE_VALUE_RE = new RegExp(`^instance/(${SEG})/(${SEG})/item/(${SEG})/(${SEG})$`);
182
+ const PAGE_PROP_RE = new RegExp(`^page/(${SEG})/props/(${SEG})$`);
183
+ const LIST_HIDDEN_RE = new RegExp(`^list/(${SEG})/item/(${SEG})$`);
184
+ const INSTANCE_HIDDEN_RE = new RegExp(`^instance/(${SEG})/(${SEG})/item/(${SEG})$`);
185
+
186
+ /** One variant's overlay, validated where the render data allows it up front. */
187
+ interface PreparedOverlay {
188
+ /** `<listId>/<itemId>` → field → value. */
189
+ readonly listValues: Map<string, Map<string, unknown>>;
190
+ readonly listHidden: Set<string>;
191
+ /** `<ciId>/<field>/<itemId>` → item field → value (validated when the field renders). */
192
+ readonly instanceValues: Map<string, Map<string, unknown>>;
193
+ readonly instanceHidden: Set<string>;
194
+ /** pageId → property → value. */
195
+ readonly pageProps: Map<string, Map<string, unknown>>;
196
+ }
197
+
198
+ /** Field types an overlay may not replace: they change structure, not text. */
199
+ const STRUCTURAL_TYPES = new Set(["module", "term"]);
200
+
201
+ function setIn(m: Map<string, Map<string, unknown>>, k: string, f: string, v: unknown): void {
202
+ const inner = m.get(k) ?? new Map<string, unknown>();
203
+ inner.set(f, v);
204
+ m.set(k, inner);
205
+ }
206
+
207
+ function prepareOverlays(
208
+ data: ListRenderData,
209
+ issues: ListOverlayIssue[],
210
+ ): Map<string, PreparedOverlay> {
211
+ const out = new Map<string, PreparedOverlay>();
212
+ if (!data.overlays) return out;
213
+ const itemIndex = new Map<string, { list: LoadedList; item: ListItem }>();
214
+ for (const list of data.lists.values()) {
215
+ const go = (items: readonly ListItem[]): void => {
216
+ for (const it of items) {
217
+ itemIndex.set(`${list.id}/${it.id}`, { list, item: it });
218
+ if (it.children) go(it.children);
219
+ }
220
+ };
221
+ go(list.items);
222
+ }
223
+ for (const [variant, overlay] of data.overlays) {
224
+ const prepared: PreparedOverlay = {
225
+ listValues: new Map(),
226
+ listHidden: new Set(),
227
+ instanceValues: new Map(),
228
+ instanceHidden: new Set(),
229
+ pageProps: new Map(),
230
+ };
231
+ const refuse = (address: string, reason: string): void => {
232
+ issues.push({ variant, address, reason });
233
+ };
234
+ for (const [address, value] of overlay.values) {
235
+ const lm = LIST_VALUE_RE.exec(address);
236
+ if (lm) {
237
+ const [, listId, itemId, fieldName] = lm as unknown as [string, string, string, string];
238
+ const hit = itemIndex.get(`${listId}/${itemId}`);
239
+ if (!hit) {
240
+ refuse(address, "no such list entry");
241
+ continue;
242
+ }
243
+ const field = fieldOf(data, hit.list.typeSlug, hit.item.node, fieldName);
244
+ const problem = overlayValueProblem(field, value, address);
245
+ if (problem) refuse(address, problem);
246
+ else setIn(prepared.listValues, `${listId}/${itemId}`, fieldName, value);
247
+ continue;
248
+ }
249
+ const im = INSTANCE_VALUE_RE.exec(address);
250
+ if (im) {
251
+ const [, ci, f, itemId, itemField] = im as unknown as [
252
+ string,
253
+ string,
254
+ string,
255
+ string,
256
+ string,
257
+ ];
258
+ setIn(prepared.instanceValues, `${ci}/${f}/${itemId}`, itemField, value);
259
+ continue;
260
+ }
261
+ const pm = PAGE_PROP_RE.exec(address);
262
+ if (pm) {
263
+ const [, pageId, prop] = pm as unknown as [string, string, string];
264
+ const page = data.pages.get(pageId);
265
+ const def = page
266
+ ? data.pageTypes.get(page.pageTypeSlug)?.def.properties.find((p) => p.name === prop)
267
+ : undefined;
268
+ if (!page) refuse(address, "no such page");
269
+ else {
270
+ const problem = overlayValueProblem(def, value, address);
271
+ if (problem) refuse(address, problem);
272
+ else setIn(prepared.pageProps, pageId, prop, value);
273
+ }
274
+ continue;
275
+ }
276
+ refuse(address, "not a rendered value address");
277
+ }
278
+ for (const address of overlay.hidden) {
279
+ const lh = LIST_HIDDEN_RE.exec(address);
280
+ if (lh) {
281
+ const key = `${lh[1]}/${lh[2]}`;
282
+ if (itemIndex.has(key)) prepared.listHidden.add(key);
283
+ else refuse(address, "no such list entry");
284
+ continue;
285
+ }
286
+ const ih = INSTANCE_HIDDEN_RE.exec(address);
287
+ if (ih) {
288
+ prepared.instanceHidden.add(`${ih[1]}/${ih[2]}/${ih[3]}`);
289
+ continue;
290
+ }
291
+ refuse(address, "not an entry address");
292
+ }
293
+ out.set(variant, prepared);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ function fieldOf(
299
+ data: ListRenderData,
300
+ typeSlug: string,
301
+ node: string,
302
+ fieldName: string,
303
+ ): FieldDef | undefined {
304
+ return data.listTypes.get(typeSlug)?.def.nodes[node]?.fields.find((f) => f.name === fieldName);
305
+ }
306
+
307
+ /** Why an overlay value cannot replace a stored one; null when it can. */
308
+ function overlayValueProblem(
309
+ field: FieldDef | undefined,
310
+ value: unknown,
311
+ address: string,
312
+ ): string | null {
313
+ if (!field) return "the entry's node declares no such field";
314
+ if (STRUCTURAL_TYPES.has(field.type)) {
315
+ return `a ${field.type} field changes structure, an overlay may only replace values`;
316
+ }
317
+ const bad = validateFieldValue({ ...field, required: false }, value, address);
318
+ return bad.length > 0 ? (bad[0]?.message ?? "invalid value") : null;
319
+ }
320
+
321
+ // ─── Context ─────────────────────────────────────────────────────────
322
+
323
+ /** Build the render-wide list context from loaded data. */
324
+ export function createListRenderContext(
325
+ data: ListRenderData,
326
+ opts: ListRenderContextOptions = {},
327
+ ): ListRenderContext {
328
+ const overlayIssues: ListOverlayIssue[] = [];
329
+ const overlays = prepareOverlays(data, overlayIssues);
330
+ const dateFormat = makeDateFormatter(data.siteLanguage);
331
+ const collator = new Intl.Collator(data.siteLanguage);
332
+ const pageList = [...data.pages.values()];
333
+ const listItemIndex = new Map<string, Map<string, { item: ListItem; depth: number }>>();
334
+ const itemsOf = (list: LoadedList): Map<string, { item: ListItem; depth: number }> => {
335
+ let idx = listItemIndex.get(list.id);
336
+ if (!idx) {
337
+ idx = new Map();
338
+ const go = (items: readonly ListItem[], depth: number): void => {
339
+ for (const it of items) {
340
+ idx?.set(it.id, { item: it, depth });
341
+ if (it.children) go(it.children, depth + 1);
342
+ }
343
+ };
344
+ go(list.items, 1);
345
+ listItemIndex.set(list.id, idx);
346
+ }
347
+ return idx;
348
+ };
349
+ const shared: SharedState = {
350
+ data,
351
+ overlays,
352
+ dateFormat,
353
+ collator,
354
+ pageList,
355
+ itemsOf,
356
+ dormant: opts.dormantPluginSources ?? {},
357
+ };
358
+ return {
359
+ overlayIssues,
360
+ forPage: (pageId, pageOpts = {}) => createPageContext(shared, pageId, pageOpts, overlayIssues),
361
+ };
362
+ }
363
+
364
+ interface SharedState {
365
+ readonly data: ListRenderData;
366
+ readonly overlays: ReadonlyMap<string, PreparedOverlay>;
367
+ readonly dateFormat: (iso: string) => string;
368
+ readonly collator: Intl.Collator;
369
+ readonly pageList: readonly ListPageRecord[];
370
+ readonly itemsOf: (list: LoadedList) => Map<string, { item: ListItem; depth: number }>;
371
+ readonly dormant: Readonly<Record<string, string>>;
372
+ }
373
+
374
+ function makeDateFormatter(language: string): (iso: string) => string {
375
+ let fmt: Intl.DateTimeFormat;
376
+ try {
377
+ fmt = new Intl.DateTimeFormat(language, { dateStyle: "long", timeZone: "UTC" });
378
+ } catch {
379
+ // An invalid BCP-47 tag in site_settings is caught on write; the
380
+ // render must not crash on stored data, so format in the root locale.
381
+ fmt = new Intl.DateTimeFormat("und", { dateStyle: "long", timeZone: "UTC" });
382
+ }
383
+ const cache = new Map<string, string>();
384
+ return (iso) => {
385
+ let out = cache.get(iso);
386
+ if (out === undefined) {
387
+ const t = Date.parse(iso);
388
+ out = Number.isNaN(t) ? iso : fmt.format(new Date(t));
389
+ cache.set(iso, out);
390
+ }
391
+ return out;
392
+ };
393
+ }
394
+
395
+ /** Where a stored tree lives — decides item attributes, overlays and markers. */
396
+ interface TreeCtx {
397
+ readonly type: ListTypeDef;
398
+ /** The module field (for markers) or the vocabulary slug for term entries. */
399
+ readonly field: string;
400
+ /** Prefix of `data-caelo-list-item` values; null = entries carry none. */
401
+ readonly attrPrefix: string;
402
+ /** Overlay values for an entry id. */
403
+ readonly overlayValues: (itemId: string) => ReadonlyMap<string, unknown> | undefined;
404
+ readonly hidden: (itemId: string) => boolean;
405
+ /** Validate an overlay value lazily (instance overlays); returns the value to use. */
406
+ readonly checkOverlay?: (item: ListItem, field: FieldDef, value: unknown) => boolean;
407
+ /** Term entries alias `link` to `landing_page`. */
408
+ readonly termLink: boolean;
409
+ }
410
+
411
+ interface Position {
412
+ readonly index: number;
413
+ readonly count: number;
414
+ readonly depth: number;
415
+ }
416
+
417
+ function createPageContext(
418
+ s: SharedState,
419
+ pageId: string | null,
420
+ opts: ListPageOptions,
421
+ renderIssues: readonly ListOverlayIssue[],
422
+ ): ListPageContext {
423
+ const { data } = s;
424
+ const current = pageId === null ? null : (data.pages.get(pageId) ?? null);
425
+ const variant = pageId === null ? null : (data.pageVariants?.get(pageId)?.variant ?? null);
426
+ const overlay = variant === null ? undefined : s.overlays.get(variant);
427
+ const resolver = opts.linkResolver;
428
+ const pageIssues: ListOverlayIssue[] = [];
429
+ const depLists = new Set<string>();
430
+ const depTypes = new Set<string>();
431
+ const depQueries = new Map<
432
+ string,
433
+ { pageType: string | null; terms: readonly string[]; parent: string | null }
434
+ >();
435
+ let depTree = false;
436
+ const depPages = new Set<string>();
437
+ let trail: readonly string[] | null = null;
438
+ /** Entries of a page-shaped system source: their pages become deps. */
439
+ const readPages = (pages: ListPageRecord[]): ListPageRecord[] => {
440
+ for (const pg of pages) depPages.add(pg.id);
441
+ return pages;
442
+ };
443
+
444
+ const urlOf = (href: string): string | null => {
445
+ const ref = parseLinkRef(href);
446
+ if (ref === null) return href.startsWith("/") ? normaliseUrl(href) : null;
447
+ if (!resolver) return null;
448
+ const r = resolver.resolve(ref);
449
+ return r.status === "resolved" || r.status === "unpublished" ? normaliseUrl(r.url) : null;
450
+ };
451
+ let currentUrlMemo: string | null | undefined;
452
+ const currentUrl = (): string | null => {
453
+ if (currentUrlMemo === undefined) {
454
+ currentUrlMemo = pageId === null ? null : urlOf(`caelo:page/${pageId}`);
455
+ }
456
+ return currentUrlMemo;
457
+ };
458
+ /** True when `href` points at the rendering page. */
459
+ const isCurrentHref = (href: string): boolean => {
460
+ if (href.length === 0 || pageId === null) return false;
461
+ const ref = parseLinkRef(href);
462
+ if (ref?.kind === "page" && ref.pageId === pageId) return true;
463
+ if (ref?.kind === "media") return false;
464
+ const here = currentUrl();
465
+ return here !== null && urlOf(href) === here;
466
+ };
467
+ /** True when the rendering page sits below page `ancestorId` in the page tree. */
468
+ const isPageAncestor = (ancestorId: string): boolean => {
469
+ let p = current?.parentId ?? null;
470
+ const seen = new Set<string>();
471
+ while (p !== null && !seen.has(p)) {
472
+ if (p === ancestorId) return true;
473
+ seen.add(p);
474
+ p = data.pages.get(p)?.parentId ?? null;
475
+ }
476
+ return false;
477
+ };
478
+
479
+ const valueEnv: ValueEnv = {
480
+ data,
481
+ formatDate: s.dateFormat,
482
+ termEntries: (refs, field) => termEntries(refs, field),
483
+ };
484
+
485
+ // ── Stored trees ──
486
+
487
+ /** Structural check of a stored tree before any entry renders. */
488
+ const checkTree = (
489
+ type: ListTypeDef,
490
+ items: unknown,
491
+ field: string,
492
+ ): { ok: true; items: readonly ListItem[] } | { ok: false; marker: string } => {
493
+ if (!Array.isArray(items)) return { ok: false, marker: `list-malformed:${field}[items]` };
494
+ const max = effectiveMaxDepth(type);
495
+ const walk = (level: readonly unknown[], depth: number, path: string): string | null => {
496
+ for (let i = 0; i < level.length; i += 1) {
497
+ const it = level[i] as Partial<ListItem> | null;
498
+ const at = `${path}[${i}]`;
499
+ if (
500
+ typeof it !== "object" ||
501
+ it === null ||
502
+ typeof it.id !== "string" ||
503
+ typeof it.node !== "string" ||
504
+ typeof it.values !== "object" ||
505
+ it.values === null
506
+ ) {
507
+ return at;
508
+ }
509
+ if (!type.nodes[it.node]) return `${at}.node`;
510
+ if (depth > max) return at;
511
+ if (it.children !== undefined) {
512
+ if (!Array.isArray(it.children)) return `${at}.children`;
513
+ const bad = walk(it.children, depth + 1, `${at}.children`);
514
+ if (bad) return bad;
515
+ }
516
+ }
517
+ return null;
518
+ };
519
+ const bad = walk(items, 1, "items");
520
+ return bad === null
521
+ ? { ok: true, items: items as ListItem[] }
522
+ : { ok: false, marker: `list-malformed:${field}[${bad}]` };
523
+ };
524
+
525
+ const entriesOf = (items: readonly ListItem[], tree: TreeCtx, depth: number): ScopeEntry[] => {
526
+ const visible = items.filter((it) => !tree.hidden(it.id));
527
+ return visible.map((it, index) =>
528
+ storedEntry(it, tree, { index, count: visible.length, depth }),
529
+ );
530
+ };
531
+
532
+ const storedEntry = (item: ListItem, tree: TreeCtx, pos: Position): ScopeEntry => {
533
+ const node = tree.type.nodes[item.node] as NodeDef;
534
+ const overlayValues = tree.overlayValues(item.id);
535
+ const memo = new Map<string, ScopeValue | undefined>();
536
+ const storedValue = (f: FieldDef): unknown => {
537
+ if (overlayValues?.has(f.name)) {
538
+ const v = overlayValues.get(f.name);
539
+ if (!tree.checkOverlay || tree.checkOverlay(item, f, v)) return v;
540
+ }
541
+ return item.values[f.name];
542
+ };
543
+ const children = (): ScopeEntry[] =>
544
+ item.children ? entriesOf(item.children, tree, pos.depth + 1) : [];
545
+ let isCurrentMemo: boolean | undefined;
546
+ const isCurrent = (): boolean => {
547
+ if (isCurrentMemo === undefined) {
548
+ const f = node.current_from ? node.fields.find((x) => x.name === node.current_from) : null;
549
+ const v = f ? storedValue(f) : undefined;
550
+ const href =
551
+ typeof (v as { href?: unknown })?.href === "string" ? (v as { href: string }).href : "";
552
+ isCurrentMemo = isCurrentHref(href);
553
+ }
554
+ return isCurrentMemo;
555
+ };
556
+ const isAncestor = (): boolean => {
557
+ const f = node.current_from ? node.fields.find((x) => x.name === node.current_from) : null;
558
+ const v = f ? storedValue(f) : undefined;
559
+ const href =
560
+ typeof (v as { href?: unknown })?.href === "string" ? (v as { href: string }).href : "";
561
+ const ref = href ? parseLinkRef(href) : null;
562
+ if (ref?.kind === "page" && ref.pageId !== pageId && isPageAncestor(ref.pageId)) return true;
563
+ const anyBelow = (entries: readonly ScopeEntry[]): boolean =>
564
+ entries.some((e) => {
565
+ const c = e.get("is_current");
566
+ const a = e.get("is_ancestor");
567
+ return (c?.kind === "bool" && c.value) || (a?.kind === "bool" && a.value);
568
+ });
569
+ return anyBelow(children());
570
+ };
571
+ const items = (): ScopeValue =>
572
+ node.source ? sourceItems(node.source, item, node, tree.field) : EMPTY_LIST;
573
+ const compute = (key: string): ScopeValue | undefined => {
574
+ const field = node.fields.find((f) => f.name === key);
575
+ if (field) return fieldValue(field, storedValue(field), valueEnv);
576
+ if (key === "id") return text(item.id);
577
+ if (key === "node") return text(item.node);
578
+ const p = positionValue(key, pos);
579
+ if (p) return p;
580
+ if (key === "is_current") return bool(node.current_from ? isCurrent() : false);
581
+ // Descendant-based, so a node without its own link (a column) is
582
+ // still an ancestor of the current entry below it.
583
+ if (key === "is_ancestor") return bool(isAncestor());
584
+ const kids = node.children?.name;
585
+ if (kids !== undefined && key === kids) return listValue(children());
586
+ // `items` is reserved: a node without a source simply has none, so
587
+ // a block shared by mixed node kinds needs no `is_<node>` guard.
588
+ if (key === "items") return items();
589
+ if (key.startsWith("has_") || key.startsWith("count_")) {
590
+ const target = key.startsWith("has_") ? key.slice(4) : key.slice(6);
591
+ let n: number | null = null;
592
+ if (kids !== undefined && target === kids) n = children().length;
593
+ else if (target === "items") {
594
+ const v = items();
595
+ n = v.kind === "list" ? v.entries.length : 0;
596
+ }
597
+ if (n === null) return undefined;
598
+ return key.startsWith("has_") ? bool(n > 0) : text(String(n));
599
+ }
600
+ if (key.startsWith("is_") && tree.type.nodes[key.slice(3)]) {
601
+ return bool(key.slice(3) === item.node);
602
+ }
603
+ const flag = selectFlag(node.fields, item.values, key);
604
+ if (flag) return flag;
605
+ if (tree.termLink && key === "link") {
606
+ const lp = node.fields.find((f) => f.name === "landing_page" && f.type === "link");
607
+ return lp ? fieldValue(lp, storedValue(lp), valueEnv) : linkValue({ href: "" }, null);
608
+ }
609
+ return undefined;
610
+ };
611
+ return {
612
+ id: item.id,
613
+ itemAttr: `${tree.attrPrefix}${item.id}`,
614
+ childrenKey: node.children?.name ?? null,
615
+ depth: pos.depth,
616
+ get(key) {
617
+ if (!memo.has(key)) memo.set(key, compute(key));
618
+ return memo.get(key);
619
+ },
620
+ };
621
+ };
622
+
623
+ const sharedTree = (
624
+ list: LoadedList,
625
+ type: ListTypeDef,
626
+ field: string,
627
+ termLink: boolean,
628
+ ): TreeCtx => ({
629
+ type,
630
+ field,
631
+ attrPrefix: `${list.id}/`,
632
+ overlayValues: (itemId) => overlay?.listValues.get(`${list.id}/${itemId}`),
633
+ hidden: (itemId) => overlay?.listHidden.has(`${list.id}/${itemId}`) ?? false,
634
+ termLink,
635
+ });
636
+
637
+ // ── Terms ──
638
+
639
+ const termEntries = (refs: readonly string[], field: string): ScopeValue => {
640
+ const out: ScopeEntry[] = [];
641
+ const found: { list: LoadedList; type: ListTypeDef; item: ListItem; depth: number }[] = [];
642
+ for (const ref of refs) {
643
+ const m = TERM_REF_RE.exec(ref);
644
+ if (!m) return { kind: "failed", marker: `list-malformed:${field}[${ref}]` };
645
+ const [, listId, itemId] = m as unknown as [string, string, string];
646
+ depLists.add(listId);
647
+ const list = data.lists.get(listId);
648
+ if (!list) return { kind: "failed", marker: `list-missing:${listId}` };
649
+ const type = data.listTypes.get(list.typeSlug);
650
+ if (!type) return { kind: "failed", marker: `list-type-missing:${list.typeSlug}` };
651
+ depTypes.add(list.typeSlug);
652
+ const hit = s.itemsOf(list).get(itemId);
653
+ if (!hit) return { kind: "failed", marker: `list-missing:${listId}/item/${itemId}` };
654
+ if (overlay?.listHidden.has(`${listId}/${itemId}`)) continue;
655
+ found.push({ list, type: type.def, item: hit.item, depth: hit.depth });
656
+ }
657
+ found.forEach((t, index) => {
658
+ out.push(
659
+ storedEntry(t.item, sharedTree(t.list, t.type, field, true), {
660
+ index,
661
+ count: found.length,
662
+ depth: t.depth,
663
+ }),
664
+ );
665
+ });
666
+ return listValue(out);
667
+ };
668
+
669
+ // ── Pages ──
670
+
671
+ const propsValue = (p: ListPageRecord): ScopeValue => {
672
+ const defs = data.pageTypes.get(p.pageTypeSlug)?.def.properties ?? [];
673
+ const propOverlay = overlay?.pageProps.get(p.id);
674
+ const memo = new Map<string, ScopeValue | undefined>();
675
+ return {
676
+ kind: "record",
677
+ text: null,
678
+ present: true,
679
+ get(key) {
680
+ if (!memo.has(key)) {
681
+ const def = defs.find((d) => d.name === key);
682
+ const raw = propOverlay?.has(key) ? propOverlay.get(key) : p.properties[key];
683
+ memo.set(key, def ? fieldValue(def, raw, valueEnv) : undefined);
684
+ }
685
+ return memo.get(key);
686
+ },
687
+ };
688
+ };
689
+
690
+ /** `terms.<vocabulary slug>` of a page. */
691
+ const pageTermsValue = (p: ListPageRecord): ScopeValue => ({
692
+ kind: "record",
693
+ text: null,
694
+ present: p.terms.length > 0,
695
+ get(slug) {
696
+ const lists = [...data.lists.values()].filter((l) => l.slug === slug);
697
+ if (lists.length === 0) return undefined;
698
+ const ids = new Set(lists.map((l) => l.id));
699
+ return termEntries(
700
+ p.terms.filter((t) => ids.has(TERM_REF_RE.exec(t)?.[1] ?? "")),
701
+ `terms.${slug}`,
702
+ );
703
+ },
704
+ });
705
+
706
+ const pageValue = (p: ListPageRecord): ((key: string) => ScopeValue | undefined) => {
707
+ const memo = new Map<string, ScopeValue | undefined>();
708
+ const compute = (key: string): ScopeValue | undefined => {
709
+ switch (key) {
710
+ case "id":
711
+ return text(p.id);
712
+ case "title":
713
+ return text(p.title);
714
+ case "short_title":
715
+ return text(p.shortTitle);
716
+ case "link":
717
+ return linkValue({ href: `caelo:page/${p.id}` }, data);
718
+ case "published_at":
719
+ return p.firstPublishedAt === null
720
+ ? dateValue("", valueEnv)
721
+ : dateValue(p.firstPublishedAt, valueEnv);
722
+ case "updated_at":
723
+ return dateValue(p.updatedAt, valueEnv);
724
+ case "summary":
725
+ return text(p.summary ?? "");
726
+ case "image":
727
+ return p.image === null ? mediaValue("", data) : mediaValue(p.image, data);
728
+ case "page_type":
729
+ return text(p.pageTypeSlug);
730
+ case "props":
731
+ return propsValue(p);
732
+ case "terms":
733
+ return pageTermsValue(p);
734
+ case "is_current":
735
+ return bool(p.id === pageId);
736
+ case "is_ancestor":
737
+ return bool(p.id !== pageId && isPageAncestor(p.id));
738
+ default:
739
+ return undefined;
740
+ }
741
+ };
742
+ return (key) => {
743
+ if (!memo.has(key)) memo.set(key, compute(key));
744
+ return memo.get(key);
745
+ };
746
+ };
747
+
748
+ /**
749
+ * Page-shaped entries. `ancestor` overrides `is_ancestor` where the
750
+ * source knows better than the page tree: a breadcrumb trail's home
751
+ * crumb sits above the current page without being its parent.
752
+ */
753
+ const pageEntries = (
754
+ pages: readonly ListPageRecord[],
755
+ ancestor?: (p: ListPageRecord, index: number) => boolean,
756
+ ): ScopeEntry[] =>
757
+ pages.map((p, index) => {
758
+ const get = pageValue(p);
759
+ const pos = { index, count: pages.length, depth: 1 };
760
+ const isAncestor = ancestor ? bool(ancestor(p, index)) : undefined;
761
+ return {
762
+ id: p.id,
763
+ itemAttr: null,
764
+ childrenKey: null,
765
+ depth: 1,
766
+ get: (key) =>
767
+ (key === "is_ancestor" ? isAncestor : undefined) ?? get(key) ?? positionValue(key, pos),
768
+ };
769
+ });
770
+
771
+ /**
772
+ * The home page in the rendering page's variant: the designated home
773
+ * itself, or its version in the variant's `link-resolve` group. Null
774
+ * when no home is designated or the variant has no version of it yet
775
+ * (an untranslated home is absent, never the other language's page).
776
+ */
777
+ const homeOfVariant = (): ListPageRecord | null => {
778
+ if (data.homePageId === null) return null;
779
+ const home = data.pages.get(data.homePageId) ?? null;
780
+ if (home === null || variant === null) return home;
781
+ const hv = data.pageVariants?.get(home.id);
782
+ if (hv === undefined || hv.variant === variant) return home;
783
+ if (hv.group === null) return null;
784
+ for (const [id, v] of data.pageVariants ?? []) {
785
+ if (v.group === hv.group && v.variant === variant) return data.pages.get(id) ?? null;
786
+ }
787
+ return null;
788
+ };
789
+
790
+ const sameVariant = (p: ListPageRecord): boolean =>
791
+ variant === null || data.pageVariants?.get(p.id)?.variant === variant;
792
+
793
+ const runQuery = (q: PageQuery): ScopeValue => {
794
+ const parent = q.parent
795
+ ? ((parseLinkRef(q.parent) as { pageId: string } | null)?.pageId ?? null)
796
+ : null;
797
+ const terms = q.terms ?? [];
798
+ const key = JSON.stringify([q.page_type ?? null, [...terms].sort(), parent]);
799
+ if (!depQueries.has(key)) {
800
+ depQueries.set(key, { pageType: q.page_type ?? null, terms: [...terms].sort(), parent });
801
+ }
802
+ const excludeCurrent = q.exclude_current ?? true;
803
+ const mode = q.terms_mode ?? "any";
804
+ const hits = s.pageList.filter(
805
+ (p) =>
806
+ p.inBuild &&
807
+ sameVariant(p) &&
808
+ (q.page_type === undefined || p.pageTypeSlug === q.page_type) &&
809
+ (parent === null || p.parentId === parent) &&
810
+ (!excludeCurrent || p.id !== pageId) &&
811
+ (terms.length === 0 ||
812
+ (mode === "all"
813
+ ? terms.every((t) => p.terms.includes(t))
814
+ : terms.some((t) => p.terms.includes(t)))),
815
+ );
816
+ hits.sort(pageOrder(q.order, s.collator));
817
+ return listValue(pageEntries(hits.slice(0, q.limit)));
818
+ };
819
+
820
+ const byTitle = (a: ListPageRecord, b: ListPageRecord): number =>
821
+ s.collator.compare(a.title, b.title) || compareIds(a.id, b.id);
822
+
823
+ const systemSource = (
824
+ name: SystemSourceName,
825
+ terms: readonly string[],
826
+ vocabularyIds: readonly string[] | null,
827
+ field: string,
828
+ ): ScopeValue => {
829
+ switch (name) {
830
+ case "breadcrumbs": {
831
+ depTree = true;
832
+ if (!current) return EMPTY_LIST;
833
+ const chain: ListPageRecord[] = [];
834
+ const seen = new Set<string>();
835
+ let p: ListPageRecord | undefined = current;
836
+ while (p && !seen.has(p.id)) {
837
+ chain.unshift(p);
838
+ seen.add(p.id);
839
+ p = p.parentId === null ? undefined : data.pages.get(p.parentId);
840
+ }
841
+ // The home page is no parent in the tree, yet every trail starts
842
+ // there; its short name is the "Start" crumb, translated with it.
843
+ const home = homeOfVariant();
844
+ if (home && !seen.has(home.id)) chain.unshift(home);
845
+ trail = chain.map((pg) => pg.id);
846
+ const last = chain.length - 1;
847
+ return listValue(pageEntries(readPages(chain), (_pg, index) => index < last));
848
+ }
849
+ case "children": {
850
+ depTree = true;
851
+ if (!current) return EMPTY_LIST;
852
+ return listValue(
853
+ pageEntries(
854
+ readPages(
855
+ s.pageList.filter((p) => p.inBuild && p.parentId === current.id).sort(byTitle),
856
+ ),
857
+ ),
858
+ );
859
+ }
860
+ case "siblings": {
861
+ depTree = true;
862
+ if (!current) return EMPTY_LIST;
863
+ return listValue(
864
+ pageEntries(
865
+ readPages(
866
+ s.pageList.filter((p) => p.inBuild && p.parentId === current.parentId).sort(byTitle),
867
+ ),
868
+ ),
869
+ );
870
+ }
871
+ case "page_terms": {
872
+ if (!current) return EMPTY_LIST;
873
+ const ids = vocabularyIds === null ? null : new Set(vocabularyIds);
874
+ for (const id of vocabularyIds ?? []) depLists.add(id);
875
+ return termEntries(
876
+ current.terms.filter((t) => ids === null || ids.has(TERM_REF_RE.exec(t)?.[1] ?? "")),
877
+ field,
878
+ );
879
+ }
880
+ case "term_pages": {
881
+ depTree = true;
882
+ if (terms.length === 0) return EMPTY_LIST;
883
+ const sorted = [...terms].sort();
884
+ const key = JSON.stringify([null, sorted, null]);
885
+ if (!depQueries.has(key))
886
+ depQueries.set(key, { pageType: null, terms: sorted, parent: null });
887
+ const hits = s.pageList
888
+ .filter(
889
+ (p) =>
890
+ p.inBuild &&
891
+ sameVariant(p) &&
892
+ p.id !== pageId &&
893
+ terms.some((t) => p.terms.includes(t)),
894
+ )
895
+ .sort(byTitle);
896
+ return listValue(pageEntries(hits.slice(0, LIST_QUERY_MAX_LIMIT)));
897
+ }
898
+ }
899
+ };
900
+
901
+ const pluginEntries = (name: string): ScopeValue => {
902
+ const entries = opts.pluginSources?.[name];
903
+ if (entries === undefined) {
904
+ const owner = s.dormant[name] ?? "unknown";
905
+ return { kind: "failed", marker: `list-source-unavailable:${name} plugin=${owner}` };
906
+ }
907
+ return listValue(
908
+ entries.map((e, index) => {
909
+ const pos = { index, count: entries.length, depth: 1 };
910
+ return {
911
+ id: e.id,
912
+ itemAttr: null,
913
+ childrenKey: null,
914
+ depth: 1,
915
+ get: (key: string) => {
916
+ if (key === "id") return text(e.id);
917
+ if (Object.hasOwn(e.values, key)) return untypedValue(e.values[key]) ?? EMPTY_TEXT;
918
+ return positionValue(key, pos);
919
+ },
920
+ };
921
+ }),
922
+ );
923
+ };
924
+
925
+ /** Merge a source's query defaults with the knobs an entry / placement may set. */
926
+ const mergedQuery = (
927
+ source: Extract<SourceDef, { kind: "query" }>,
928
+ overrides: unknown,
929
+ field: string,
930
+ path: string,
931
+ ): PageQuery | { marker: string } => {
932
+ const merged: Record<string, unknown> = { ...source.defaults };
933
+ if (overrides !== undefined && overrides !== null) {
934
+ if (typeof overrides !== "object" || Array.isArray(overrides)) {
935
+ return { marker: `list-malformed:${field}[${path}]` };
936
+ }
937
+ for (const [k, v] of Object.entries(overrides)) {
938
+ if (!(source.editable as readonly string[]).includes(k as QueryKnob)) {
939
+ return { marker: `list-malformed:${field}[${path}.${k}]` };
940
+ }
941
+ merged[k] = v;
942
+ }
943
+ }
944
+ const parsed = pageQuerySchema.safeParse(merged);
945
+ return parsed.success ? parsed.data : { marker: `list-malformed:${field}[${path}]` };
946
+ };
947
+
948
+ /** Generated entries of a source node (`{{#items}}`). */
949
+ const sourceItems = (
950
+ source: SourceDef,
951
+ item: ListItem,
952
+ node: NodeDef,
953
+ field: string,
954
+ ): ScopeValue => {
955
+ switch (source.kind) {
956
+ case "query": {
957
+ const q = mergedQuery(source, item.query, field, `${item.id}.query`);
958
+ return "marker" in q ? { kind: "failed", marker: q.marker } : runQuery(q);
959
+ }
960
+ case "system": {
961
+ const termFields = node.fields.filter((f) => f.type === "term");
962
+ const terms = termFields.flatMap((f) => {
963
+ const v = item.values[f.name];
964
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
965
+ });
966
+ const vocabSlugs = termFields.map((f) => (f.type === "term" ? f.list : ""));
967
+ const vocabIds =
968
+ vocabSlugs.length === 0
969
+ ? null
970
+ : [...data.lists.values()].filter((l) => vocabSlugs.includes(l.slug)).map((l) => l.id);
971
+ return systemSource(source.name, terms, vocabIds, field);
972
+ }
973
+ case "plugin":
974
+ return pluginEntries(source.name);
975
+ }
976
+ };
977
+
978
+ const fromValue = (v: ScopeValue): ListFieldResolution =>
979
+ v.kind === "failed"
980
+ ? { ok: false, marker: v.marker }
981
+ : { ok: true, entries: v.kind === "list" ? v.entries : [] };
982
+
983
+ const resolveField = (
984
+ field: ListFieldSpec,
985
+ value: unknown,
986
+ holder: ListHolder,
987
+ ): ListFieldResolution => {
988
+ const bind = field.bind ?? "local";
989
+ if (bind === "source") {
990
+ const source = field.source;
991
+ if (!source) return { ok: false, marker: `list-malformed:${field.name}[source]` };
992
+ switch (source.kind) {
993
+ case "query": {
994
+ const q = mergedQuery(source, value, field.name, "query");
995
+ return "marker" in q ? { ok: false, marker: q.marker } : fromValue(runQuery(q));
996
+ }
997
+ case "system": {
998
+ const overrides = (value ?? {}) as { terms?: unknown };
999
+ const terms = Array.isArray(overrides.terms)
1000
+ ? overrides.terms.filter((t): t is string => typeof t === "string")
1001
+ : [];
1002
+ const vocab = field.list === undefined ? null : parseListRef(field.list);
1003
+ return fromValue(
1004
+ systemSource(source.name, terms, vocab === null ? null : [vocab], field.name),
1005
+ );
1006
+ }
1007
+ case "plugin":
1008
+ return fromValue(pluginEntries(source.name));
1009
+ }
1010
+ }
1011
+ const typeRow = data.listTypes.get(field.listType);
1012
+ if (!typeRow) return { ok: false, marker: `list-type-missing:${field.listType}` };
1013
+ depTypes.add(field.listType);
1014
+ const type = typeRow.def;
1015
+ if (bind === "shared") {
1016
+ const raw = field.list ?? (typeof value === "string" ? value : undefined) ?? field.default;
1017
+ if (raw === undefined || raw === null || raw === "") {
1018
+ return { ok: false, marker: `list-unbound:${field.name}` };
1019
+ }
1020
+ const listId = typeof raw === "string" ? parseListRef(raw) : null;
1021
+ if (listId === null) return { ok: false, marker: `list-malformed:${field.name}[list]` };
1022
+ depLists.add(listId);
1023
+ const list = data.lists.get(listId);
1024
+ if (!list) return { ok: false, marker: `list-missing:${listId}` };
1025
+ if (list.typeSlug !== field.listType) {
1026
+ return { ok: false, marker: `list-malformed:${field.name}[listType]` };
1027
+ }
1028
+ const checked = checkTree(type, list.items, field.name);
1029
+ if (!checked.ok) return checked;
1030
+ return {
1031
+ ok: true,
1032
+ entries: entriesOf(checked.items, sharedTree(list, type, field.name, false), 1),
1033
+ };
1034
+ }
1035
+ const raw = value === undefined || value === null ? field.default : value;
1036
+ if (raw === undefined || raw === null) return { ok: true, entries: [] };
1037
+ const checked = checkTree(type, raw, field.name);
1038
+ if (!checked.ok) return checked;
1039
+ const ci = holder.contentInstanceId;
1040
+ const local: TreeCtx = {
1041
+ type,
1042
+ field: field.name,
1043
+ attrPrefix: ci === null ? `local/${field.name}/` : `instance/${ci}/${field.name}/`,
1044
+ overlayValues: (itemId) =>
1045
+ ci === null ? undefined : overlay?.instanceValues.get(`${ci}/${field.name}/${itemId}`),
1046
+ hidden: (itemId) =>
1047
+ ci !== null && (overlay?.instanceHidden.has(`${ci}/${field.name}/${itemId}`) ?? false),
1048
+ checkOverlay: (item, f, v) => {
1049
+ const address = `instance/${ci}/${field.name}/item/${item.id}/${f.name}`;
1050
+ const problem = overlayValueProblem(f, v, address);
1051
+ if (problem && variant !== null && !pageIssues.some((i) => i.address === address)) {
1052
+ pageIssues.push({ variant, address, reason: problem });
1053
+ }
1054
+ return problem === null;
1055
+ },
1056
+ termLink: false,
1057
+ };
1058
+ if (ci !== null && overlay) {
1059
+ // Instance overlay addresses naming an entry or field this list
1060
+ // does not have can only be found here, where the stored entries
1061
+ // are known.
1062
+ const ids = new Set<string>();
1063
+ const fieldsByItem = new Map<string, NodeDef>();
1064
+ const go = (items: readonly ListItem[]): void => {
1065
+ for (const it of items) {
1066
+ ids.add(it.id);
1067
+ fieldsByItem.set(it.id, type.nodes[it.node] as NodeDef);
1068
+ if (it.children) go(it.children);
1069
+ }
1070
+ };
1071
+ go(checked.items);
1072
+ const prefix = `${ci}/${field.name}/`;
1073
+ for (const [key, values] of overlay.instanceValues) {
1074
+ if (!key.startsWith(prefix) || variant === null) continue;
1075
+ const itemId = key.slice(prefix.length);
1076
+ const node = fieldsByItem.get(itemId);
1077
+ for (const f of values.keys()) {
1078
+ const address = `instance/${ci}/${field.name}/item/${itemId}/${f}`;
1079
+ if (!node?.fields.some((x) => x.name === f)) {
1080
+ if (!pageIssues.some((i) => i.address === address)) {
1081
+ pageIssues.push({
1082
+ variant,
1083
+ address,
1084
+ reason: node ? "the entry's node declares no such field" : "no such list entry",
1085
+ });
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ return { ok: true, entries: entriesOf(checked.items, local, 1) };
1092
+ };
1093
+
1094
+ let siteTextMemo: ScopeValue | undefined;
1095
+ /** `{{site_text.<key>}}` — see {@link ListPageContext.siteTextScope}. */
1096
+ const siteTextScope = (): ScopeValue => {
1097
+ if (siteTextMemo !== undefined) return siteTextMemo;
1098
+ depTypes.add(SITE_TEXTS_LIST_TYPE);
1099
+ const byKey = new Map<string, { list: LoadedList; item: ListItem }[]>();
1100
+ for (const list of data.lists.values()) {
1101
+ if (list.typeSlug !== SITE_TEXTS_LIST_TYPE) continue;
1102
+ depLists.add(list.id);
1103
+ for (const item of list.items) {
1104
+ if (overlay?.listHidden.has(`${list.id}/${item.id}`)) continue;
1105
+ const key = item.values.key;
1106
+ if (typeof key !== "string") continue;
1107
+ byKey.set(key, [...(byKey.get(key) ?? []), { list, item }]);
1108
+ }
1109
+ }
1110
+ const memo = new Map<string, ScopeValue>();
1111
+ siteTextMemo = {
1112
+ kind: "record",
1113
+ text: null,
1114
+ present: true,
1115
+ get(key) {
1116
+ const hit = memo.get(key);
1117
+ if (hit) return hit;
1118
+ const found = byKey.get(key) ?? [];
1119
+ let v: ScopeValue;
1120
+ if (found.length === 0) v = { kind: "failed", marker: `site-text-missing:${key}` };
1121
+ else if (found.length > 1) v = { kind: "failed", marker: `site-text-duplicate:${key}` };
1122
+ else {
1123
+ const { list, item } = found[0] as { list: LoadedList; item: ListItem };
1124
+ const translated = overlay?.listValues.get(`${list.id}/${item.id}`)?.get("text");
1125
+ const raw = translated ?? item.values.text;
1126
+ v =
1127
+ typeof raw === "string"
1128
+ ? text(raw)
1129
+ : { kind: "failed", marker: `site-text-missing:${key}` };
1130
+ }
1131
+ memo.set(key, v);
1132
+ return v;
1133
+ },
1134
+ };
1135
+ return siteTextMemo;
1136
+ };
1137
+
1138
+ let pageScopeMemo: ScopeValue | null | undefined;
1139
+ return {
1140
+ pageId,
1141
+ resolveField,
1142
+ siteTextScope,
1143
+ breadcrumbTrail: () => trail,
1144
+ pageScope: () => {
1145
+ if (pageScopeMemo === undefined) {
1146
+ if (current === null) pageScopeMemo = null;
1147
+ else {
1148
+ const get = pageValue(current);
1149
+ pageScopeMemo = { kind: "record", text: null, present: true, get };
1150
+ }
1151
+ }
1152
+ return pageScopeMemo;
1153
+ },
1154
+ pluginSource: (name) => {
1155
+ if (opts.pluginSources?.[name] === undefined && s.dormant[name] === undefined) return null;
1156
+ return fromValue(pluginEntries(name));
1157
+ },
1158
+ deps: () => ({
1159
+ lists: [...depLists].sort(),
1160
+ listTypes: [...depTypes].sort(),
1161
+ queries: [...depQueries.values()],
1162
+ pageTree: depTree,
1163
+ pages: [...depPages].sort(),
1164
+ }),
1165
+ overlayIssues: () => [...renderIssues, ...pageIssues],
1166
+ };
1167
+ }
1168
+
1169
+ function normaliseUrl(url: string): string {
1170
+ const cut = url.split(/[?#]/, 1)[0] ?? url;
1171
+ return cut.length > 1 && cut.endsWith("/") ? cut.slice(0, -1) : cut;
1172
+ }
1173
+
1174
+ function compareIds(a: string, b: string): number {
1175
+ return a < b ? -1 : a > b ? 1 : 0;
1176
+ }
1177
+
1178
+ /** Sort order of a query. Pages without the sort key sort last; ties break by id (deterministic). */
1179
+ function pageOrder(
1180
+ order: PageQuery["order"],
1181
+ collator: Intl.Collator,
1182
+ ): (a: ListPageRecord, b: ListPageRecord) => number {
1183
+ const nullsLast = <T>(
1184
+ a: T | null | undefined,
1185
+ b: T | null | undefined,
1186
+ cmp: (x: T, y: T) => number,
1187
+ ): number => {
1188
+ const an = a === null || a === undefined;
1189
+ const bn = b === null || b === undefined;
1190
+ if (an || bn) return an === bn ? 0 : an ? 1 : -1;
1191
+ return cmp(a as T, b as T);
1192
+ };
1193
+ const cmpValue = (x: unknown, y: unknown): number => {
1194
+ if (typeof x === "number" && typeof y === "number") return x - y;
1195
+ return collator.compare(String(x), String(y));
1196
+ };
1197
+ switch (order) {
1198
+ case "published_desc":
1199
+ return (a, b) =>
1200
+ nullsLast(a.firstPublishedAt, b.firstPublishedAt, (x, y) => compareIds(y, x)) ||
1201
+ compareIds(b.updatedAt, a.updatedAt) ||
1202
+ compareIds(a.id, b.id);
1203
+ case "published_asc":
1204
+ return (a, b) =>
1205
+ nullsLast(a.firstPublishedAt, b.firstPublishedAt, compareIds) ||
1206
+ compareIds(a.updatedAt, b.updatedAt) ||
1207
+ compareIds(a.id, b.id);
1208
+ case "title_asc":
1209
+ return (a, b) => collator.compare(a.title, b.title) || compareIds(a.id, b.id);
1210
+ default: {
1211
+ const desc = order.startsWith("prop_desc:");
1212
+ const prop = order.slice(order.indexOf(":") + 1);
1213
+ return (a, b) =>
1214
+ nullsLast(a.properties[prop], b.properties[prop], (x, y) =>
1215
+ desc ? cmpValue(y, x) : cmpValue(x, y),
1216
+ ) || compareIds(a.id, b.id);
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ /** Every embedded module a set of shared lists references (`module` fields), for batch loading. */
1222
+ export function collectListModuleRefs(
1223
+ data: Pick<ListRenderData, "lists" | "listTypes">,
1224
+ ): { moduleId: string; contentInstanceId: string }[] {
1225
+ const out: { moduleId: string; contentInstanceId: string }[] = [];
1226
+ for (const list of data.lists.values()) {
1227
+ const type = data.listTypes.get(list.typeSlug)?.def;
1228
+ if (!type) continue;
1229
+ const go = (items: readonly ListItem[]): void => {
1230
+ for (const it of items) {
1231
+ for (const f of type.nodes[it.node]?.fields ?? []) {
1232
+ if (f.type !== "module") continue;
1233
+ const v = it.values[f.name] as
1234
+ | { moduleId?: unknown; contentInstanceId?: unknown }
1235
+ | undefined;
1236
+ if (typeof v?.moduleId === "string" && typeof v.contentInstanceId === "string") {
1237
+ out.push({ moduleId: v.moduleId, contentInstanceId: v.contentInstanceId });
1238
+ }
1239
+ }
1240
+ if (it.children) go(it.children);
1241
+ }
1242
+ };
1243
+ go(list.items);
1244
+ }
1245
+ return out;
1246
+ }