@caelo-cms/shared 0.10.72 → 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 (184) 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 +48 -36
  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 +23 -4
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +201 -100
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +176 -74
  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/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/interactions.js +2 -2
  30. package/dist/interactions.js.map +1 -1
  31. package/dist/link-canonicalise.d.ts.map +1 -1
  32. package/dist/link-canonicalise.js +7 -1
  33. package/dist/link-canonicalise.js.map +1 -1
  34. package/dist/link-refs.d.ts +1 -1
  35. package/dist/link-refs.js +3 -3
  36. package/dist/lists/address.d.ts +34 -0
  37. package/dist/lists/address.d.ts.map +1 -0
  38. package/dist/lists/address.js +43 -0
  39. package/dist/lists/address.js.map +1 -0
  40. package/dist/lists/default-types.d.ts +10 -0
  41. package/dist/lists/default-types.d.ts.map +1 -0
  42. package/dist/lists/default-types.js +91 -0
  43. package/dist/lists/default-types.js.map +1 -0
  44. package/dist/lists/fields.d.ts +152 -0
  45. package/dist/lists/fields.d.ts.map +1 -0
  46. package/dist/lists/fields.js +258 -0
  47. package/dist/lists/fields.js.map +1 -0
  48. package/dist/lists/index.d.ts +14 -0
  49. package/dist/lists/index.d.ts.map +1 -0
  50. package/dist/lists/index.js +15 -0
  51. package/dist/lists/index.js.map +1 -0
  52. package/dist/lists/item-attr.d.ts +26 -0
  53. package/dist/lists/item-attr.d.ts.map +1 -0
  54. package/dist/lists/item-attr.js +31 -0
  55. package/dist/lists/item-attr.js.map +1 -0
  56. package/dist/lists/items.d.ts +105 -0
  57. package/dist/lists/items.d.ts.map +1 -0
  58. package/dist/lists/items.js +304 -0
  59. package/dist/lists/items.js.map +1 -0
  60. package/dist/lists/list-type.d.ts +401 -0
  61. package/dist/lists/list-type.d.ts.map +1 -0
  62. package/dist/lists/list-type.js +275 -0
  63. package/dist/lists/list-type.js.map +1 -0
  64. package/dist/lists/model.d.ts +14 -0
  65. package/dist/lists/model.d.ts.map +1 -0
  66. package/dist/lists/model.js +15 -0
  67. package/dist/lists/model.js.map +1 -0
  68. package/dist/lists/page-type.d.ts +115 -0
  69. package/dist/lists/page-type.d.ts.map +1 -0
  70. package/dist/lists/page-type.js +66 -0
  71. package/dist/lists/page-type.js.map +1 -0
  72. package/dist/lists/refs.d.ts +13 -0
  73. package/dist/lists/refs.d.ts.map +1 -0
  74. package/dist/lists/refs.js +18 -0
  75. package/dist/lists/refs.js.map +1 -0
  76. package/dist/lists/render-data.d.ts +114 -0
  77. package/dist/lists/render-data.d.ts.map +1 -0
  78. package/dist/lists/render-data.js +3 -0
  79. package/dist/lists/render-data.js.map +1 -0
  80. package/dist/lists/render-deps.d.ts +68 -0
  81. package/dist/lists/render-deps.d.ts.map +1 -0
  82. package/dist/lists/render-deps.js +91 -0
  83. package/dist/lists/render-deps.js.map +1 -0
  84. package/dist/lists/resolve-values.d.ts +63 -0
  85. package/dist/lists/resolve-values.d.ts.map +1 -0
  86. package/dist/lists/resolve-values.js +181 -0
  87. package/dist/lists/resolve-values.js.map +1 -0
  88. package/dist/lists/resolve.d.ts +106 -0
  89. package/dist/lists/resolve.d.ts.map +1 -0
  90. package/dist/lists/resolve.js +878 -0
  91. package/dist/lists/resolve.js.map +1 -0
  92. package/dist/lists/scope.d.ts +81 -0
  93. package/dist/lists/scope.d.ts.map +1 -0
  94. package/dist/lists/scope.js +3 -0
  95. package/dist/lists/scope.js.map +1 -0
  96. package/dist/markup-structure.d.ts +8 -0
  97. package/dist/markup-structure.d.ts.map +1 -1
  98. package/dist/markup-structure.js +2 -0
  99. package/dist/markup-structure.js.map +1 -1
  100. package/dist/media.d.ts +1 -1
  101. package/dist/nested-module-render.d.ts +25 -20
  102. package/dist/nested-module-render.d.ts.map +1 -1
  103. package/dist/nested-module-render.js +122 -31
  104. package/dist/nested-module-render.js.map +1 -1
  105. package/dist/page-log.d.ts +1 -1
  106. package/dist/preview-compose.d.ts +24 -21
  107. package/dist/preview-compose.d.ts.map +1 -1
  108. package/dist/preview-compose.js +34 -118
  109. package/dist/preview-compose.js.map +1 -1
  110. package/dist/source-text-transport.d.ts +17 -0
  111. package/dist/source-text-transport.d.ts.map +1 -0
  112. package/dist/source-text-transport.js +71 -0
  113. package/dist/source-text-transport.js.map +1 -0
  114. package/dist/template-engine.d.ts +44 -31
  115. package/dist/template-engine.d.ts.map +1 -1
  116. package/dist/template-engine.js +75 -174
  117. package/dist/template-engine.js.map +1 -1
  118. package/dist/template-sections.d.ts +69 -0
  119. package/dist/template-sections.d.ts.map +1 -0
  120. package/dist/template-sections.js +423 -0
  121. package/dist/template-sections.js.map +1 -0
  122. package/dist/version.d.ts +2 -2
  123. package/dist/version.d.ts.map +1 -1
  124. package/dist/version.js +1 -1
  125. package/dist/version.js.map +1 -1
  126. package/package.json +8 -1
  127. package/src/__tests__/redos-hardening.test.ts +50 -16
  128. package/src/ai-tools-add-module-modes.test.ts +50 -0
  129. package/src/ai-tools.test.ts +10 -0
  130. package/src/ai-tools.ts +51 -37
  131. package/src/build-page.test.ts +34 -3
  132. package/src/build-page.ts +26 -3
  133. package/src/content.test.ts +111 -3
  134. package/src/content.ts +186 -75
  135. package/src/css-var-aliases.test.ts +61 -0
  136. package/src/css-var-aliases.ts +109 -0
  137. package/src/css-var-scan.ts +9 -0
  138. package/src/edit-markers.test.ts +37 -21
  139. package/src/edit-markers.ts +52 -20
  140. package/src/index.ts +3 -1
  141. package/src/interactions.ts +2 -2
  142. package/src/link-canonicalise.test.ts +36 -6
  143. package/src/link-canonicalise.ts +6 -1
  144. package/src/link-refs.test.ts +66 -42
  145. package/src/link-refs.ts +3 -3
  146. package/src/lists/address.ts +75 -0
  147. package/src/lists/default-types.ts +102 -0
  148. package/src/lists/fields.ts +285 -0
  149. package/src/lists/index.ts +16 -0
  150. package/src/lists/item-attr.test.ts +36 -0
  151. package/src/lists/item-attr.ts +49 -0
  152. package/src/lists/items.ts +402 -0
  153. package/src/lists/list-fixtures.test.ts +302 -0
  154. package/src/lists/list-type.ts +306 -0
  155. package/src/lists/lists.test.ts +328 -0
  156. package/src/lists/model.ts +16 -0
  157. package/src/lists/page-type.ts +71 -0
  158. package/src/lists/refs.ts +21 -0
  159. package/src/lists/render-data.ts +125 -0
  160. package/src/lists/render-deps.test.ts +132 -0
  161. package/src/lists/render-deps.ts +144 -0
  162. package/src/lists/resolve-values.ts +224 -0
  163. package/src/lists/resolve.test.ts +295 -0
  164. package/src/lists/resolve.ts +1148 -0
  165. package/src/lists/scope.ts +69 -0
  166. package/src/lists-compose-parity.test.ts +262 -0
  167. package/src/markup-structure.ts +10 -0
  168. package/src/nested-module-render.test.ts +111 -77
  169. package/src/nested-module-render.ts +85 -48
  170. package/src/preview-compose.test.ts +55 -19
  171. package/src/preview-compose.ts +59 -154
  172. package/src/source-text-transport.test.ts +63 -0
  173. package/src/source-text-transport.ts +70 -0
  174. package/src/template-engine-lists.test.ts +553 -0
  175. package/src/template-engine.test.ts +62 -80
  176. package/src/template-engine.ts +102 -245
  177. package/src/template-sections.ts +500 -0
  178. package/src/version.ts +1 -1
  179. package/dist/structured-sets.d.ts +0 -70
  180. package/dist/structured-sets.d.ts.map +0 -1
  181. package/dist/structured-sets.js +0 -92
  182. package/dist/structured-sets.js.map +0 -1
  183. package/src/structured-sets.ts +0 -114
  184. package/src/template-engine-data-lists.test.ts +0 -90
