@caelo-cms/shared 0.10.71 → 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 (202) 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 +50 -38
  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 +26 -7
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +203 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +178 -76
  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/id-safe-truncate.d.ts +15 -0
  26. package/dist/id-safe-truncate.d.ts.map +1 -0
  27. package/dist/id-safe-truncate.js +47 -0
  28. package/dist/id-safe-truncate.js.map +1 -0
  29. package/dist/index.d.ts +5 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/interactions.js +2 -2
  34. package/dist/interactions.js.map +1 -1
  35. package/dist/link-canonicalise.d.ts.map +1 -1
  36. package/dist/link-canonicalise.js +7 -1
  37. package/dist/link-canonicalise.js.map +1 -1
  38. package/dist/link-refs.d.ts +1 -1
  39. package/dist/link-refs.js +3 -3
  40. package/dist/lists/address.d.ts +34 -0
  41. package/dist/lists/address.d.ts.map +1 -0
  42. package/dist/lists/address.js +43 -0
  43. package/dist/lists/address.js.map +1 -0
  44. package/dist/lists/default-types.d.ts +10 -0
  45. package/dist/lists/default-types.d.ts.map +1 -0
  46. package/dist/lists/default-types.js +91 -0
  47. package/dist/lists/default-types.js.map +1 -0
  48. package/dist/lists/fields.d.ts +152 -0
  49. package/dist/lists/fields.d.ts.map +1 -0
  50. package/dist/lists/fields.js +258 -0
  51. package/dist/lists/fields.js.map +1 -0
  52. package/dist/lists/index.d.ts +14 -0
  53. package/dist/lists/index.d.ts.map +1 -0
  54. package/dist/lists/index.js +15 -0
  55. package/dist/lists/index.js.map +1 -0
  56. package/dist/lists/item-attr.d.ts +26 -0
  57. package/dist/lists/item-attr.d.ts.map +1 -0
  58. package/dist/lists/item-attr.js +31 -0
  59. package/dist/lists/item-attr.js.map +1 -0
  60. package/dist/lists/items.d.ts +105 -0
  61. package/dist/lists/items.d.ts.map +1 -0
  62. package/dist/lists/items.js +304 -0
  63. package/dist/lists/items.js.map +1 -0
  64. package/dist/lists/list-type.d.ts +401 -0
  65. package/dist/lists/list-type.d.ts.map +1 -0
  66. package/dist/lists/list-type.js +275 -0
  67. package/dist/lists/list-type.js.map +1 -0
  68. package/dist/lists/model.d.ts +14 -0
  69. package/dist/lists/model.d.ts.map +1 -0
  70. package/dist/lists/model.js +15 -0
  71. package/dist/lists/model.js.map +1 -0
  72. package/dist/lists/page-type.d.ts +115 -0
  73. package/dist/lists/page-type.d.ts.map +1 -0
  74. package/dist/lists/page-type.js +66 -0
  75. package/dist/lists/page-type.js.map +1 -0
  76. package/dist/lists/refs.d.ts +13 -0
  77. package/dist/lists/refs.d.ts.map +1 -0
  78. package/dist/lists/refs.js +18 -0
  79. package/dist/lists/refs.js.map +1 -0
  80. package/dist/lists/render-data.d.ts +114 -0
  81. package/dist/lists/render-data.d.ts.map +1 -0
  82. package/dist/lists/render-data.js +3 -0
  83. package/dist/lists/render-data.js.map +1 -0
  84. package/dist/lists/render-deps.d.ts +68 -0
  85. package/dist/lists/render-deps.d.ts.map +1 -0
  86. package/dist/lists/render-deps.js +91 -0
  87. package/dist/lists/render-deps.js.map +1 -0
  88. package/dist/lists/resolve-values.d.ts +63 -0
  89. package/dist/lists/resolve-values.d.ts.map +1 -0
  90. package/dist/lists/resolve-values.js +181 -0
  91. package/dist/lists/resolve-values.js.map +1 -0
  92. package/dist/lists/resolve.d.ts +106 -0
  93. package/dist/lists/resolve.d.ts.map +1 -0
  94. package/dist/lists/resolve.js +878 -0
  95. package/dist/lists/resolve.js.map +1 -0
  96. package/dist/lists/scope.d.ts +81 -0
  97. package/dist/lists/scope.d.ts.map +1 -0
  98. package/dist/lists/scope.js +3 -0
  99. package/dist/lists/scope.js.map +1 -0
  100. package/dist/markup-structure.d.ts +8 -0
  101. package/dist/markup-structure.d.ts.map +1 -1
  102. package/dist/markup-structure.js +2 -0
  103. package/dist/markup-structure.js.map +1 -1
  104. package/dist/media.d.ts +1 -1
  105. package/dist/nested-module-render.d.ts +25 -20
  106. package/dist/nested-module-render.d.ts.map +1 -1
  107. package/dist/nested-module-render.js +122 -31
  108. package/dist/nested-module-render.js.map +1 -1
  109. package/dist/page-log.d.ts +1 -1
  110. package/dist/preview-compose.d.ts +24 -21
  111. package/dist/preview-compose.d.ts.map +1 -1
  112. package/dist/preview-compose.js +34 -118
  113. package/dist/preview-compose.js.map +1 -1
  114. package/dist/preview-scanner.d.ts +5 -2
  115. package/dist/preview-scanner.d.ts.map +1 -1
  116. package/dist/preview-scanner.js +5 -3
  117. package/dist/preview-scanner.js.map +1 -1
  118. package/dist/source-text-transport.d.ts +17 -0
  119. package/dist/source-text-transport.d.ts.map +1 -0
  120. package/dist/source-text-transport.js +71 -0
  121. package/dist/source-text-transport.js.map +1 -0
  122. package/dist/template-engine.d.ts +44 -31
  123. package/dist/template-engine.d.ts.map +1 -1
  124. package/dist/template-engine.js +75 -174
  125. package/dist/template-engine.js.map +1 -1
  126. package/dist/template-sections.d.ts +69 -0
  127. package/dist/template-sections.d.ts.map +1 -0
  128. package/dist/template-sections.js +423 -0
  129. package/dist/template-sections.js.map +1 -0
  130. package/dist/uuid-diagnose.d.ts +9 -0
  131. package/dist/uuid-diagnose.d.ts.map +1 -0
  132. package/dist/uuid-diagnose.js +47 -0
  133. package/dist/uuid-diagnose.js.map +1 -0
  134. package/dist/version.d.ts +2 -2
  135. package/dist/version.d.ts.map +1 -1
  136. package/dist/version.js +1 -1
  137. package/dist/version.js.map +1 -1
  138. package/package.json +8 -1
  139. package/src/__tests__/redos-hardening.test.ts +50 -16
  140. package/src/ai-tools-add-module-modes.test.ts +50 -0
  141. package/src/ai-tools.test.ts +10 -0
  142. package/src/ai-tools.ts +53 -39
  143. package/src/build-page.test.ts +34 -3
  144. package/src/build-page.ts +29 -6
  145. package/src/content.test.ts +111 -3
  146. package/src/content.ts +188 -77
  147. package/src/css-var-aliases.test.ts +61 -0
  148. package/src/css-var-aliases.ts +109 -0
  149. package/src/css-var-scan.ts +9 -0
  150. package/src/edit-markers.test.ts +37 -21
  151. package/src/edit-markers.ts +52 -20
  152. package/src/id-safe-truncate.test.ts +35 -0
  153. package/src/id-safe-truncate.ts +46 -0
  154. package/src/index.ts +5 -1
  155. package/src/interactions.ts +2 -2
  156. package/src/link-canonicalise.test.ts +36 -6
  157. package/src/link-canonicalise.ts +6 -1
  158. package/src/link-refs.test.ts +66 -42
  159. package/src/link-refs.ts +3 -3
  160. package/src/lists/address.ts +75 -0
  161. package/src/lists/default-types.ts +102 -0
  162. package/src/lists/fields.ts +285 -0
  163. package/src/lists/index.ts +16 -0
  164. package/src/lists/item-attr.test.ts +36 -0
  165. package/src/lists/item-attr.ts +49 -0
  166. package/src/lists/items.ts +402 -0
  167. package/src/lists/list-fixtures.test.ts +302 -0
  168. package/src/lists/list-type.ts +306 -0
  169. package/src/lists/lists.test.ts +328 -0
  170. package/src/lists/model.ts +16 -0
  171. package/src/lists/page-type.ts +71 -0
  172. package/src/lists/refs.ts +21 -0
  173. package/src/lists/render-data.ts +125 -0
  174. package/src/lists/render-deps.test.ts +132 -0
  175. package/src/lists/render-deps.ts +144 -0
  176. package/src/lists/resolve-values.ts +224 -0
  177. package/src/lists/resolve.test.ts +295 -0
  178. package/src/lists/resolve.ts +1148 -0
  179. package/src/lists/scope.ts +69 -0
  180. package/src/lists-compose-parity.test.ts +262 -0
  181. package/src/markup-structure.ts +10 -0
  182. package/src/nested-module-render.test.ts +111 -77
  183. package/src/nested-module-render.ts +85 -48
  184. package/src/preview-compose.test.ts +74 -19
  185. package/src/preview-compose.ts +59 -154
  186. package/src/preview-scanner.test.ts +8 -4
  187. package/src/preview-scanner.ts +5 -3
  188. package/src/source-text-transport.test.ts +63 -0
  189. package/src/source-text-transport.ts +70 -0
  190. package/src/template-engine-lists.test.ts +553 -0
  191. package/src/template-engine.test.ts +62 -80
  192. package/src/template-engine.ts +102 -245
  193. package/src/template-sections.ts +500 -0
  194. package/src/uuid-diagnose.test.ts +28 -0
  195. package/src/uuid-diagnose.ts +50 -0
  196. package/src/version.ts +1 -1
  197. package/dist/structured-sets.d.ts +0 -70
  198. package/dist/structured-sets.d.ts.map +0 -1
  199. package/dist/structured-sets.js +0 -92
  200. package/dist/structured-sets.js.map +0 -1
  201. package/src/structured-sets.ts +0 -114
  202. package/src/template-engine-data-lists.test.ts +0 -90
