@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,69 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The values a template sees inside a list section (epic #1107,
5
+ * `docs/lists.md` §8) — the seam between the resolver (`resolve.ts`),
6
+ * which turns stored entries, page queries, system and plugin sources
7
+ * into these scopes, and the template engine, which only looks keys up.
8
+ * The engine knows nothing about list types, links or pages; the
9
+ * resolver knows nothing about template syntax.
10
+ *
11
+ * Every lookup is lazy: a children list, a query or an embedded module
12
+ * is only resolved when the template actually reaches it, so render
13
+ * dependencies (`ListRenderDeps`) record what a page really rendered,
14
+ * not what it could have.
15
+ */
16
+
17
+ /** One value a template key resolves to. */
18
+ export type ScopeValue =
19
+ /** Plain text (`{{f}}`); a section over it renders when it is non-empty. */
20
+ | { readonly kind: "text"; readonly text: string }
21
+ /** A flag (`{{#is_current}}`); `{{f}}` renders `true` / `false`. */
22
+ | { readonly kind: "bool"; readonly value: boolean }
23
+ /**
24
+ * A compound value with dot access (`link`, `media`, `date`, `props`,
25
+ * `page`). `text` is what the bare key renders (`{{link}}` = href);
26
+ * null when the bare key has no meaning (`{{props}}`).
27
+ */
28
+ | {
29
+ readonly kind: "record";
30
+ readonly text: string | null;
31
+ /** Section truthiness of the bare key (`{{#link}}` — set or not). */
32
+ readonly present: boolean;
33
+ get(key: string): ScopeValue | undefined;
34
+ }
35
+ /** Iterable entries (`children`, `items`, a term field, `page.terms.tags`). */
36
+ | { readonly kind: "list"; readonly entries: readonly ScopeEntry[] }
37
+ /** An embedded module (`{{>f}}`). */
38
+ | {
39
+ readonly kind: "module";
40
+ readonly ref: { readonly moduleId: string; readonly contentInstanceId: string };
41
+ }
42
+ /**
43
+ * A value that could not resolve (a list source of an inactive plugin,
44
+ * a malformed query). The engine renders the tag raw and reports
45
+ * `marker` — loud, never silently empty (CLAUDE.md §2).
46
+ */
47
+ | { readonly kind: "failed"; readonly marker: string };
48
+
49
+ /** One entry a list section iterates. */
50
+ export interface ScopeEntry {
51
+ /** The entry id (stored uuid, page id for page-shaped entries, plugin id). */
52
+ readonly id: string;
53
+ /** Look up a key of this entry: fields, system keys, children, `items`. */
54
+ get(key: string): ScopeValue | undefined;
55
+ /**
56
+ * Value of the `data-caelo-list-item` attribute stamped on the entry's
57
+ * root element (`docs/lists.md` §13), or null for generated entries that
58
+ * are no stored list entry (query results, plugin entries).
59
+ */
60
+ readonly itemAttr: string | null;
61
+ /**
62
+ * Loop name of this entry's stored children (`children.name` of its
63
+ * node), which `{{>recurse}}` renders with the enclosing block; null
64
+ * when the node takes no children.
65
+ */
66
+ readonly childrenKey: string | null;
67
+ /** 1-based depth in its tree. */
68
+ readonly depth: number;
69
+ }
@@ -0,0 +1,262 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Preview / generator parity for lists (epic #1107, #1110 acceptance):
5
+ * a mega menu (entry → column / news feed / promo → sub link, with an
6
+ * embedded teaser module) rendered as a LAYOUT chrome placement and
7
+ * inside a NESTED module, composed the way the editor preview does it
8
+ * (page modules pre-rendered with `renderModuleWithContent`, chrome
9
+ * through the composer) and the way the static generator does it
10
+ * (everything through the composer with a `nestedModules` resolver).
11
+ * Same input, same HTML.
12
+ */
13
+
14
+ import { describe, expect, it } from "bun:test";
15
+ import {
16
+ fixtureData,
17
+ fixtureLinkIndex,
18
+ INSTANCE,
19
+ ITEM,
20
+ LIST,
21
+ MODULE,
22
+ PAGE,
23
+ } from "./lists/list-fixtures.test.js";
24
+ import { createListRenderContext } from "./lists/resolve.js";
25
+ import {
26
+ type NestedContentInstanceResource,
27
+ type NestedModuleResource,
28
+ type NestedRenderResolver,
29
+ renderModuleWithContent,
30
+ } from "./nested-module-render.js";
31
+ import { type ComposeModule, composePageWithLayout } from "./preview-compose.js";
32
+ import type { TemplateField } from "./template-engine.js";
33
+
34
+ const HEADER_MOD = "00000000-0000-4000-8000-000000000311";
35
+ const WRAPPER_MOD = "00000000-0000-4000-8000-000000000312";
36
+ const HEADER_CI = "00000000-0000-4000-8000-000000000411";
37
+ const WRAPPER_CI = "00000000-0000-4000-8000-000000000412";
38
+
39
+ const MEGA_HTML =
40
+ '<nav class="mega"><ul>{{#mega}}<li class="{{#is_current}}current{{/is_current}}{{#is_ancestor}}open{{/is_ancestor}}">' +
41
+ '<a href="{{link}}">{{label}}</a>' +
42
+ '{{#has_columns}}<div class="panel">{{#columns}}' +
43
+ '{{#is_column}}<div class="col"><h4>{{heading}}</h4><ul>{{#links}}<li><a href="{{link}}">{{label}}</a>{{#badge_hot}}!{{/badge_hot}}</li>{{/links}}</ul></div>{{/is_column}}' +
44
+ '{{#is_news_feed}}<aside><h4>{{heading}}</h4>{{#items}}<a href="{{link}}">{{title}} · {{published_at.formatted}}</a>{{/items}}{{^items}}No news{{/items}}</aside>{{/is_news_feed}}' +
45
+ '{{#is_promo}}<div class="promo">{{>teaser}}</div>{{/is_promo}}' +
46
+ "{{/columns}}</div>{{/has_columns}}</li>{{/mega}}</ul><p>{{page.title}}</p></nav>";
47
+
48
+ const HEADER_FIELDS: TemplateField[] = [
49
+ {
50
+ name: "mega",
51
+ kind: "list",
52
+ listType: "mega-menu",
53
+ bind: "shared",
54
+ list: `caelo:list/${LIST.mega}`,
55
+ },
56
+ ];
57
+
58
+ const modules: NestedModuleResource[] = [
59
+ {
60
+ moduleId: HEADER_MOD,
61
+ slug: "mega-header",
62
+ html: MEGA_HTML,
63
+ css: ".mega{}",
64
+ js: "",
65
+ fields: HEADER_FIELDS,
66
+ },
67
+ {
68
+ moduleId: MODULE.teaser,
69
+ slug: "teaser",
70
+ html: '<article class="teaser"><h5>{{title}}</h5></article>',
71
+ css: ".teaser{}",
72
+ js: "",
73
+ fields: [{ name: "title", kind: "text" }],
74
+ },
75
+ {
76
+ moduleId: WRAPPER_MOD,
77
+ slug: "wrapper",
78
+ html: '<section class="wrap">{{>inner}}</section>',
79
+ css: ".wrap{}",
80
+ js: "",
81
+ fields: [{ name: "inner", kind: "module" }],
82
+ },
83
+ ];
84
+ const instances: NestedContentInstanceResource[] = [
85
+ {
86
+ id: INSTANCE.teaser,
87
+ moduleId: MODULE.teaser,
88
+ values: { title: "Try it free" },
89
+ deletedAt: null,
90
+ },
91
+ { id: HEADER_CI, moduleId: HEADER_MOD, values: {}, deletedAt: null },
92
+ {
93
+ id: WRAPPER_CI,
94
+ moduleId: WRAPPER_MOD,
95
+ values: { inner: { moduleId: HEADER_MOD, contentInstanceId: HEADER_CI } },
96
+ deletedAt: null,
97
+ },
98
+ ];
99
+ const resolver: NestedRenderResolver = {
100
+ getModule: (id) => modules.find((m) => m.moduleId === id) ?? null,
101
+ getContentInstance: (id) => instances.find((c) => c.id === id) ?? null,
102
+ };
103
+ const mod = (id: string): NestedModuleResource =>
104
+ modules.find((m) => m.moduleId === id) as NestedModuleResource;
105
+
106
+ const LAYOUT =
107
+ '<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>';
108
+ const TEMPLATE = '<body><caelo-slot name="content"></caelo-slot></body>';
109
+
110
+ /** The chrome placement: renders from field defaults, no content instance. */
111
+ function chrome(): ComposeModule {
112
+ const m = mod(HEADER_MOD);
113
+ return {
114
+ moduleId: m.moduleId,
115
+ slug: m.slug,
116
+ displayName: "Header",
117
+ html: m.html,
118
+ css: m.css,
119
+ js: m.js,
120
+ fields: m.fields,
121
+ };
122
+ }
123
+
124
+ function contexts(pageId: string) {
125
+ const linkResolver = fixtureLinkIndex().forPage(pageId);
126
+ const lists = createListRenderContext(fixtureData()).forPage(pageId, { linkResolver });
127
+ return { linkResolver, lists };
128
+ }
129
+
130
+ /** The editor preview's path (`pages.render_preview`). */
131
+ function previewCompose(pageId: string) {
132
+ const { linkResolver, lists } = contexts(pageId);
133
+ const rendered = renderModuleWithContent(
134
+ WRAPPER_MOD,
135
+ WRAPPER_CI,
136
+ resolver,
137
+ undefined,
138
+ lists,
139
+ linkResolver,
140
+ );
141
+ const w = mod(WRAPPER_MOD);
142
+ const nested = [...rendered.touchedModuleIds].filter((id) => id !== WRAPPER_MOD).map(mod);
143
+ return composePageWithLayout({
144
+ templateHtml: TEMPLATE,
145
+ templateCss: "",
146
+ blocks: [
147
+ {
148
+ blockName: "content",
149
+ modules: [
150
+ {
151
+ moduleId: w.moduleId,
152
+ slug: w.slug,
153
+ displayName: "Wrapper",
154
+ html: rendered.html,
155
+ css: w.css,
156
+ js: w.js,
157
+ },
158
+ ],
159
+ },
160
+ {
161
+ blockName: "__nested__",
162
+ modules: nested.map((n) => ({
163
+ moduleId: n.moduleId,
164
+ slug: n.slug,
165
+ displayName: n.slug,
166
+ html: "",
167
+ css: n.css,
168
+ js: n.js,
169
+ })),
170
+ },
171
+ ],
172
+ layoutHtml: LAYOUT,
173
+ layoutCss: "",
174
+ layoutBlocks: [{ blockName: "header", modules: [chrome()] }],
175
+ nestedModules: resolver,
176
+ lists,
177
+ linkResolver,
178
+ });
179
+ }
180
+
181
+ /** The static generator's path (`generate.ts`). */
182
+ function generatorCompose(pageId: string) {
183
+ const { linkResolver, lists } = contexts(pageId);
184
+ const w = mod(WRAPPER_MOD);
185
+ return composePageWithLayout({
186
+ templateHtml: TEMPLATE,
187
+ templateCss: "",
188
+ blocks: [
189
+ {
190
+ blockName: "content",
191
+ modules: [
192
+ {
193
+ moduleId: w.moduleId,
194
+ slug: w.slug,
195
+ displayName: "Wrapper",
196
+ html: w.html,
197
+ css: w.css,
198
+ js: w.js,
199
+ fields: w.fields,
200
+ contentValues: (
201
+ instances.find((c) => c.id === WRAPPER_CI) as NestedContentInstanceResource
202
+ ).values,
203
+ contentInstanceId: WRAPPER_CI,
204
+ },
205
+ ],
206
+ },
207
+ ],
208
+ layoutHtml: LAYOUT,
209
+ layoutCss: "",
210
+ layoutBlocks: [{ blockName: "header", modules: [chrome()] }],
211
+ nestedModules: resolver,
212
+ lists,
213
+ linkResolver,
214
+ });
215
+ }
216
+
217
+ const bodyOf = (html: string) => html.slice(html.indexOf("<body>"), html.indexOf("</body>"));
218
+
219
+ describe("mega menu — preview / generator parity", () => {
220
+ it("renders the same HTML on both paths, in chrome and inside a nested module", () => {
221
+ const preview = previewCompose(PAGE.team);
222
+ const build = generatorCompose(PAGE.team);
223
+ expect(preview.moduleFailures).toEqual([]);
224
+ expect(build.moduleFailures).toEqual([]);
225
+ expect(bodyOf(preview.html)).toBe(bodyOf(build.html));
226
+ // CSS of every module the lists reached joins the page once on both paths.
227
+ for (const out of [preview, build]) {
228
+ for (const css of [".mega{}", ".teaser{}", ".wrap{}"]) {
229
+ expect(out.html.split(css).length - 1).toBe(1);
230
+ }
231
+ }
232
+ });
233
+
234
+ it("renders every node kind with page-aware state", () => {
235
+ const body = bodyOf(generatorCompose(PAGE.team).html);
236
+ // Twice: once in the chrome, once inside the wrapper module.
237
+ expect(body.split('<nav class="mega"').length - 1).toBe(2);
238
+ expect(body).toContain(
239
+ `<li class="open" data-caelo-list-item="${LIST.mega}/${ITEM.products}"><a href="/about/">Products</a>`,
240
+ );
241
+ expect(body).toContain(
242
+ `<div class="col" data-caelo-list-item="${LIST.mega}/${ITEM.column}"><h4>Explore</h4>`,
243
+ );
244
+ expect(body).toContain(
245
+ `<li data-caelo-list-item="${LIST.mega}/${ITEM.subA}"><a href="/about/team/">Team</a></li>`,
246
+ );
247
+ expect(body).toContain(
248
+ '<a href="/news/meetup/">Meetup · October 5, 2026</a><a href="/news/launch/">Launch day · October 1, 2026</a>',
249
+ );
250
+ expect(body).toContain(
251
+ `<div class="promo" data-caelo-list-item="${LIST.mega}/${ITEM.promo}"><article class="teaser" data-caelo-module-id="${MODULE.teaser}"><h5>Try it free</h5></article></div>`,
252
+ );
253
+ expect(body).toContain("<p>Team</p>");
254
+ expect(body).not.toContain("{{");
255
+ });
256
+
257
+ it("re-renders per page: the page title and current state follow the rendering page", () => {
258
+ const body = bodyOf(generatorCompose(PAGE.home).html);
259
+ expect(body).toContain("<p>Home</p>");
260
+ expect(body).toContain('<li class=""');
261
+ });
262
+ });
@@ -40,6 +40,14 @@ export interface MarkupNode {
40
40
  readonly classes: readonly string[];
41
41
  /** The `data-caelo-module-id` the composer stamps on every module root. */
42
42
  readonly moduleId: string | null;
43
+ /** The `href` attribute (links), as written. */
44
+ readonly href: string | null;
45
+ /**
46
+ * The `data-caelo-list-item` the template engine stamps on a rendered
47
+ * list entry (docs/lists.md §13): `<listId>/<itemId>` for a shared
48
+ * list, `instance/…` / `local/…` for a module's local list.
49
+ */
50
+ readonly listItem: string | null;
43
51
  readonly children: MarkupNode[];
44
52
  readonly parent: MarkupNode | null;
45
53
  }
@@ -76,6 +84,8 @@ export function parseMarkup(html: string): MarkupNode[] {
76
84
  tag,
77
85
  classes: splitClasses(attribs.class),
78
86
  moduleId: attribs["data-caelo-module-id"] ?? null,
87
+ href: attribs.href ?? null,
88
+ listItem: attribs["data-caelo-list-item"] ?? null,
79
89
  children: [],
80
90
  parent,
81
91
  };
@@ -19,6 +19,8 @@
19
19
  */
20
20
 
21
21
  import { describe, expect, it } from "bun:test";
22
+ import { fixtureData, INSTANCE, ITEM, LIST, MODULE, PAGE } from "./lists/list-fixtures.test.js";
23
+ import { createListRenderContext } from "./lists/resolve.js";
22
24
  import {
23
25
  collectNestedRefs,
24
26
  type NestedContentInstanceResource,
@@ -288,148 +290,176 @@ describe("renderModuleWithContent — empty optional module field (#1041)", () =
288
290
  });
289
291
  });