@@ -0,0 +1,224 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Field values as template scopes (epic #1107, `docs/lists.md` §2): how
5
+ * a stored value of each field type reads in a template. Shared by list
6
+ * entries, page properties (`{{page.props.x}}`, `{{props.x}}` on query
7
+ * results) and plugin source entries, so every value renders the same
8
+ * way wherever it comes from.
9
+ *
10
+ * Internal references stay references here (`caelo:page/<id>`,
11
+ * `caelo:media/<id>`): the template engine resolves every reference in
12
+ * the finished HTML through the page's link resolver, so a link inside a
13
+ * list entry follows a page move exactly like a link in module HTML.
14
+ */
15
+
16
+ import { parseLinkRef } from "../link-refs.js";
17
+ import type { FieldDef } from "./fields.js";
18
+ import type { ListRenderData } from "./render-data.js";
19
+ import type { ScopeEntry, ScopeValue } from "./scope.js";
20
+
21
+ export const EMPTY_TEXT: ScopeValue = { kind: "text", text: "" };
22
+ export const FALSE: ScopeValue = { kind: "bool", value: false };
23
+ export const TRUE: ScopeValue = { kind: "bool", value: true };
24
+ export const EMPTY_LIST: ScopeValue = { kind: "list", entries: [] };
25
+
26
+ /** A text scope value. */
27
+ export function text(s: string): ScopeValue {
28
+ return { kind: "text", text: s };
29
+ }
30
+
31
+ /** A boolean scope value. */
32
+ export function bool(v: boolean): ScopeValue {
33
+ return v ? TRUE : FALSE;
34
+ }
35
+
36
+ /** A record scope value over a fixed key map. */
37
+ export function record(
38
+ bare: string | null,
39
+ present: boolean,
40
+ parts: Readonly<Record<string, ScopeValue>>,
41
+ ): ScopeValue {
42
+ return {
43
+ kind: "record",
44
+ text: bare,
45
+ present,
46
+ get: (key) => (Object.hasOwn(parts, key) ? parts[key] : undefined),
47
+ };
48
+ }
49
+
50
+ /** What value scopes need from the render: data plus lazy hooks into the page context. */
51
+ export interface ValueEnv {
52
+ readonly data: ListRenderData;
53
+ /** Format an ISO date in the site language (cached formatter). */
54
+ formatDate(iso: string): string;
55
+ /**
56
+ * Resolve term references to vocabulary entries. `field` names the
57
+ * field for markers; the vocabulary lists are recorded as deps.
58
+ */
59
+ termEntries(refs: readonly string[], field: string): ScopeValue;
60
+ }
61
+
62
+ /** The empty value of a declared field — declared-but-unset never falls through to outer scopes. */
63
+ export function emptyValueOf(field: FieldDef): ScopeValue {
64
+ switch (field.type) {
65
+ case "boolean":
66
+ return FALSE;
67
+ case "link":
68
+ return linkValue({ href: "" }, null);
69
+ case "media":
70
+ return record("", false, {
71
+ url: EMPTY_TEXT,
72
+ alt: EMPTY_TEXT,
73
+ width: EMPTY_TEXT,
74
+ height: EMPTY_TEXT,
75
+ });
76
+ case "date":
77
+ return record("", false, { formatted: EMPTY_TEXT });
78
+ case "term":
79
+ return EMPTY_LIST;
80
+ default:
81
+ return EMPTY_TEXT;
82
+ }
83
+ }
84
+
85
+ /** Scope value of a link: bare = href, plus `href`, `target`, `title` (internal page title). */
86
+ export function linkValue(
87
+ v: { readonly href: string; readonly target?: string },
88
+ data: ListRenderData | null,
89
+ ): ScopeValue {
90
+ const ref = v.href.length > 0 ? parseLinkRef(v.href) : null;
91
+ const title =
92
+ ref?.kind === "page" && data !== null ? (data.pages.get(ref.pageId)?.title ?? "") : "";
93
+ return record(v.href, v.href.length > 0, {
94
+ href: text(v.href),
95
+ target: text(v.target ?? ""),
96
+ title: text(title),
97
+ });
98
+ }
99
+
100
+ /** Scope value of a media reference: bare = url (the reference, resolved later), alt/width/height. */
101
+ export function mediaValue(ref: string, data: ListRenderData): ScopeValue {
102
+ const parsed = parseLinkRef(ref);
103
+ const meta = parsed?.kind === "media" ? data.media.get(parsed.mediaId) : undefined;
104
+ return record(ref, ref.length > 0, {
105
+ url: text(ref),
106
+ alt: text(meta?.alt ?? ""),
107
+ width: text(meta?.width === null || meta?.width === undefined ? "" : String(meta.width)),
108
+ height: text(meta?.height === null || meta?.height === undefined ? "" : String(meta.height)),
109
+ });
110
+ }
111
+
112
+ /** Scope value of a date or timestamp: bare = the stored ISO string, `formatted` in the site language. */
113
+ export function dateValue(iso: string, env: Pick<ValueEnv, "formatDate">): ScopeValue {
114
+ return record(iso, iso.length > 0, { formatted: text(env.formatDate(iso)) });
115
+ }
116
+
117
+ /**
118
+ * Scope value of one stored field value. `undefined` / `null` read as the
119
+ * field's empty value; a value of the wrong shape (data drift after a
120
+ * type change that skipped validation) reads as the raw string form when
121
+ * it has one — validation happens on write, rendering never invents data.
122
+ */
123
+ export function fieldValue(field: FieldDef, raw: unknown, env: ValueEnv): ScopeValue {
124
+ if (raw === undefined || raw === null) return emptyValueOf(field);
125
+ switch (field.type) {
126
+ case "text":
127
+ case "richtext":
128
+ case "icon":
129
+ case "select":
130
+ return typeof raw === "string" ? text(raw) : text(String(raw));
131
+ case "number":
132
+ return typeof raw === "number" ? text(String(raw)) : text(String(raw));
133
+ case "boolean":
134
+ return bool(raw === true);
135
+ case "link": {
136
+ if (typeof raw === "object" && typeof (raw as { href?: unknown }).href === "string") {
137
+ return linkValue(raw as { href: string; target?: string }, env.data);
138
+ }
139
+ return typeof raw === "string" ? linkValue({ href: raw }, env.data) : emptyValueOf(field);
140
+ }
141
+ case "media":
142
+ return typeof raw === "string" ? mediaValue(raw, env.data) : emptyValueOf(field);
143
+ case "date":
144
+ return typeof raw === "string" ? dateValue(raw, env) : emptyValueOf(field);
145
+ case "module": {
146
+ const r = raw as { moduleId?: unknown; contentInstanceId?: unknown };
147
+ if (typeof r.moduleId === "string" && typeof r.contentInstanceId === "string") {
148
+ return {
149
+ kind: "module",
150
+ ref: { moduleId: r.moduleId, contentInstanceId: r.contentInstanceId },
151
+ };
152
+ }
153
+ return { kind: "failed", marker: `list-malformed:${field.name}[module]` };
154
+ }
155
+ case "term":
156
+ return Array.isArray(raw)
157
+ ? env.termEntries(
158
+ raw.filter((x): x is string => typeof x === "string"),
159
+ field.name,
160
+ )
161
+ : EMPTY_LIST;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Lookup for a `select` field's option flag: `{{#f_<option>}}` is true
167
+ * when `f` holds `<option>`. Returns undefined when `key` is no option
168
+ * flag of any select field.
169
+ */
170
+ export function selectFlag(
171
+ fields: readonly FieldDef[],
172
+ values: Readonly<Record<string, unknown>>,
173
+ key: string,
174
+ ): ScopeValue | undefined {
175
+ for (const f of fields) {
176
+ if (f.type !== "select" || !key.startsWith(`${f.name}_`)) continue;
177
+ const option = key.slice(f.name.length + 1);
178
+ if (f.options.includes(option)) return bool(values[f.name] === option);
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ /**
184
+ * Generic scope of an untyped value map (plugin source entries, whose
185
+ * node definition the host does not hand over yet): strings and numbers
186
+ * as text, booleans as flags, `{ href }` objects as links.
187
+ */
188
+ export function untypedValue(raw: unknown): ScopeValue | undefined {
189
+ if (typeof raw === "string") return text(raw);
190
+ if (typeof raw === "number" && Number.isFinite(raw)) return text(String(raw));
191
+ if (typeof raw === "boolean") return bool(raw);
192
+ if (
193
+ typeof raw === "object" &&
194
+ raw !== null &&
195
+ typeof (raw as { href?: unknown }).href === "string"
196
+ ) {
197
+ return linkValue(raw as { href: string; target?: string }, null);
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ /** Position keys every entry carries: `index` (0-based), `first`, `last`, `depth` (1-based). */
203
+ export function positionValue(
204
+ key: string,
205
+ pos: { readonly index: number; readonly count: number; readonly depth: number },
206
+ ): ScopeValue | undefined {
207
+ switch (key) {
208
+ case "index":
209
+ return text(String(pos.index));
210
+ case "first":
211
+ return bool(pos.index === 0);
212
+ case "last":
213
+ return bool(pos.index === pos.count - 1);
214
+ case "depth":
215
+ return text(String(pos.depth));
216
+ default:
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ /** A list of entries as a scope value, with `has_`/`count_` helpers derived by the caller. */
222
+ export function listValue(entries: readonly ScopeEntry[]): ScopeValue {
223
+ return { kind: "list", entries };
224
+ }
@@ -0,0 +1,295 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The list resolver (epic #1107, `docs/lists.md` §6, §9, §10): every
5
+ * source kind, query knobs, variant scoping, overlays (replace, hide,
6
+ * refused changes) and the render dependencies a page records.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import type { LinkPageVariant } from "../link-refs.js";
11
+ import { fixtureData, fixtureLinkIndex, ITEM, LIST, PAGE, TERM } from "./list-fixtures.test.js";
12
+ import type { QueryKnob } from "./list-type.js";
13
+ import type { ListVariantOverlay } from "./render-data.js";
14
+ import {
15
+ createListRenderContext,
16
+ type ListFieldSpec,
17
+ type ListPageContext,
18
+ type ListRenderContextOptions,
19
+ } from "./resolve.js";
20
+ import type { ScopeEntry } from "./scope.js";
21
+
22
+ function page(
23
+ pageId: string | null,
24
+ data = fixtureData(),
25
+ opts: ListRenderContextOptions = {},
26
+ ): ListPageContext {
27
+ return createListRenderContext(data, opts).forPage(pageId, {
28
+ linkResolver: fixtureLinkIndex(data.pageVariants).forPage(pageId),
29
+ });
30
+ }
31
+
32
+ function entries(
33
+ ctx: ListPageContext,
34
+ field: ListFieldSpec,
35
+ value?: unknown,
36
+ ): readonly ScopeEntry[] {
37
+ const r = ctx.resolveField(field, value, { contentInstanceId: null });
38
+ if (!r.ok) throw new Error(r.marker);
39
+ return r.entries;
40
+ }
41
+
42
+ const textOf = (e: ScopeEntry, key: string): string => {
43
+ const v = e.get(key);
44
+ if (v?.kind === "text") return v.text;
45
+ if (v?.kind === "record") return v.text ?? "";
46
+ throw new Error(`${key} is ${v?.kind}`);
47
+ };
48
+
49
+ const query = (defaults: Record<string, unknown>, editable: QueryKnob[] = []): ListFieldSpec => ({
50
+ name: "q",
51
+ listType: "links",
52
+ bind: "source",
53
+ source: { kind: "query", editable, defaults },
54
+ });
55
+
56
+ describe("query sources", () => {
57
+ it("returns published pages of a type, newest first, excluding drafts", () => {
58
+ const got = entries(
59
+ page(PAGE.home),
60
+ query({ page_type: "news", order: "published_desc", limit: 10 }),
61
+ );
62
+ expect(got.map((e) => textOf(e, "title"))).toEqual(["Meetup", "Launch day"]);
63
+ });
64
+
65
+ it("excludes the rendering page by default, includes it with exclude_current: false", () => {
66
+ const base = { page_type: "news", order: "title_asc", limit: 10 };
67
+ expect(entries(page(PAGE.newsA), query(base)).map((e) => e.id)).toEqual([PAGE.newsB]);
68
+ expect(
69
+ entries(page(PAGE.newsA), query({ ...base, exclude_current: false })).map((e) => e.id),
70
+ ).toEqual([PAGE.newsA, PAGE.newsB]);
71
+ });
72
+
73
+ it("filters by terms (any / all) and parent", () => {
74
+ const any = query({ terms: [TERM.release, TERM.event], order: "title_asc", limit: 10 });
75
+ expect(entries(page(PAGE.home), any).map((e) => textOf(e, "title"))).toEqual([
76
+ "Launch day",
77
+ "Meetup",
78
+ ]);
79
+ const all = query({
80
+ terms: [TERM.release, TERM.event],
81
+ terms_mode: "all",
82
+ order: "title_asc",
83
+ limit: 10,
84
+ });
85
+ expect(entries(page(PAGE.home), all)).toEqual([]);
86
+ const kids = query({ parent: `caelo:page/${PAGE.about}`, order: "title_asc", limit: 10 });
87
+ expect(entries(page(PAGE.home), kids).map((e) => textOf(e, "title"))).toEqual(["Team"]);
88
+ });
89
+
90
+ it("orders by a property and applies the limit", () => {
91
+ const q = query({ page_type: "news", order: "prop_desc:rating", limit: 1 });
92
+ expect(entries(page(PAGE.home), q).map((e) => textOf(e, "title"))).toEqual(["Meetup"]);
93
+ });
94
+
95
+ it("applies the placement's overrides only for editable knobs", () => {
96
+ const q = query({ page_type: "news", order: "published_desc", limit: 3 }, ["limit"]);
97
+ expect(entries(page(PAGE.home), q, { limit: 1 })).toHaveLength(1);
98
+ const r = page(PAGE.home).resolveField(q, { page_type: "page" }, { contentInstanceId: null });
99
+ expect(r).toEqual({ ok: false, marker: "list-malformed:q[query.page_type]" });
100
+ });
101
+
102
+ it("an incomplete query (no order / limit) is malformed, never guessed", () => {
103
+ const r = page(PAGE.home).resolveField(query({ page_type: "news" }), undefined, {
104
+ contentInstanceId: null,
105
+ });
106
+ expect(r).toEqual({ ok: false, marker: "list-malformed:q[query]" });
107
+ });
108
+
109
+ it("returns only pages of the rendering page's variant", () => {
110
+ const pageVariants = new Map<string, LinkPageVariant>([
111
+ [PAGE.home, { variant: "en", group: null }],
112
+ [PAGE.newsA, { variant: "en", group: null }],
113
+ [PAGE.newsB, { variant: "de", group: null }],
114
+ ]);
115
+ const q = query({ page_type: "news", order: "title_asc", limit: 10 });
116
+ const got = entries(page(PAGE.home, fixtureData({ pageVariants })), q);
117
+ expect(got.map((e) => e.id)).toEqual([PAGE.newsA]);
118
+ });
119
+ });
120
+
121
+ describe("system sources", () => {
122
+ const sys = (name: "breadcrumbs" | "children" | "siblings"): ListFieldSpec => ({
123
+ name,
124
+ listType: "links",
125
+ bind: "source",
126
+ source: { kind: "system", name },
127
+ });
128
+
129
+ it("breadcrumbs run root → current", () => {
130
+ expect(entries(page(PAGE.team), sys("breadcrumbs")).map((e) => textOf(e, "title"))).toEqual([
131
+ "About",
132
+ "Team",
133
+ ]);
134
+ });
135
+
136
+ it("children are the published children in title order", () => {
137
+ expect(entries(page(PAGE.news), sys("children")).map((e) => textOf(e, "title"))).toEqual([
138
+ "Launch day",
139
+ "Meetup",
140
+ ]);
141
+ });
142
+
143
+ it("siblings include the current page, marked is_current", () => {
144
+ const got = entries(page(PAGE.newsA), sys("siblings"));
145
+ expect(got.map((e) => textOf(e, "title"))).toEqual(["Launch day", "Meetup"]);
146
+ expect(got.map((e) => e.get("is_current"))).toEqual([
147
+ { kind: "bool", value: true },
148
+ { kind: "bool", value: false },
149
+ ]);
150
+ });
151
+
152
+ it("outside any page, page-shaped system sources are empty", () => {
153
+ expect(entries(page(null), sys("breadcrumbs"))).toEqual([]);
154
+ });
155
+ });
156
+
157
+ describe("overlays (list-resolve)", () => {
158
+ const variants = new Map<string, LinkPageVariant>([[PAGE.home, { variant: "de", group: null }]]);
159
+ const HEADER: ListFieldSpec = {
160
+ name: "menu",
161
+ listType: "menu",
162
+ bind: "shared",
163
+ list: `caelo:list/${LIST.header}`,
164
+ };
165
+ const overlay = (
166
+ values: [string, unknown][],
167
+ hidden: string[] = [],
168
+ ): ReadonlyMap<string, ListVariantOverlay> =>
169
+ new Map([["de", { values: new Map(values), hidden: new Set(hidden) }]]);
170
+
171
+ it("replaces values and hides entries for the rendering page's variant", () => {
172
+ const data = fixtureData({
173
+ pageVariants: variants,
174
+ overlays: overlay(
175
+ [[`list/${LIST.header}/item/${ITEM.home}/label`, "Start"]],
176
+ [`list/${LIST.header}/item/${ITEM.newsEntry}`],
177
+ ),
178
+ });
179
+ const got = entries(page(PAGE.home, data), HEADER);
180
+ expect(got.map((e) => textOf(e, "label"))).toEqual(["Start", "About"]);
181
+ expect(got[1]?.get("last")).toEqual({ kind: "bool", value: true });
182
+ // Another variant's page sees the stored list.
183
+ expect(entries(page(PAGE.about, data), HEADER).map((e) => textOf(e, "label"))).toEqual([
184
+ "Home",
185
+ "About",
186
+ "News",
187
+ ]);
188
+ });
189
+
190
+ it("drops and reports unknown addresses, structural and invalid changes", () => {
191
+ const ctx = createListRenderContext(
192
+ fixtureData({
193
+ pageVariants: variants,
194
+ overlays: overlay(
195
+ [
196
+ [`list/${LIST.header}/item/${ITEM.home}/ghost`, "x"],
197
+ [`list/${LIST.header}/item/00000000-0000-4000-8000-999999999999/label`, "x"],
198
+ [`list/${LIST.header}/item/${ITEM.home}/link`, { nope: true }],
199
+ [
200
+ `list/${LIST.mega}/item/${ITEM.promo}/teaser`,
201
+ { moduleId: "x", contentInstanceId: "y" },
202
+ ],
203
+ ["nonsense", "x"],
204
+ ],
205
+ [`list/${LIST.header}/item/00000000-0000-4000-8000-999999999999`],
206
+ ),
207
+ }),
208
+ );
209
+ expect(ctx.overlayIssues.map((i) => i.reason)).toEqual([
210
+ "the entry's node declares no such field",
211
+ "no such list entry",
212
+ expect.stringContaining('"link" must be'),
213
+ "a module field changes structure, an overlay may only replace values",
214
+ "not a rendered value address",
215
+ "no such list entry",
216
+ ]);
217
+ const got = entries(ctx.forPage(PAGE.home), HEADER);
218
+ expect(got.map((e) => textOf(e, "label"))).toEqual(["Home", "About", "News"]);
219
+ });
220
+
221
+ it("replaces local list values by instance address and reports unknown fields", () => {
222
+ const ci = "00000000-0000-4000-8000-000000000999";
223
+ const itemId = "00000000-0000-4000-9000-000000000001";
224
+ const data = fixtureData({
225
+ pageVariants: variants,
226
+ overlays: overlay([
227
+ [`instance/${ci}/bullets/item/${itemId}/text`, "Schnell"],
228
+ [`instance/${ci}/bullets/item/${itemId}/ghost`, "x"],
229
+ ]),
230
+ });
231
+ const ctx = page(PAGE.home, data);
232
+ const r = ctx.resolveField(
233
+ { name: "bullets", listType: "texts" },
234
+ [{ id: itemId, node: "text", values: { text: "Fast" } }],
235
+ { contentInstanceId: ci },
236
+ );
237
+ if (!r.ok) throw new Error(r.marker);
238
+ expect(textOf(r.entries[0] as ScopeEntry, "text")).toBe("Schnell");
239
+ expect(ctx.overlayIssues().map((i) => i.address)).toEqual([
240
+ `instance/${ci}/bullets/item/${itemId}/ghost`,
241
+ ]);
242
+ });
243
+ });
244
+
245
+ describe("render dependencies", () => {
246
+ it("records shared lists, types, queries and page-tree reads", () => {
247
+ const ctx = page(PAGE.team);
248
+ entries(ctx, {
249
+ name: "menu",
250
+ listType: "menu",
251
+ bind: "shared",
252
+ list: `caelo:list/${LIST.header}`,
253
+ });
254
+ entries(ctx, query({ page_type: "news", terms: [TERM.event], order: "title_asc", limit: 3 }));
255
+ entries(ctx, {
256
+ name: "b",
257
+ listType: "links",
258
+ bind: "source",
259
+ source: { kind: "system", name: "breadcrumbs" },
260
+ });
261
+ expect(ctx.deps()).toEqual({
262
+ lists: [LIST.header],
263
+ listTypes: ["menu"],
264
+ queries: [{ pageType: "news", terms: [TERM.event], parent: null }],
265
+ pageTree: true,
266
+ // The breadcrumb trail's pages: their values render as entries.
267
+ pages: [PAGE.about, PAGE.team].sort(),
268
+ });
269
+ });
270
+
271
+ it("records only what the render reached (lazy)", () => {
272
+ const ctx = page(PAGE.home);
273
+ const mega = entries(ctx, {
274
+ name: "mega",
275
+ listType: "mega-menu",
276
+ bind: "shared",
277
+ list: `caelo:list/${LIST.mega}`,
278
+ });
279
+ expect(ctx.deps().queries).toEqual([]);
280
+ const columns = mega[0]?.get("columns");
281
+ if (columns?.kind !== "list") throw new Error("columns");
282
+ columns.entries[1]?.get("items");
283
+ expect(ctx.deps().queries).toEqual([{ pageType: "news", terms: [], parent: null }]);
284
+ });
285
+
286
+ it("term reads record the vocabulary list", () => {
287
+ const ctx = page(PAGE.newsA);
288
+ const scope = ctx.pageScope();
289
+ if (scope?.kind !== "record") throw new Error("page scope");
290
+ const terms = scope.get("terms");
291
+ if (terms?.kind !== "record") throw new Error("terms");
292
+ terms.get("tags");
293
+ expect(ctx.deps().lists).toEqual([LIST.tags]);
294
+ });
295
+ });