@@ -34,10 +34,11 @@
34
34
  * values[fieldName] is
35
35
  * { moduleId, contentInstanceId }.
36
36
  * {{#fieldName}}…{{/fieldName}} — section over a list field
37
- * (kind = text-list / link-list
38
- * / module-list). text-list /
39
- * link-list iterate the inner
40
- * template; module-list ignores
37
+ * (kind = list / module-list).
38
+ * A `list` iterates the inner
39
+ * template per entry (an entry's
40
+ * `{{>f}}` module field recurses
41
+ * here too); module-list ignores
41
42
  * the inner and renders each
42
43
  * element's nested module HTML
43
44
  * via `renderInner` (recursion).
@@ -60,8 +61,8 @@
60
61
  * of band so the page's <head>/<style> + footer scripts are stable.
61
62
  */
62
63
 
63
- import type { ModuleFieldKind } from "./content.js";
64
64
  import { isLinkFailureMarker, type LinkResolver } from "./link-refs.js";
65
+ import type { ListPageContext } from "./lists/resolve.js";
65
66
  import { tagModuleId } from "./module-id-tag.js";
66
67
  import { caeloMissingComment, renderTemplate, type TemplateField } from "./template-engine.js";
67
68
 
@@ -74,11 +75,7 @@ export interface NestedModuleResource {
74
75
  readonly html: string;
75
76
  readonly css: string;
76
77
  readonly js: string;
77
- readonly fields: readonly {
78
- readonly name: string;
79
- readonly kind: ModuleFieldKind;
80
- readonly default?: unknown;
81
- }[];
78
+ readonly fields: readonly TemplateField[];
82
79
  }
83
80
 
84
81
  /** A content instance the recursion can render a nested module with. */
@@ -87,6 +84,13 @@ export interface NestedContentInstanceResource {
87
84
  readonly moduleId: string;
88
85
  readonly values: Record<string, unknown>;
89
86
  readonly deletedAt: string | null;
87
+ /**
88
+ * The stored content instance these values belong to, when `id` is a
89
+ * render-local alias (the editor's edit-marked copy `edit:<key>`):
90
+ * local list entries are addressed by the real instance id (overlays,
91
+ * `data-caelo-list-item`). Absent = `id` itself.
92
+ */
93
+ readonly listHolderId?: string;
90
94
  }
91
95
 
92
96
  /** The stored shape of a `module` field value / `module-list` element. */
@@ -159,11 +163,12 @@ interface RenderContext {
159
163
  * active theme on this install (renderer emits loud-raw for any
160
164
  * `{{theme_<slot>_url}}` placeholders). */
161
165
  readonly themeAssets: NestedRenderThemeAssets | undefined;
162
- /** Plugin data lists for the page being rendered, and the names of
163
- * installed-but-inactive plugins' lists. Threaded to every nested
164
- * module: a switcher can sit inside a nested chrome module. */
165
- readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
166
- readonly dormantDataLists: Readonly<Record<string, string>>;
166
+ /** The page's list context (`lists/resolve.ts`), threaded to every
167
+ * nested module so a list renders the same at any depth — a menu
168
+ * inside a nested chrome module included. */
169
+ readonly lists: ListPageContext | undefined;
170
+ /** Content instance whose values this level renders (local list address). */
171
+ readonly listHolder: string | null;
167
172
  /** Epic #724 — resolves internal references in every nested module. */
168
173
  readonly linkResolver: LinkResolver | undefined;
169
174
  }
@@ -197,10 +202,7 @@ export function renderModuleWithContent(
197
202
  contentInstanceId: string,
198
203
  resolver: NestedRenderResolver,
199
204
  themeAssets?: NestedRenderThemeAssets,
200
- pluginLists?: {
201
- readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
202
- readonly dormantDataLists: Readonly<Record<string, string>>;
203
- },
205
+ lists?: ListPageContext,
204
206
  linkResolver?: LinkResolver,
205
207
  ): NestedRenderResult {
206
208
  const touched = new Set<string>();
@@ -215,13 +217,15 @@ export function renderModuleWithContent(
215
217
  path: new Set<string>(),
216
218
  depth: 0,
217
219
  themeAssets,
218
- dataLists: pluginLists?.dataLists ?? {},
219
- dormantDataLists: pluginLists?.dormantDataLists ?? {},
220
+ lists,
221
+ listHolder: null,
220
222
  linkResolver,
221
223
  });
222
224
  return { html, touchedModuleIds: touched, missingSlots: missing, failures };
223
225
  }
224
226
 
227
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
228
+
225
229
  function renderInner(moduleId: string, contentInstanceId: string, ctx: RenderContext): string {
226
230
  if (ctx.depth >= MAX_RECURSION_DEPTH) {
227
231
  fail(ctx, `depth-limit:${moduleId}/${contentInstanceId}`);
@@ -263,8 +267,10 @@ function renderInner(moduleId: string, contentInstanceId: string, ctx: RenderCon
263
267
  path,
264
268
  depth: ctx.depth + 1,
265
269
  themeAssets: ctx.themeAssets,
266
- dataLists: ctx.dataLists,
267
- dormantDataLists: ctx.dormantDataLists,
270
+ lists: ctx.lists,
271
+ // Variant content (`variant:<key>`) is no stored instance, so its
272
+ // local list entries carry no instance address.
273
+ listHolder: ci.listHolderId ?? (UUID_RE.test(ci.id) ? ci.id : null),
268
274
  linkResolver: ctx.linkResolver,
269
275
  };
270
276
 
@@ -286,6 +292,9 @@ function renderInner(moduleId: string, contentInstanceId: string, ctx: RenderCon
286
292
  * HTML for element i. Malformed elements (non-NestedRef shape)
287
293
  * resolve to `undefined` — the engine emits the existing
288
294
  * `module-list-malformed:<name>[<i>]` marker.
295
+ * - `{{>f}}` inside an entry of list field `<name>`:
296
+ * `list:<name>/<itemId>/<f>`, with the ref passed alongside (the
297
+ * entry may come from a shared list, not from `values`).
289
298
  *
290
299
  * Failure-marker parity: every literal `missingSlots` string the
291
300
  * legacy hand-rolled substitution emitted is preserved — half by the
@@ -308,8 +317,8 @@ function substituteWithRecursion(
308
317
  // stays off the page. Memoised: `{{>name}}` twice renders it once.
309
318
  const fieldByName = new Map(fields.map((f) => [f.name, f]));
310
319
  const rendered = new Map<string, string | undefined>();
311
- const partials = (key: string): string | undefined => {
312
- if (!rendered.has(key)) rendered.set(key, renderPartial(key, fieldByName, values, ctx));
320
+ const partials = (key: string, ref?: NestedRefValue): string | undefined => {
321
+ if (!rendered.has(key)) rendered.set(key, renderPartial(key, ref, fieldByName, values, ctx));
313
322
  return rendered.get(key);
314
323
  };
315
324
 
@@ -319,8 +328,8 @@ function substituteWithRecursion(
319
328
  contentValues: values,
320
329
  partials,
321
330
  themeAssets: ctx.themeAssets,
322
- dataLists: ctx.dataLists,
323
- dormantDataLists: ctx.dormantDataLists,
331
+ lists: ctx.lists,
332
+ listHolder: { contentInstanceId: ctx.listHolder },
324
333
  linkResolver: ctx.linkResolver,
325
334
  });
326
335
  for (const m of result.missingSlots) {
@@ -335,18 +344,20 @@ function substituteWithRecursion(
335
344
  }
336
345
 
337
346
  /**
338
- * Render the partial the engine asked for: `<name>` (a `module` field)
339
- * or `<name>__<i>` (element i of a `module-list`). `undefined` when the
340
- * value is not a well-formed ref — the engine then emits its own
341
- * `module-ref-malformed` / `module-list-malformed` marker.
347
+ * Render the partial the engine asked for: `<name>` (a `module` field),
348
+ * `<name>__<i>` (element i of a `module-list`) or
349
+ * `list:<name>/<itemId>/<f>` (a list entry's module field, `ref` given).
350
+ * `undefined` when the value is not a well-formed ref — the engine then
351
+ * emits its own `module-ref-malformed` / `module-list-malformed` marker.
342
352
  */
343
353
  function renderPartial(
344
354
  key: string,
355
+ ref: NestedRefValue | undefined,
345
356
  fieldByName: ReadonlyMap<string, TemplateField>,
346
357
  values: Readonly<Record<string, unknown>>,
347
358
  ctx: RenderContext,
348
359
  ): string | undefined {
349
- const hit = nestedRefForPartial(key, fieldByName, values);
360
+ const hit = nestedRefForPartial(key, ref, fieldByName, values);
350
361
  if (!hit) return undefined;
351
362
  // #1027 — a nested module's root carries its own id, exactly like a
352
363
  // page placement's (the composer tags those), so the live-edit overlay
@@ -366,9 +377,15 @@ function renderPartial(
366
377
  */
367
378
  function nestedRefForPartial(
368
379
  key: string,
380
+ ref: NestedRefValue | undefined,
369
381
  fieldByName: ReadonlyMap<string, TemplateField>,
370
382
  values: Readonly<Record<string, unknown>>,
371
383
  ): { readonly ref: NestedRefValue; readonly field: string; readonly step: string } | undefined {
384
+ if (key.startsWith(LIST_PARTIAL_PREFIX)) {
385
+ const [name, itemId, entryField] = key.slice(LIST_PARTIAL_PREFIX.length).split("/");
386
+ if (!name || !itemId || !entryField || !ref || !isNestedRef(ref)) return undefined;
387
+ return { ref, field: name, step: `${name}[${itemId}].${entryField}` };
388
+ }
372
389
  const single = fieldByName.get(key);
373
390
  if (single?.kind === "module") {
374
391
  const ref = values[key];
@@ -387,6 +404,9 @@ function nestedRefForPartial(
387
404
  return isNestedRef(el) ? { ref: el, field: name, step: `${name}[${i}]` } : undefined;
388
405
  }
389
406
 
407
+ /** Partial-key prefix of a list entry's embedded module (`template-sections.ts`). */
408
+ const LIST_PARTIAL_PREFIX = "list:";
409
+
390
410
  /** A nested ref a module's template dispatches (see {@link dispatchedNestedRefs}). */
391
411
  export interface DispatchedNestedRef extends NestedRefValue {
392
412
  /** The `module` / `module-list` field holding it. */
@@ -408,6 +428,7 @@ export function dispatchedNestedRefs(
408
428
  html: string,
409
429
  fields: readonly TemplateField[],
410
430
  values: Readonly<Record<string, unknown>>,
431
+ lists?: ListPageContext,
411
432
  ): DispatchedNestedRef[] {
412
433
  const fieldByName = new Map(fields.map((f) => [f.name, f]));
413
434
  const out: DispatchedNestedRef[] = [];
@@ -416,8 +437,9 @@ export function dispatchedNestedRefs(
416
437
  html,
417
438
  fields,
418
439
  contentValues: values,
419
- partials: (key) => {
420
- const hit = nestedRefForPartial(key, fieldByName, values);
440
+ ...(lists ? { lists } : {}),
441
+ partials: (key, ref) => {
442
+ const hit = nestedRefForPartial(key, ref, fieldByName, values);
421
443
  if (!hit) return undefined;
422
444
  if (!asked.has(key)) {
423
445
  asked.add(key);
@@ -461,8 +483,10 @@ export function renderPlacedModule(
461
483
  resolver: NestedRenderResolver,
462
484
  options: {
463
485
  readonly themeAssets?: NestedRenderThemeAssets;
464
- readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
465
- readonly dormantDataLists?: Readonly<Record<string, string>>;
486
+ /** The page's list context (`lists/resolve.ts`). */
487
+ readonly lists?: ListPageContext;
488
+ /** The placement's stored content instance (local list address); absent for defaults. */
489
+ readonly contentInstanceId?: string;
466
490
  /** Epic #724 — resolves internal references (see `link-refs.ts`). */
467
491
  readonly linkResolver?: LinkResolver;
468
492
  },
@@ -482,8 +506,8 @@ export function renderPlacedModule(
482
506
  path: new Set<string>(),
483
507
  depth: 1,
484
508
  themeAssets: options.themeAssets,
485
- dataLists: options.dataLists ?? {},
486
- dormantDataLists: options.dormantDataLists ?? {},
509
+ lists: options.lists,
510
+ listHolder: options.contentInstanceId ?? null,
487
511
  linkResolver: options.linkResolver,
488
512
  });
489
513
  return { html, touchedModuleIds: touched, missingSlots: missing, failures };
@@ -491,19 +515,32 @@ export function renderPlacedModule(
491
515
 
492
516
  /**
493
517
  * Walk a content_instance's values to find every nested-module reference
494
- * it carries (single + list shapes). Used by the caller to pre-batch
495
- * the modules + content_instances the page will need.
518
+ * it carries: `module` / `module-list` values and the `module` fields of
519
+ * local list entries at any depth (`{ id, node, values, children }`).
520
+ * Used by the caller to pre-batch the modules + content_instances the
521
+ * page will need.
496
522
  */
497
- export function collectNestedRefs(values: Record<string, unknown>): NestedRefValue[] {
523
+ export function collectNestedRefs(values: Readonly<Record<string, unknown>>): NestedRefValue[] {
498
524
  const refs: NestedRefValue[] = [];
499
- for (const v of Object.values(values)) {
525
+ const visit = (v: unknown, depth: number): void => {
526
+ if (depth > MAX_COLLECT_DEPTH || typeof v !== "object" || v === null) return;
500
527
  if (isNestedRef(v)) {
501
- refs.push(v);
502
- } else if (Array.isArray(v)) {
503
- for (const el of v) {
504
- if (isNestedRef(el)) refs.push(el);
505
- }
528
+ refs.push({ moduleId: v.moduleId, contentInstanceId: v.contentInstanceId });
529
+ return;
506
530
  }
507
- }
531
+ if (Array.isArray(v)) {
532
+ for (const el of v) visit(el, depth + 1);
533
+ return;
534
+ }
535
+ const entry = v as { values?: unknown; children?: unknown };
536
+ if (typeof entry.values === "object" && entry.values !== null) {
537
+ for (const inner of Object.values(entry.values)) visit(inner, depth + 1);
538
+ }
539
+ if (Array.isArray(entry.children)) visit(entry.children, depth + 1);
540
+ };
541
+ for (const v of Object.values(values)) visit(v, 0);
508
542
  return refs;
509
543
  }
544
+
545
+ /** Bound for {@link collectNestedRefs}: list depth (8) × two levels per entry, plus slack. */
546
+ const MAX_COLLECT_DEPTH = 24;
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import { INTERACTIONS } from "./interactions.js";
5
+ import { fixtureData, fixtureLinkIndex, ITEM, LIST } from "./lists/list-fixtures.test.js";
6
+ import { createListRenderContext } from "./lists/resolve.js";
5
7
  import {
6
8
  ComposeError,
7
9
  composePagePreview,
@@ -19,6 +21,10 @@ const blankModule = {
19
21
  js: "",
20
22
  };
21
23
 
24
+ /** List context over the fixture site, rendered outside any page. */
25
+ const LISTS = createListRenderContext(fixtureData()).forPage(null);
26
+ const uid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
27
+
22
28
  describe("composePagePreview", () => {
23
29
  it("inlines module HTML into named slots and stamps CSS/JS into head/body", () => {
24
30
  const out = composePagePreview({
@@ -279,16 +285,17 @@ describe("composePagePreview substitutes {{name}} placeholders with field defaul
279
285
 
280
286
  describe("composePagePreview list-iteration via shared template engine (#71)", () => {
281
287
  // PR #71 / Plan B — the compose path now routes through the shared
282
- // template engine and gains text-list / link-list / module-list
283
- // section support. The fixtures below are the AC verbatim shapes;
288
+ // template engine and gains list / module-list section support
289
+ // (`list` since epic #1107). The fixtures below are the AC verbatim shapes;
284
290
  // failures here imply the static-generator is leaking raw mustache
285
291
  // markers to visitors again (the bug #71 closes).
286
292
  const slot = `<body><caelo-slot name="content">_</caelo-slot></body>`;
287
293
 
288
- it("AC #1 — link-list iterates per element inside composed page", () => {
294
+ it("AC #1 — a links list iterates per entry inside composed page", () => {
289
295
  const out = composePagePreview({
290
296
  templateHtml: slot,
291
297
  templateCss: "",
298
+ lists: LISTS,
292
299
  blocks: [
293
300
  {
294
301
  blockName: "content",
@@ -297,14 +304,14 @@ describe("composePagePreview list-iteration via shared template engine (#71)", (
297
304
  moduleId: "mod-nav",
298
305
  slug: "header",
299
306
  displayName: "Header",
300
- html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
307
+ html: '<nav>{{#nav_items}}<a href="{{link}}">{{label}}</a>{{/nav_items}}</nav>',
301
308
  css: "",
302
309
  js: "",
303
- fields: [{ name: "nav_items", kind: "link-list" }],
310
+ fields: [{ name: "nav_items", kind: "list", listType: "links" }],
304
311
  contentValues: {
305
312
  nav_items: [
306
- { label: "Docs", href: "/docs" },
307
- { label: "Blog", href: "/blog" },
313
+ { id: uid(1), node: "link", values: { label: "Docs", link: { href: "/docs" } } },
314
+ { id: uid(2), node: "link", values: { label: "Blog", link: { href: "/blog" } } },
308
315
  ],
309
316
  },
310
317
  },
@@ -315,18 +322,23 @@ describe("composePagePreview list-iteration via shared template engine (#71)", (
315
322
  // Outermost element is tagged with data-caelo-module-id; assert
316
323
  // the section iterated and that no raw markers leaked into the
317
324
  // composed HTML.
318
- expect(out.html).toContain('<a href="/docs">Docs</a>');
319
- expect(out.html).toContain('<a href="/blog">Blog</a>');
325
+ expect(out.html).toContain(
326
+ `<a href="/docs" data-caelo-list-item="local/nav_items/${uid(1)}">Docs</a>`,
327
+ );
328
+ expect(out.html).toContain(
329
+ `<a href="/blog" data-caelo-list-item="local/nav_items/${uid(2)}">Blog</a>`,
330
+ );
320
331
  expect(out.html).not.toMatch(/\{\{[#/]/);
321
332
  expect(out.html).not.toMatch(/\{\{label/);
322
333
  expect(out.html).not.toMatch(/\{\{href/);
323
334
  expect(out.html).toContain('data-caelo-module-id="mod-nav"');
324
335
  });
325
336
 
326
- it("AC #2 — text-list iterates {{.}} per element inside composed page", () => {
337
+ it("AC #2 — a texts list iterates per entry inside composed page", () => {
327
338
  const out = composePagePreview({
328
339
  templateHtml: slot,
329
340
  templateCss: "",
341
+ lists: LISTS,
330
342
  blocks: [
331
343
  {
332
344
  blockName: "content",
@@ -335,17 +347,25 @@ describe("composePagePreview list-iteration via shared template engine (#71)", (
335
347
  moduleId: "mod-tags",
336
348
  slug: "tags",
337
349
  displayName: "Tags",
338
- html: "<ul>{{#tags}}<li>{{.}}</li>{{/tags}}</ul>",
350
+ html: "<ul>{{#tags}}<li>{{text}}</li>{{/tags}}</ul>",
339
351
  css: "",
340
352
  js: "",
341
- fields: [{ name: "tags", kind: "text-list" }],
342
- contentValues: { tags: ["a", "b", "c"] },
353
+ fields: [{ name: "tags", kind: "list", listType: "texts" }],
354
+ contentValues: {
355
+ tags: ["a", "b", "c"].map((text, i) => ({
356
+ id: uid(i + 1),
357
+ node: "text",
358
+ values: { text },
359
+ })),
360
+ },
343
361
  },
344
362
  ],
345
363
  },
346
364
  ],
347
365
  });
348
- expect(out.html).toContain("<li>a</li><li>b</li><li>c</li>");
366
+ expect(out.html.replace(/ data-caelo-list-item="[^"]*"/g, "")).toContain(
367
+ "<li>a</li><li>b</li><li>c</li>",
368
+ );
349
369
  expect(out.html).not.toMatch(/\{\{[#/]/);
350
370
  expect(out.html).toContain('data-caelo-module-id="mod-tags"');
351
371
  });
@@ -432,6 +452,25 @@ describe("composePageWithLayout", () => {
432
452
  const layoutHtml = `<!doctype html><html><head></head><body><header><caelo-slot name="header">_</caelo-slot></header><main><caelo-slot name="content">_</caelo-slot></main></body></html>`;
433
453
  const templateHtml = `<body><caelo-slot name="content">_</caelo-slot></body>`;
434
454
 
455
+ // #1085 — an empty header block and an empty content block showed the
456
+ // authored "_" placeholders on the composed page visitors get.
457
+ it("renders empty blocks empty, never the authored slot placeholder", () => {
458
+ const out = composePageWithLayout({
459
+ templateHtml,
460
+ templateCss: "",
461
+ blocks: [],
462
+ layoutHtml,
463
+ layoutCss: "",
464
+ layoutBlocks: [],
465
+ layoutSlug: "test",
466
+ });
467
+ const body = out.html.slice(out.html.indexOf("<body>"));
468
+ expect(body).toContain(`<caelo-slot name="header"></caelo-slot>`);
469
+ expect(body).toContain(`<caelo-slot name="content"></caelo-slot>`);
470
+ expect(body).not.toContain("_");
471
+ expect(out.missingSlots).toContain("header");
472
+ });
473
+
435
474
  it("aggregates CSS in layout → template → modules order", () => {
436
475
  const out = composePageWithLayout({
437
476
  templateHtml,
@@ -506,11 +545,25 @@ describe("composePageWithLayout", () => {
506
545
  });
507
546
 
508
547
  it("renders a responsive nav with once-per-page functional assets (issue #160)", () => {
548
+ // The module owns the markup (epic #1107 — no built-in menu renderer);
549
+ // the interaction kit owns the behaviour.
509
550
  const navModule = {
510
551
  ...blankModule,
511
552
  moduleId: "33333333-3333-4333-8333-333333333303",
512
- slug: "nav-menu-header",
513
- html: "<nav>replaced</nav>",
553
+ slug: "header-nav",
554
+ html:
555
+ '<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false">' +
556
+ '<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"></button>' +
557
+ '<ul>{{#menu}}<li><a href="{{link}}">{{label}}</a></li>{{/menu}}</ul></nav>',
558
+ fields: [
559
+ {
560
+ name: "menu",
561
+ kind: "list" as const,
562
+ listType: "menu",
563
+ bind: "shared" as const,
564
+ list: `caelo:list/${LIST.header}`,
565
+ },
566
+ ],
514
567
  };
515
568
  const out = composePageWithLayout({
516
569
  templateHtml,
@@ -520,10 +573,12 @@ describe("composePageWithLayout", () => {
520
573
  layoutCss: "",
521
574
  layoutBlocks: [],
522
575
  layoutSlug: "test",
523
- structuredSets: {
524
- byKindSlug: { "nav-menu/header": [{ label: "Docs", href: "/docs" }] },
525
- },
576
+ lists: LISTS,
577
+ linkResolver: fixtureLinkIndex().forPage(null),
526
578
  });
579
+ expect(out.html).toContain(
580
+ `<li data-caelo-list-item="${LIST.header}/${ITEM.about}"><a href="/about/">About</a></li>`,
581
+ );
527
582
  expect(out.html).toContain('class="caelo-nav-toggle"');
528
583
  expect(out.html).toContain('aria-expanded="false"');
529
584
  expect(out.html).toContain('data-nav-open="false"');