290
292
 
291
- describe("renderModuleWithContent — list-of-primitive slots", () => {
292
- it("text-list iterates {{.}} per string element", () => {
293
+ describe("renderModuleWithContent — list fields (epic #1107)", () => {
294
+ const lists = createListRenderContext(fixtureData()).forPage(PAGE.home);
295
+ const T = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
296
+ const texts = (...t: string[]) =>
297
+ t.map((text, i) => ({ id: T(i + 1), node: "text", values: { text } }));
298
+ const PARENT_CI = "00000000-0000-4000-8000-00000000bbb1";
299
+
300
+ it("iterates a local list and addresses entries by the content instance", () => {
293
301
  const resolver = buildResolver(
294
302
  [
295
303
  {
296
304
  moduleId: PARENT_MOD_ID,
297
305
  slug: "tag-cloud",
298
- html: "<div>{{#tags}}<span>{{.}}</span>{{/tags}}</div>",
306
+ html: "<div>{{#tags}}<span>{{text}}</span>{{/tags}}</div>",
299
307
  css: "",
300
308
  js: "",
301
- fields: [{ name: "tags", kind: "text-list" }],
309
+ fields: [{ name: "tags", kind: "list", listType: "texts" }],
302
310
  },
303
311
  ],
304
312
  [
305
313
  {
306
- id: PARENT_CI_ID,
314
+ id: PARENT_CI,
307
315
  moduleId: PARENT_MOD_ID,
308
- values: { tags: ["alpha", "beta", "gamma"] },
316
+ values: { tags: texts("alpha", "beta") },
309
317
  deletedAt: null,
310
318
  },
311
319
  ],
312
320
  );
313
- const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
314
- expect(r.html).toBe("<div><span>alpha</span><span>beta</span><span>gamma</span></div>");
321
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI, resolver, undefined, lists);
322
+ expect(r.html).toBe(
323
+ `<div><span data-caelo-list-item="instance/${PARENT_CI}/tags/${T(1)}">alpha</span>` +
324
+ `<span data-caelo-list-item="instance/${PARENT_CI}/tags/${T(2)}">beta</span></div>`,
325
+ );
315
326
  expect(r.missingSlots).toEqual([]);
316
327
  });
317
328
 
318
- it("text-list also accepts the {{item}} alias", () => {
329
+ it("an edit-marked copy keeps the real instance as the entries' address", () => {
319
330
  const resolver = buildResolver(
320
331
  [
321
332
  {
322
333
  moduleId: PARENT_MOD_ID,
323
- slug: "bullets",
324
- html: "<ul>{{#bullets}}<li>{{item}}</li>{{/bullets}}</ul>",
334
+ slug: "tag-cloud",
335
+ html: "{{#tags}}<i>{{text}}</i>{{/tags}}",
325
336
  css: "",
326
337
  js: "",
327
- fields: [{ name: "bullets", kind: "text-list" }],
338
+ fields: [{ name: "tags", kind: "list", listType: "texts" }],
328
339
  },
329
340
  ],
330
341
  [
331
342
  {
332
- id: PARENT_CI_ID,
343
+ id: "edit:content#0",
333
344
  moduleId: PARENT_MOD_ID,
334
- values: { bullets: ["one", "two"] },
345
+ values: { tags: texts("a") },
335
346
  deletedAt: null,
347
+ listHolderId: PARENT_CI,
336
348
  },
337
349
  ],
338
350
  );
339
- const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
340
- expect(r.html).toBe("<ul><li>one</li><li>two</li></ul>");
351
+ const r = renderModuleWithContent(PARENT_MOD_ID, "edit:content#0", resolver, undefined, lists);
352
+ expect(r.html).toContain(`instance/${PARENT_CI}/tags/`);
341
353
  });
342
354
 
343
- it("text-list falls back to field.default when values entry is absent", () => {
355
+ it("falls back to the field default when the values entry is absent", () => {
344
356
  const resolver = buildResolver(
345
357
  [
346
358
  {
347
359
  moduleId: PARENT_MOD_ID,
348
360
  slug: "tag-cloud",
349
- html: "<div>{{#tags}}<span>{{.}}</span>{{/tags}}</div>",
361
+ html: "<div>{{#tags}}{{text}};{{/tags}}</div>",
350
362
  css: "",
351
363
  js: "",
352
364
  fields: [
353
- {
354
- name: "tags",
355
- kind: "text-list",
356
- default: ["alpha", "beta"],
357
- },
365
+ { name: "tags", kind: "list", listType: "texts", default: texts("alpha", "beta") },
358
366
  ],
359
367
  },
360
368
  ],
361
369
  [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
362
370
  );
363
- const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
364
- expect(r.html).toBe("<div><span>alpha</span><span>beta</span></div>");
371
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
372
+ expect(r.html).toBe("<div>alpha;beta;</div>");
365
373
  });
366
374
 
367
- it("link-list iterates {{label}} + {{href}} per element", () => {
375
+ it("renders a list entry's embedded module through the recursion (depth + cycle guards apply)", () => {
368
376
  const resolver = buildResolver(
369
377
  [
370
378
  {
371
379
  moduleId: PARENT_MOD_ID,
372
- slug: "primary-nav",
373
- html: '<nav>{{#menu}}<a href="{{href}}">{{label}}</a>{{/menu}}</nav>',
380
+ slug: "mega",
381
+ html: "<nav>{{#mega}}{{#columns}}{{#is_promo}}<div>{{>teaser}}</div>{{/is_promo}}{{/columns}}{{/mega}}</nav>",
382
+ css: "",
383
+ js: "",
384
+ fields: [
385
+ {
386
+ name: "mega",
387
+ kind: "list",
388
+ listType: "mega-menu",
389
+ bind: "shared",
390
+ list: `caelo:list/${LIST.mega}`,
391
+ },
392
+ ],
393
+ },
394
+ {
395
+ moduleId: MODULE.teaser,
396
+ slug: "teaser",
397
+ html: "<p>{{title}}</p>",
374
398
  css: "",
375
399
  js: "",
376
- fields: [{ name: "menu", kind: "link-list" }],
400
+ fields: [{ name: "title", kind: "text" }],
377
401
  },
378
402
  ],
379
403
  [
380
- {
381
- id: PARENT_CI_ID,
382
- moduleId: PARENT_MOD_ID,
383
- values: {
384
- menu: [
385
- { label: "Home", href: "/" },
386
- { label: "About", href: "/about" },
387
- { label: "Contact", href: "/contact" },
388
- ],
389
- },
390
- deletedAt: null,
391
- },
404
+ { id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null },
405
+ { id: INSTANCE.teaser, moduleId: MODULE.teaser, values: { title: "Try" }, deletedAt: null },
392
406
  ],
393
407
  );
394
- const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
408
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
395
409
  expect(r.html).toBe(
396
- '<nav><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav>',
410
+ `<nav><div data-caelo-list-item="${LIST.mega}/${ITEM.promo}"><p ${tag(MODULE.teaser)}>Try</p></div></nav>`,
397
411
  );
412
+ expect(r.touchedModuleIds.has(MODULE.teaser)).toBe(true);
398
413
  expect(r.missingSlots).toEqual([]);
399
414
  });
400
415
 
401
- it("link-list malformed element gets a per-element marker, others render", () => {
416
+ it("a broken embedded module is attributed to the list entry", () => {
402
417
  const resolver = buildResolver(
403
418
  [
404
419
  {
405
420
  moduleId: PARENT_MOD_ID,
406
- slug: "primary-nav",
407
- html: '<nav>{{#menu}}<a href="{{href}}">{{label}}</a>{{/menu}}</nav>',
421
+ slug: "mega",
422
+ html: "{{#mega}}{{#columns}}{{#is_promo}}{{>teaser}}{{/is_promo}}{{/columns}}{{/mega}}",
408
423
  css: "",
409
424
  js: "",
410
- fields: [{ name: "menu", kind: "link-list" }],
425
+ fields: [
426
+ {
427
+ name: "mega",
428
+ kind: "list",
429
+ listType: "mega-menu",
430
+ bind: "shared",
431
+ list: `caelo:list/${LIST.mega}`,
432
+ },
433
+ ],
411
434
  },
412
435
  ],
413
- [
414
- {
415
- id: PARENT_CI_ID,
416
- moduleId: PARENT_MOD_ID,
417
- values: {
418
- menu: [
419
- { label: "Home", href: "/" },
420
- { label: "missing-href" }, // malformed
421
- { label: "About", href: "/about" },
436
+ [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
437
+ );
438
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
439
+ expect(r.failures).toEqual([
440
+ { field: `mega[${ITEM.promo}].teaser`, reason: `module-missing:${MODULE.teaser}` },
441
+ ]);
442
+ });
443
+
444
+ it("collectNestedRefs finds module fields inside local list entries", () => {
445
+ expect(
446
+ collectNestedRefs({
447
+ mega: [
448
+ {
449
+ id: T(1),
450
+ node: "entry",
451
+ values: {},
452
+ children: [
453
+ {
454
+ id: T(2),
455
+ node: "promo",
456
+ values: { teaser: { moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser } },
457
+ },
422
458
  ],
423
459
  },
424
- deletedAt: null,
425
- },
426
- ],
427
- );
428
- const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
429
- expect(r.html).toContain('<a href="/">Home</a>');
430
- expect(r.html).toContain('<a href="/about">About</a>');
431
- expect(r.html).toContain("link-list-malformed");
432
- expect(r.missingSlots.some((s) => s.startsWith("link-list-malformed"))).toBe(true);
460
+ ],
461
+ }),
462
+ ).toEqual([{ moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser }]);
433
463
  });
434
464
  });
435
465
 
@@ -668,7 +698,7 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
668
698
  });
669
699
 
670
700
  it("kind-mismatch on {{>name}} against a list field emits the exact legacy string", () => {
671
- const expected = "kind-mismatch:items expected=module actual=text-list";
701
+ const expected = "kind-mismatch:items expected=module actual=list";
672
702
  const resolver = buildResolver(
673
703
  [
674
704
  {
@@ -677,7 +707,7 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
677
707
  html: "<aside>{{>items}}</aside>",
678
708
  css: "",
679
709
  js: "",
680
- fields: [{ name: "items", kind: "text-list" }],
710
+ fields: [{ name: "items", kind: "list", listType: "texts" }],
681
711
  },
682
712
  ],
683
713
  [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
@@ -713,32 +743,36 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
713
743
  expect(r.missingSlots).toContain("module-ref-malformed:hero");
714
744
  });
715
745
 
716
- it("text-list-malformed per element preserves the exact legacy marker", () => {
746
+ it("list-malformed names the entry path and keeps the section raw", () => {
717
747
  const resolver = buildResolver(
718
748
  [
719
749
  {
720
750
  moduleId: PARENT_MOD_ID,
721
- slug: "text-list-malformed",
722
- html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
751
+ slug: "list-malformed",
752
+ html: "<ul>{{#items}}<li>{{text}}</li>{{/items}}</ul>",
723
753
  css: "",
724
754
  js: "",
725
- fields: [{ name: "items", kind: "text-list" }],
755
+ fields: [{ name: "items", kind: "list", listType: "texts" }],
726
756
  },
727
757
  ],
728
758
  [
729
759
  {
730
760
  id: PARENT_CI_ID,
731
761
  moduleId: PARENT_MOD_ID,
732
- values: { items: ["a", { bogus: true }, "c"] },
762
+ values: {
763
+ items: [
764
+ { id: "00000000-0000-4000-9000-000000000001", node: "text", values: { text: "a" } },
765
+ { bogus: true },
766
+ ],
767
+ },
733
768
  deletedAt: null,
734
769
  },
735
770
  ],
736
771
  );
737
- const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
738
- expect(r.html).toContain("<li>a</li>");
739
- expect(r.html).toContain("<li>c</li>");
740
- expect(r.html).toContain("<!-- caelo:missing reason=text-list-malformed items[1] -->");
741
- expect(r.missingSlots).toContain("text-list-malformed:items[1]");
772
+ const lists = createListRenderContext(fixtureData()).forPage(null);
773
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver, undefined, lists);
774
+ expect(r.html).toBe("<ul>{{#items}}<li>{{text}}</li>{{/items}}</ul>");
775
+ expect(r.missingSlots).toContain("list-malformed:items[items[1]]");
742
776
  });
743
777
 
744
778
  it("module-list-malformed per element preserves the exact legacy marker", () => {