@caelo-cms/shared 0.10.72 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/dist/ai-tools.d.ts +103 -142
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +79 -167
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +115 -35
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +31 -12
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +214 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +219 -84
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-scope-scan.js +1 -1
  14. package/dist/css-var-aliases.d.ts +20 -0
  15. package/dist/css-var-aliases.d.ts.map +1 -0
  16. package/dist/css-var-aliases.js +95 -0
  17. package/dist/css-var-aliases.js.map +1 -0
  18. package/dist/css-var-scan.d.ts +0 -16
  19. package/dist/css-var-scan.d.ts.map +1 -1
  20. package/dist/css-var-scan.js +24 -0
  21. package/dist/css-var-scan.js.map +1 -1
  22. package/dist/edit-markers.d.ts +41 -5
  23. package/dist/edit-markers.d.ts.map +1 -1
  24. package/dist/edit-markers.js +47 -50
  25. package/dist/edit-markers.js.map +1 -1
  26. package/dist/escaped-line-breaks.d.ts +44 -0
  27. package/dist/escaped-line-breaks.d.ts.map +1 -0
  28. package/dist/escaped-line-breaks.js +203 -0
  29. package/dist/escaped-line-breaks.js.map +1 -0
  30. package/dist/index.d.ts +4 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interactions.js +6 -6
  35. package/dist/interactions.js.map +1 -1
  36. package/dist/link-canonicalise.d.ts.map +1 -1
  37. package/dist/link-canonicalise.js +7 -1
  38. package/dist/link-canonicalise.js.map +1 -1
  39. package/dist/link-refs.d.ts +1 -1
  40. package/dist/link-refs.js +3 -3
  41. package/dist/lists/address.d.ts +34 -0
  42. package/dist/lists/address.d.ts.map +1 -0
  43. package/dist/lists/address.js +43 -0
  44. package/dist/lists/address.js.map +1 -0
  45. package/dist/lists/default-types.d.ts +22 -0
  46. package/dist/lists/default-types.d.ts.map +1 -0
  47. package/dist/lists/default-types.js +127 -0
  48. package/dist/lists/default-types.js.map +1 -0
  49. package/dist/lists/fields.d.ts +152 -0
  50. package/dist/lists/fields.d.ts.map +1 -0
  51. package/dist/lists/fields.js +259 -0
  52. package/dist/lists/fields.js.map +1 -0
  53. package/dist/lists/index.d.ts +14 -0
  54. package/dist/lists/index.d.ts.map +1 -0
  55. package/dist/lists/index.js +15 -0
  56. package/dist/lists/index.js.map +1 -0
  57. package/dist/lists/item-attr.d.ts +26 -0
  58. package/dist/lists/item-attr.d.ts.map +1 -0
  59. package/dist/lists/item-attr.js +31 -0
  60. package/dist/lists/item-attr.js.map +1 -0
  61. package/dist/lists/items.d.ts +105 -0
  62. package/dist/lists/items.d.ts.map +1 -0
  63. package/dist/lists/items.js +304 -0
  64. package/dist/lists/items.js.map +1 -0
  65. package/dist/lists/list-type.d.ts +401 -0
  66. package/dist/lists/list-type.d.ts.map +1 -0
  67. package/dist/lists/list-type.js +275 -0
  68. package/dist/lists/list-type.js.map +1 -0
  69. package/dist/lists/model.d.ts +14 -0
  70. package/dist/lists/model.d.ts.map +1 -0
  71. package/dist/lists/model.js +15 -0
  72. package/dist/lists/model.js.map +1 -0
  73. package/dist/lists/page-type.d.ts +115 -0
  74. package/dist/lists/page-type.d.ts.map +1 -0
  75. package/dist/lists/page-type.js +66 -0
  76. package/dist/lists/page-type.js.map +1 -0
  77. package/dist/lists/refs.d.ts +13 -0
  78. package/dist/lists/refs.d.ts.map +1 -0
  79. package/dist/lists/refs.js +18 -0
  80. package/dist/lists/refs.js.map +1 -0
  81. package/dist/lists/render-data.d.ts +123 -0
  82. package/dist/lists/render-data.d.ts.map +1 -0
  83. package/dist/lists/render-data.js +3 -0
  84. package/dist/lists/render-data.js.map +1 -0
  85. package/dist/lists/render-deps.d.ts +69 -0
  86. package/dist/lists/render-deps.d.ts.map +1 -0
  87. package/dist/lists/render-deps.js +93 -0
  88. package/dist/lists/render-deps.js.map +1 -0
  89. package/dist/lists/resolve-values.d.ts +63 -0
  90. package/dist/lists/resolve-values.d.ts.map +1 -0
  91. package/dist/lists/resolve-values.js +181 -0
  92. package/dist/lists/resolve-values.js.map +1 -0
  93. package/dist/lists/resolve.d.ts +119 -0
  94. package/dist/lists/resolve.d.ts.map +1 -0
  95. package/dist/lists/resolve.js +970 -0
  96. package/dist/lists/resolve.js.map +1 -0
  97. package/dist/lists/scope.d.ts +81 -0
  98. package/dist/lists/scope.d.ts.map +1 -0
  99. package/dist/lists/scope.js +3 -0
  100. package/dist/lists/scope.js.map +1 -0
  101. package/dist/markup-structure.d.ts +8 -0
  102. package/dist/markup-structure.d.ts.map +1 -1
  103. package/dist/markup-structure.js +2 -0
  104. package/dist/markup-structure.js.map +1 -1
  105. package/dist/media.d.ts +1 -1
  106. package/dist/nested-module-render.d.ts +38 -20
  107. package/dist/nested-module-render.d.ts.map +1 -1
  108. package/dist/nested-module-render.js +156 -31
  109. package/dist/nested-module-render.js.map +1 -1
  110. package/dist/page-log.d.ts +1 -1
  111. package/dist/preview-compose.d.ts +24 -21
  112. package/dist/preview-compose.d.ts.map +1 -1
  113. package/dist/preview-compose.js +34 -118
  114. package/dist/preview-compose.js.map +1 -1
  115. package/dist/redirect-plan.d.ts +13 -0
  116. package/dist/redirect-plan.d.ts.map +1 -1
  117. package/dist/redirect-plan.js +22 -0
  118. package/dist/redirect-plan.js.map +1 -1
  119. package/dist/seo.d.ts +21 -0
  120. package/dist/seo.d.ts.map +1 -1
  121. package/dist/seo.js +32 -0
  122. package/dist/seo.js.map +1 -1
  123. package/dist/source-text-transport.d.ts +43 -0
  124. package/dist/source-text-transport.d.ts.map +1 -0
  125. package/dist/source-text-transport.js +79 -0
  126. package/dist/source-text-transport.js.map +1 -0
  127. package/dist/template-engine.d.ts +44 -31
  128. package/dist/template-engine.d.ts.map +1 -1
  129. package/dist/template-engine.js +75 -174
  130. package/dist/template-engine.js.map +1 -1
  131. package/dist/template-sections.d.ts +69 -0
  132. package/dist/template-sections.d.ts.map +1 -0
  133. package/dist/template-sections.js +436 -0
  134. package/dist/template-sections.js.map +1 -0
  135. package/dist/version.d.ts +2 -2
  136. package/dist/version.d.ts.map +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/version.js.map +1 -1
  139. package/package.json +8 -1
  140. package/src/__tests__/redos-hardening.test.ts +51 -16
  141. package/src/ai-tools-add-module-modes.test.ts +51 -1
  142. package/src/ai-tools.test.ts +7 -10
  143. package/src/ai-tools.ts +90 -180
  144. package/src/bug-intake.test.ts +1 -1
  145. package/src/build-page.test.ts +34 -3
  146. package/src/build-page.ts +34 -11
  147. package/src/content.test.ts +137 -4
  148. package/src/content.ts +230 -85
  149. package/src/css-scope-scan.ts +1 -1
  150. package/src/css-var-aliases.test.ts +61 -0
  151. package/src/css-var-aliases.ts +109 -0
  152. package/src/css-var-scan.ts +9 -0
  153. package/src/edit-markers.test.ts +37 -21
  154. package/src/edit-markers.ts +52 -20
  155. package/src/escaped-line-breaks.ts +221 -0
  156. package/src/index.ts +4 -1
  157. package/src/interactions.ts +6 -6
  158. package/src/link-canonicalise.test.ts +36 -6
  159. package/src/link-canonicalise.ts +6 -1
  160. package/src/link-refs.test.ts +67 -42
  161. package/src/link-refs.ts +3 -3
  162. package/src/lists/address.ts +75 -0
  163. package/src/lists/default-types.ts +141 -0
  164. package/src/lists/fields.ts +286 -0
  165. package/src/lists/index.ts +16 -0
  166. package/src/lists/item-attr.test.ts +36 -0
  167. package/src/lists/item-attr.ts +49 -0
  168. package/src/lists/items.ts +402 -0
  169. package/src/lists/list-fixtures.test.ts +308 -0
  170. package/src/lists/list-type.ts +306 -0
  171. package/src/lists/lists.test.ts +328 -0
  172. package/src/lists/model.ts +16 -0
  173. package/src/lists/page-type.ts +71 -0
  174. package/src/lists/refs.ts +21 -0
  175. package/src/lists/render-data.ts +134 -0
  176. package/src/lists/render-deps.test.ts +132 -0
  177. package/src/lists/render-deps.ts +147 -0
  178. package/src/lists/resolve-values.ts +224 -0
  179. package/src/lists/resolve.test.ts +472 -0
  180. package/src/lists/resolve.ts +1246 -0
  181. package/src/lists/scope.ts +69 -0
  182. package/src/lists-compose-parity.test.ts +262 -0
  183. package/src/markup-structure.ts +10 -0
  184. package/src/nested-module-render.test.ts +147 -74
  185. package/src/nested-module-render.ts +120 -48
  186. package/src/preview-compose.test.ts +55 -19
  187. package/src/preview-compose.ts +59 -154
  188. package/src/redirect-plan.test.ts +25 -0
  189. package/src/redirect-plan.ts +24 -0
  190. package/src/seo.test.ts +58 -0
  191. package/src/seo.ts +49 -0
  192. package/src/source-text-transport.test.ts +158 -0
  193. package/src/source-text-transport.ts +81 -0
  194. package/src/template-engine-lists.test.ts +608 -0
  195. package/src/template-engine.test.ts +62 -80
  196. package/src/template-engine.ts +102 -245
  197. package/src/template-sections.ts +514 -0
  198. package/src/version.ts +1 -1
  199. package/dist/structured-sets.d.ts +0 -70
  200. package/dist/structured-sets.d.ts.map +0 -1
  201. package/dist/structured-sets.js +0 -92
  202. package/dist/structured-sets.js.map +0 -1
  203. package/src/structured-sets.ts +0 -114
  204. package/src/template-engine-data-lists.test.ts +0 -90
@@ -0,0 +1,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
  };