@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,553 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Template syntax for lists (epic #1107, `docs/lists.md` §8): one test
5
+ * (or more) per syntax row, plus the loud markers. Rendered through the
6
+ * real resolver over the fixture site, so the engine and the resolver
7
+ * are tested as the composer uses them.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ fixtureData,
13
+ fixtureLinkIndex,
14
+ INSTANCE,
15
+ ITEM,
16
+ LIST,
17
+ MODULE,
18
+ PAGE,
19
+ TERM,
20
+ } from "./lists/list-fixtures.test.js";
21
+ import { createListRenderContext, type ListPageContext } from "./lists/resolve.js";
22
+ import { renderTemplate, type TemplateField } from "./template-engine.js";
23
+
24
+ function ctx(
25
+ pageId: string | null = PAGE.home,
26
+ opts: Parameters<ReturnType<typeof createListRenderContext>["forPage"]>[1] = {},
27
+ ): ListPageContext {
28
+ return createListRenderContext(fixtureData(), {
29
+ dormantPluginSources: { comments: "comments-plugin" },
30
+ }).forPage(pageId, { linkResolver: fixtureLinkIndex().forPage(pageId), ...opts });
31
+ }
32
+
33
+ function render(
34
+ html: string,
35
+ fields: readonly TemplateField[],
36
+ values: Record<string, unknown> = {},
37
+ lists: ListPageContext | null = ctx(),
38
+ extra: Partial<Parameters<typeof renderTemplate>[0]> = {},
39
+ ) {
40
+ return renderTemplate({
41
+ html,
42
+ fields,
43
+ contentValues: values,
44
+ ...(lists ? { lists } : {}),
45
+ linkResolver: fixtureLinkIndex().forPage(lists?.pageId ?? null),
46
+ ...extra,
47
+ });
48
+ }
49
+
50
+ const HEADER: TemplateField = {
51
+ name: "menu",
52
+ kind: "list",
53
+ listType: "menu",
54
+ bind: "shared",
55
+ list: `caelo:list/${LIST.header}`,
56
+ };
57
+ const MEGA: TemplateField = {
58
+ name: "mega",
59
+ kind: "list",
60
+ listType: "mega-menu",
61
+ bind: "shared",
62
+ list: `caelo:list/${LIST.mega}`,
63
+ };
64
+ const uuid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
65
+ const TEXTS: TemplateField = { name: "bullets", kind: "list", listType: "texts" };
66
+ const bullets = (...texts: string[]) =>
67
+ texts.map((text, i) => ({ id: uuid(i + 1), node: "text", values: { text } }));
68
+
69
+ describe("lists — {{#f}} iteration and {{^f}} inverted sections", () => {
70
+ it("iterates a local list and stamps each entry's root element", () => {
71
+ const r = render("<ul>{{#bullets}}<li>{{text}}</li>{{/bullets}}</ul>", [TEXTS], {
72
+ bullets: bullets("Fast", "Safe"),
73
+ });
74
+ expect(r.html).toBe(
75
+ `<ul><li data-caelo-list-item="local/bullets/${uuid(1)}">Fast</li><li data-caelo-list-item="local/bullets/${uuid(2)}">Safe</li></ul>`,
76
+ );
77
+ expect(r.missingSlots).toEqual([]);
78
+ });
79
+
80
+ it("addresses a local list by its content instance when one holds it", () => {
81
+ const r = render(
82
+ "{{#bullets}}<li>{{text}}</li>{{/bullets}}",
83
+ [TEXTS],
84
+ { bullets: bullets("A") },
85
+ ctx(),
86
+ {
87
+ listHolder: { contentInstanceId: INSTANCE.placement },
88
+ },
89
+ );
90
+ expect(r.html).toContain(
91
+ `data-caelo-list-item="instance/${INSTANCE.placement}/bullets/${uuid(1)}"`,
92
+ );
93
+ });
94
+
95
+ it("falls back to the field default for a local list without a placement value", () => {
96
+ const r = render(
97
+ "{{#bullets}}{{text}};{{/bullets}}",
98
+ [{ ...TEXTS, default: bullets("D") }],
99
+ {},
100
+ );
101
+ expect(r.html).toBe("D;");
102
+ });
103
+
104
+ it("{{^f}} renders for an empty list and not for a filled one", () => {
105
+ const html = "{{#bullets}}x{{/bullets}}{{^bullets}}none{{/bullets}}";
106
+ expect(render(html, [TEXTS], { bullets: [] }).html).toBe("none");
107
+ expect(render(html, [TEXTS], { bullets: bullets("a") }).html).toBe("x");
108
+ });
109
+
110
+ it("{{^f}} works for scalars and module-lists too", () => {
111
+ const fields: TemplateField[] = [
112
+ { name: "title", kind: "text" },
113
+ { name: "cards", kind: "module-list" },
114
+ ];
115
+ const r = render("{{^title}}untitled{{/title}}|{{^cards}}no cards{{/cards}}", fields, {
116
+ cards: [],
117
+ });
118
+ expect(r.html).toBe("untitled|no cards");
119
+ expect(render("{{^title}}untitled{{/title}}", fields, { title: "T" }).html).toBe("");
120
+ });
121
+
122
+ it("an entry starting with text gets no stamp", () => {
123
+ const r = render("{{#bullets}}{{text}}, {{/bullets}}", [TEXTS], { bullets: bullets("a", "b") });
124
+ expect(r.html).toBe("a, b, ");
125
+ });
126
+
127
+ it("entry values are data: literal braces in content stay literal (#1053)", () => {
128
+ const r = render("{{#bullets}}<i>{{text}}</i>{{/bullets}}", [TEXTS], {
129
+ bullets: bullets("{{Page URL}}"),
130
+ });
131
+ expect(r.html).toContain(">{{Page URL}}</i>");
132
+ expect(r.missingSlots).toEqual([]);
133
+ });
134
+ });
135
+
136
+ describe("lists — shared lists, children, {{>recurse}}", () => {
137
+ it("iterates a shared list and its children by the node's loop name", () => {
138
+ const r = render(
139
+ "{{#menu}}<a>{{label}}</a>{{#children}}<b>{{label}}</b>{{/children}}{{/menu}}",
140
+ [HEADER],
141
+ );
142
+ expect(r.html).toBe(
143
+ `<a data-caelo-list-item="${LIST.header}/${ITEM.home}">Home</a>` +
144
+ `<a data-caelo-list-item="${LIST.header}/${ITEM.about}">About</a><b data-caelo-list-item="${LIST.header}/${ITEM.team}">Team</b>` +
145
+ `<a data-caelo-list-item="${LIST.header}/${ITEM.newsEntry}">News</a>`,
146
+ );
147
+ });
148
+
149
+ it("matches a children name that repeats inside itself (balanced)", () => {
150
+ const r = render(
151
+ "{{#menu}}[{{label}}{{#children}}({{label}}{{#children}}!{{/children}}){{/children}}]{{/menu}}",
152
+ [HEADER],
153
+ );
154
+ expect(r.html).toBe("[Home][About(Team)][News]");
155
+ });
156
+
157
+ it("{{>recurse}} renders the current entry's children with the enclosing block", () => {
158
+ const r = render(
159
+ "<ul>{{#menu}}<li>{{label}}{{#has_children}}<ul>{{>recurse}}</ul>{{/has_children}}</li>{{/menu}}</ul>",
160
+ [HEADER],
161
+ );
162
+ expect(r.html.replace(/ data-caelo-list-item="[^"]*"/g, "")).toBe(
163
+ "<ul><li>Home</li><li>About<ul><li>Team</li></ul></li><li>News</li></ul>",
164
+ );
165
+ });
166
+
167
+ it("{{>recurse}} is bounded: entries deeper than the type's max_depth are refused, not drawn", () => {
168
+ const deep = (n: number): Record<string, unknown> => ({
169
+ id: uuid(100 + n),
170
+ node: "entry",
171
+ values: { label: `L${n}` },
172
+ ...(n < 4 ? { children: [deep(n + 1)] } : {}),
173
+ });
174
+ const fields: TemplateField[] = [{ name: "nav", kind: "list", listType: "menu" }];
175
+ const html = "{{#nav}}<li>{{label}}<ul>{{>recurse}}</ul></li>{{/nav}}";
176
+ // menu: max_depth 3 — three levels render, a fourth is data the type forbids.
177
+ const ok = render(html, fields, { nav: [deep(2)] });
178
+ expect(ok.html.replace(/ data-caelo-list-item="[^"]*"/g, "")).toBe(
179
+ "<li>L2<ul><li>L3<ul><li>L4<ul></ul></li></ul></li></ul></li>",
180
+ );
181
+ const tooDeep = render(html, fields, { nav: [deep(1)] });
182
+ expect(tooDeep.html).toBe(html);
183
+ expect(tooDeep.missingSlots).toEqual([
184
+ "list-malformed:nav[items[0].children[0].children[0].children[0]]",
185
+ ]);
186
+ });
187
+
188
+ it("system keys: id node index depth first last", () => {
189
+ const r = render(
190
+ "{{#menu}}{{index}}:{{node}}:{{depth}}:{{#first}}F{{/first}}{{#last}}L{{/last}}{{#children}}/{{depth}}{{id}}{{/children}};{{/menu}}",
191
+ [HEADER],
192
+ );
193
+ expect(r.html).toBe(`0:entry:1:F;1:entry:1:/2${ITEM.team};2:entry:1:L;`);
194
+ });
195
+
196
+ it("has_<children> and count_<children>", () => {
197
+ const r = render("{{#menu}}{{count_children}}{{#has_children}}+{{/has_children}},{{/menu}}", [
198
+ HEADER,
199
+ ]);
200
+ expect(r.html).toBe("0,1+,0,");
201
+ });
202
+ });
203
+
204
+ describe("lists — dot access and compound values", () => {
205
+ it("a bare link renders its href, which resolves like any reference", () => {
206
+ const r = render('{{#menu}}<a href="{{link}}">{{link.title}}</a>{{/menu}}', [HEADER]);
207
+ expect(r.html).toContain('href="/about/"');
208
+ expect(r.html).toContain(">About</a>");
209
+ expect(r.missingSlots).toEqual([]);
210
+ });
211
+
212
+ it("{{link.href}} and {{link.target}}", () => {
213
+ const fields: TemplateField[] = [{ name: "links", kind: "list", listType: "links" }];
214
+ const r = render(
215
+ '{{#links}}<a href="{{link.href}}" target="{{link.target}}">{{label}}</a>{{/links}}',
216
+ fields,
217
+ {
218
+ links: [
219
+ {
220
+ id: uuid(1),
221
+ node: "link",
222
+ values: { label: "Ext", link: { href: "https://x.test", target: "_blank" } },
223
+ },
224
+ ],
225
+ },
226
+ );
227
+ expect(r.html).toContain('href="https://x.test" target="_blank" data-caelo-list-item=');
228
+ expect(r.html).toContain(">Ext</a>");
229
+ });
230
+
231
+ it("a dotted key the value does not have stays raw and is reported", () => {
232
+ const r = render("{{#menu}}{{link.nope}}{{/menu}}", [HEADER]);
233
+ expect(r.html).toContain("{{link.nope}}");
234
+ expect(r.missingSlots).toContain("field-not-declared:menu.link.nope");
235
+ });
236
+ });
237
+
238
+ describe("lists — lookup order: entry, enclosing entries, module fields", () => {
239
+ it("reads an enclosing entry's key and a module field from inside an entry", () => {
240
+ const fields: TemplateField[] = [HEADER, { name: "brand", kind: "text" }];
241
+ const r = render("{{#menu}}{{#children}}{{brand}}>{{label}}<{{/children}}{{/menu}}", fields, {
242
+ brand: "ACME",
243
+ });
244
+ expect(r.html).toContain("ACME>Team<");
245
+ });
246
+
247
+ it("the innermost entry wins over an enclosing one", () => {
248
+ const r = render("{{#menu}}{{#children}}{{label}}{{/children}}{{/menu}}", [HEADER]);
249
+ expect(r.html).toContain("Team");
250
+ expect(r.html).not.toContain("About");
251
+ });
252
+
253
+ it("a key found nowhere stays raw and pushes field-not-declared:<f>.<key>", () => {
254
+ const r = render("{{#menu}}{{lable}}{{/menu}}", [HEADER]);
255
+ expect(r.html).toContain("{{lable}}");
256
+ expect(r.missingSlots).toContain("field-not-declared:menu.lable");
257
+ });
258
+ });
259
+
260
+ describe("lists — is_current / is_ancestor", () => {
261
+ it("marks the entry linking to the rendering page and its ancestors", () => {
262
+ const r = render(
263
+ "{{#menu}}{{label}}{{#is_current}}*{{/is_current}}{{#is_ancestor}}^{{/is_ancestor}}{{#children}}({{label}}{{#is_current}}*{{/is_current}}){{/children}} {{/menu}}",
264
+ [HEADER],
265
+ {},
266
+ ctx(PAGE.team),
267
+ );
268
+ expect(r.html).toBe("Home About^(Team*) News ");
269
+ });
270
+
271
+ it("a page below a menu entry's page makes the entry an ancestor (page tree)", () => {
272
+ const r = render(
273
+ "{{#menu}}{{#is_ancestor}}{{label}}{{/is_ancestor}}{{/menu}}",
274
+ [HEADER],
275
+ {},
276
+ ctx(PAGE.newsA),
277
+ );
278
+ expect(r.html).toBe("News");
279
+ });
280
+
281
+ it("a node without its own link is an ancestor of a current entry below it", () => {
282
+ const r = render(
283
+ "{{#mega}}{{#columns}}{{#is_ancestor}}[{{node}}]{{/is_ancestor}}{{/columns}}{{/mega}}",
284
+ [MEGA],
285
+ {},
286
+ ctx(PAGE.team),
287
+ );
288
+ expect(r.html).toBe("[column]");
289
+ });
290
+
291
+ it("compares resolved URLs for literal hrefs", () => {
292
+ const fields: TemplateField[] = [{ name: "links", kind: "list", listType: "links" }];
293
+ const r = render(
294
+ "{{#links}}{{#is_current}}{{label}}{{/is_current}}{{/links}}",
295
+ fields,
296
+ {
297
+ links: [
298
+ { id: uuid(1), node: "link", values: { label: "Lit", link: { href: "/about/" } } },
299
+ { id: uuid(2), node: "link", values: { label: "Other", link: { href: "/news" } } },
300
+ ],
301
+ },
302
+ ctx(PAGE.about),
303
+ );
304
+ expect(r.html).toBe("Lit");
305
+ });
306
+ });
307
+
308
+ describe("lists — mega menu: is_<node>, items, select flags, embedded modules", () => {
309
+ const MEGA_HTML =
310
+ "{{#mega}}<li>{{label}}{{#columns}}" +
311
+ '{{#is_column}}<div><h4>{{heading}}</h4>{{#links}}<a href="{{link}}">{{label}}{{#badge_new}} NEW{{/badge_new}}</a>{{/links}}</div>{{/is_column}}' +
312
+ "{{#is_news_feed}}<aside>{{heading}}{{#items}}<p>{{title}}|{{published_at}}</p>{{/items}}</aside>{{/is_news_feed}}" +
313
+ "{{#is_promo}}<section>{{title}}{{>teaser}}</section>{{/is_promo}}" +
314
+ "{{/columns}}</li>{{/mega}}";
315
+
316
+ it("renders every node kind of the tree", () => {
317
+ const asked: { key: string; ref: unknown }[] = [];
318
+ const r = render(MEGA_HTML, [MEGA], {}, ctx(), {
319
+ partials: (key, ref) => {
320
+ asked.push({ key, ref });
321
+ return "<em>teaser</em>";
322
+ },
323
+ });
324
+ const html = r.html.replace(/ data-caelo-list-item="[^"]*"/g, "");
325
+ expect(html).toBe(
326
+ "<li>Products" +
327
+ '<div><h4>Explore</h4><a href="/about/team/">Team NEW</a><a href="https://docs.example">Docs</a></div>' +
328
+ "<aside>Latest<p>Meetup|2026-10-05T09:00:00.000Z</p><p>Launch day|2026-10-01T09:00:00.000Z</p></aside>" +
329
+ "<section>Try it<em>teaser</em></section>" +
330
+ "</li>",
331
+ );
332
+ expect(asked).toEqual([
333
+ {
334
+ key: `list:mega/${ITEM.promo}/teaser`,
335
+ ref: { moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser },
336
+ },
337
+ ]);
338
+ expect(r.missingSlots).toEqual([]);
339
+ });
340
+
341
+ it("without a nested renderer an embedded module stays loud", () => {
342
+ const r = render(
343
+ "{{#mega}}{{#columns}}{{#is_promo}}{{>teaser}}{{/is_promo}}{{/columns}}{{/mega}}",
344
+ [MEGA],
345
+ );
346
+ expect(r.missingSlots).toContain(`nested-renderer-unavailable:mega[${ITEM.promo}].teaser`);
347
+ });
348
+
349
+ it("generated entries carry no list-item stamp; items is empty on a node without a source", () => {
350
+ const r = render(
351
+ "{{#mega}}{{#columns}}{{#items}}<p>{{title}}</p>{{/items}}{{/columns}}{{/mega}}",
352
+ [MEGA],
353
+ );
354
+ expect(r.html).toBe("<p>Meetup</p><p>Launch day</p>");
355
+ expect(r.missingSlots).toEqual([]);
356
+ });
357
+ });
358
+
359
+ describe("lists — {{page.*}}", () => {
360
+ it("reads the rendering page's title, props and terms anywhere in a module", () => {
361
+ const r = render(
362
+ "<h1>{{page.title}}</h1>{{page.props.rating}}|{{page.props.event_date.formatted}}|{{#page.terms.tags}}#{{name}}{{/page.terms.tags}}",
363
+ [],
364
+ {},
365
+ ctx(PAGE.newsA),
366
+ );
367
+ expect(r.html).toBe("<h1>Launch day</h1>4|November 2, 2026|#Release");
368
+ });
369
+
370
+ it("is loud without a list context", () => {
371
+ const r = render("{{page.title}}", [], {}, null);
372
+ expect(r.html).toBe("{{page.title}}");
373
+ expect(r.missingSlots).toContain("list-context-unavailable:page");
374
+ });
375
+
376
+ it("query entries expose summary, image (url/alt) and props", () => {
377
+ const fields: TemplateField[] = [
378
+ {
379
+ name: "latest",
380
+ kind: "list",
381
+ listType: "links",
382
+ bind: "source",
383
+ source: {
384
+ kind: "query",
385
+ editable: [],
386
+ defaults: { page_type: "news", order: "prop_asc:rating", limit: 5 },
387
+ },
388
+ },
389
+ ];
390
+ const r = render(
391
+ '{{#latest}}<img src="{{image}}" alt="{{image.alt}}">{{summary}}|{{props.rating}};{{/latest}}',
392
+ fields,
393
+ {},
394
+ ctx(PAGE.home),
395
+ );
396
+ expect(r.html).toBe(
397
+ '<img src="/_caelo/media/hero" alt="Hero">We launched.|4;<img src="" alt="">|5;',
398
+ );
399
+ });
400
+ });
401
+
402
+ describe("lists — terms", () => {
403
+ it("a term field iterates the vocabulary entries it references", () => {
404
+ const type = {
405
+ name: "chips",
406
+ kind: "list" as const,
407
+ listType: "tags",
408
+ bind: "shared" as const,
409
+ list: `caelo:list/${LIST.tags}`,
410
+ };
411
+ const r = render("{{#chips}}{{name}}/{{slug}} {{/chips}}", [type]);
412
+ expect(r.html).toBe("Release/release Event/event ");
413
+ });
414
+
415
+ it("page_terms lists the rendering page's terms", () => {
416
+ const fields: TemplateField[] = [
417
+ {
418
+ name: "tags",
419
+ kind: "list",
420
+ listType: "tags",
421
+ bind: "source",
422
+ source: { kind: "system", name: "page_terms" },
423
+ list: `caelo:list/${LIST.tags}`,
424
+ },
425
+ ];
426
+ expect(render("{{#tags}}{{name}}{{/tags}}", fields, {}, ctx(PAGE.newsB)).html).toBe("Event");
427
+ });
428
+
429
+ it("term_pages lists other pages carrying the placement's terms", () => {
430
+ const fields: TemplateField[] = [
431
+ {
432
+ name: "related",
433
+ kind: "list",
434
+ listType: "links",
435
+ bind: "source",
436
+ source: { kind: "system", name: "term_pages" },
437
+ },
438
+ ];
439
+ const r = render(
440
+ "{{#related}}{{title}}{{/related}}",
441
+ fields,
442
+ { related: { terms: [TERM.release, TERM.event] } },
443
+ ctx(PAGE.newsA),
444
+ );
445
+ expect(r.html).toBe("Meetup");
446
+ });
447
+ });
448
+
449
+ describe("lists — plugin sources", () => {
450
+ const SWITCHER =
451
+ '<nav>{{#language_links}}<a href="{{href}}">{{label}}</a>{{/language_links}}</nav>';
452
+
453
+ it("an undeclared section name reads an active plugin source", () => {
454
+ const lists = ctx(PAGE.home, {
455
+ pluginSources: {
456
+ language_links: [
457
+ { id: "en", values: { href: "/", label: "English" } },
458
+ { id: "de", values: { href: "/de", label: "Deutsch" } },
459
+ ],
460
+ },
461
+ });
462
+ const r = render(SWITCHER, [], {}, lists);
463
+ expect(r.html).toBe('<nav><a href="/">English</a><a href="/de">Deutsch</a></nav>');
464
+ expect(r.missingSlots).toEqual([]);
465
+ });
466
+
467
+ it("an empty plugin source renders nothing — that is data", () => {
468
+ const r = render(SWITCHER, [], {}, ctx(PAGE.home, { pluginSources: { language_links: [] } }));
469
+ expect(r.html).toBe("<nav></nav>");
470
+ expect(r.missingSlots).toEqual([]);
471
+ });
472
+
473
+ it("a source of an inactive plugin stays loud and names the plugin", () => {
474
+ const fields: TemplateField[] = [
475
+ {
476
+ name: "comments",
477
+ kind: "list",
478
+ listType: "texts",
479
+ bind: "source",
480
+ source: { kind: "plugin", name: "comments" },
481
+ },
482
+ ];
483
+ const r = render("{{#comments}}x{{/comments}}", fields);
484
+ expect(r.html).toBe("{{#comments}}x{{/comments}}");
485
+ expect(r.missingSlots).toEqual(["list-source-unavailable:comments plugin=comments-plugin"]);
486
+ });
487
+
488
+ it("an undeclared name of an inactive plugin's source stays loud and names the plugin", () => {
489
+ const lists = createListRenderContext(fixtureData(), {
490
+ dormantPluginSources: { language_links: "international-site" },
491
+ }).forPage(PAGE.home);
492
+ const r = render(SWITCHER, [], {}, lists);
493
+ expect(r.html).toBe(SWITCHER);
494
+ expect(r.missingSlots).toEqual([
495
+ "list-source-unavailable:language_links plugin=international-site",
496
+ ]);
497
+ });
498
+
499
+ it("a module's own field wins over a plugin source of the same name", () => {
500
+ const fields: TemplateField[] = [{ ...TEXTS, name: "language_links" }];
501
+ const r = render(
502
+ "{{#language_links}}{{text}}{{/language_links}}",
503
+ fields,
504
+ {
505
+ language_links: bullets("mine"),
506
+ },
507
+ ctx(PAGE.home, {
508
+ pluginSources: { language_links: [{ id: "x", values: { text: "plugin" } }] },
509
+ }),
510
+ );
511
+ expect(r.html).toBe("mine");
512
+ });
513
+ });
514
+
515
+ describe("lists — loud markers", () => {
516
+ it("list-unbound: a shared field with neither list nor value", () => {
517
+ const r = render("{{#nav}}x{{/nav}}", [
518
+ { name: "nav", kind: "list", listType: "menu", bind: "shared" },
519
+ ]);
520
+ expect(r.html).toBe("{{#nav}}x{{/nav}}");
521
+ expect(r.missingSlots).toEqual(["list-unbound:nav"]);
522
+ });
523
+
524
+ it("list-missing: a shared field naming a deleted list", () => {
525
+ const gone = "00000000-0000-4000-8000-999999999999";
526
+ const r = render(
527
+ "{{#nav}}x{{/nav}}",
528
+ [{ name: "nav", kind: "list", listType: "menu", bind: "shared" }],
529
+ {
530
+ nav: `caelo:list/${gone}`,
531
+ },
532
+ );
533
+ expect(r.missingSlots).toEqual([`list-missing:${gone}`]);
534
+ });
535
+
536
+ it("list-type-missing: an unknown list type", () => {
537
+ const r = render("{{#x}}y{{/x}}", [{ name: "x", kind: "list", listType: "ghost" }], { x: [] });
538
+ expect(r.missingSlots).toEqual(["list-type-missing:ghost"]);
539
+ });
540
+
541
+ it("list-malformed: an entry of an unknown node kind names its path", () => {
542
+ const r = render("{{#bullets}}x{{/bullets}}", [TEXTS], {
543
+ bullets: [{ id: uuid(1), node: "ghost", values: {} }],
544
+ });
545
+ expect(r.missingSlots).toEqual(["list-malformed:bullets[items[0].node]"]);
546
+ });
547
+
548
+ it("list-context-unavailable: a list rendered without a list context", () => {
549
+ const r = render("{{#bullets}}x{{/bullets}}", [TEXTS], { bullets: bullets("a") }, null);
550
+ expect(r.html).toBe("{{#bullets}}x{{/bullets}}");
551
+ expect(r.missingSlots).toEqual(["list-context-unavailable:bullets"]);
552
+ });
553
+ });