@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
@@ -29,6 +29,7 @@ import { BASE_TECHNICAL_CSS } from "./base-css.js";
29
29
  import type { ModuleFieldKind } from "./content.js";
30
30
  import { findInteractionIds, interactionAssets } from "./interactions.js";
31
31
  import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
32
+ import type { ListPageContext } from "./lists/resolve.js";
32
33
  import { tagModuleId } from "./module-id-tag.js";
33
34
  import {
34
35
  type NestedModuleResource,
@@ -60,8 +61,8 @@ export interface ComposeModule {
60
61
  readonly js: string;
61
62
  /**
62
63
  * v0.4.0 module-field schema, extended in #71 to carry `kind` so
63
- * the shared template engine can dispatch text-list / link-list /
64
- * module-list / module sections. `kind` is optional for back-compat
64
+ * the shared template engine can dispatch list / module-list /
65
+ * module sections (a `list` field carries its binding too). `kind` is optional for back-compat
65
66
  * with callers that haven't been updated; the engine treats absent
66
67
  * kinds as primitives (the legacy compose-path behaviour).
67
68
  *
@@ -76,7 +77,7 @@ export interface ComposeModule {
76
77
  * here via the `contentValues` field below. Nested-module refs
77
78
  * render through `ComposeInput.nestedModules`.
78
79
  */
79
- readonly fields?: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[];
80
+ readonly fields?: readonly ComposeField[];
80
81
  /**
81
82
  * PR #61 follow-up — per-placement content values from the bound
82
83
  * `content_instances.values` jsonb. When present, the composer
@@ -87,20 +88,23 @@ export interface ComposeModule {
87
88
  * visitors — the bug e2e-livedit's second Stage caught.
88
89
  */
89
90
  readonly contentValues?: Readonly<Record<string, unknown>>;
91
+ /**
92
+ * The stored content instance `contentValues` come from — the address
93
+ * of the module's local list entries (overlays, `data-caelo-list-item`).
94
+ * Absent for site chrome rendered from field defaults and for content
95
+ * variants, which are no stored instance.
96
+ */
97
+ readonly contentInstanceId?: string;
90
98
  }
91
99
 
100
+ /** A module field as the composer passes it to the engine; `kind` absent = primitive. */
101
+ export type ComposeField = Omit<TemplateField, "kind"> & { readonly kind?: ModuleFieldKind };
102
+
92
103
  export interface ComposeBlock {
93
104
  readonly blockName: string;
94
105
  readonly modules: readonly ComposeModule[];
95
106
  }
96
107
 
97
- /** P6.7.5 — structured sets carried into the composer so nav-menu
98
- * modules render from typed items and theme tokens flow into <head>. */
99
- export interface ComposeStructuredSets {
100
- /** Map keyed by `<kind>/<slug>` (e.g. `nav-menu/header-main`). */
101
- readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
102
- }
103
-
104
108
  /**
105
109
  * v0.11.0 — Theme context resolved by the preview op + static generator
106
110
  * (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
@@ -149,7 +153,6 @@ export interface ComposeInput {
149
153
  readonly templateHtml: string;
150
154
  readonly templateCss: string;
151
155
  readonly blocks: readonly ComposeBlock[];
152
- readonly structuredSets?: ComposeStructuredSets;
153
156
  /**
154
157
  * v0.11.0 — active theme threaded through from the preview op + static
155
158
  * generator. Undefined when no theme row exists OR when the caller
@@ -160,13 +163,14 @@ export interface ComposeInput {
160
163
  /** issue #150 — resolved web fonts; undefined = system stacks only. */
161
164
  readonly fonts?: ComposeFonts;
162
165
  /**
163
- * Plugin-provided lists for the page being composed, plus the names
164
- * declared by installed-but-inactive plugins. Threaded so a module
165
- * iterating `{{#language_links}}` renders the same on deploy as in
166
- * the editor — including the loud marker when its plugin is off.
166
+ * The list view of the page being composed (`lists/resolve.ts`
167
+ * `createListRenderContext(data).forPage(pageId, …)`): resolves `list`
168
+ * fields — local, shared, query, system and plugin sources — and
169
+ * `{{page.*}}` in every placed, nested and chrome module alike, so a
170
+ * menu renders the same in the editor and on deploy, including the
171
+ * loud marker when its plugin source is off.
167
172
  */
168
- readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
169
- readonly dormantDataLists?: Readonly<Record<string, string>>;
173
+ readonly lists?: ListPageContext;
170
174
  /**
171
175
  * #450 — modules withheld by a plugin, keyed by module id. Resolved
172
176
  * by the host before composing; absent means the module renders
@@ -186,7 +190,7 @@ export interface ComposeInput {
186
190
  /**
187
191
  * Epic #724 — resolves internal references (`caelo:page/<id>`,
188
192
  * `caelo:media/<id>`) for THIS page: in every placed and nested
189
- * module and in nav-menu items. A broken reference becomes a
193
+ * module and in list entries. A broken reference becomes a
190
194
  * `link-target-missing:<kind>/<id>` module failure; without a resolver
191
195
  * every reference fails with `link-resolver-unavailable` (loud — a
192
196
  * deploy refuses it).
@@ -291,25 +295,11 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
291
295
  // P6.7 — tag every module's outermost element with
292
296
  // `data-caelo-module-id="<uuid>"` so the live-edit overlay's iframe
293
297
  // hover affordances can identify the clicked module.
294
- //
295
- // P6.7.5 — modules whose slug matches a `nav-menu/<slug>` set get
296
- // their HTML replaced by a fresh render of the menu items. That's
297
- // what makes a slug change update every menu without touching
298
- // module HTML.
299
298
  const renderedModuleHtml = block.modules.map((m) => {
300
- const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
301
- let baseHtml: string;
302
- if (navMenuItems !== null) {
303
- const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
304
- baseHtml = nav.html;
305
- recordFailures(moduleFailures, block.blockName, m, nav.failures);
306
- } else {
307
- const rendered = renderModuleFields(m, input);
308
- baseHtml = rendered.html;
309
- recordFailures(moduleFailures, block.blockName, m, rendered.failures);
310
- nestedAssetIds.push(...rendered.nestedModuleIds);
311
- }
312
- return tagModuleId(baseHtml, m.moduleId);
299
+ const rendered = renderModuleFields(m, input);
300
+ recordFailures(moduleFailures, block.blockName, m, rendered.failures);
301
+ nestedAssetIds.push(...rendered.nestedModuleIds);
302
+ return tagModuleId(rendered.html, m.moduleId);
313
303
  });
314
304
  const html = renderedModuleHtml.join("\n");
315
305
  contentByName.set(block.blockName, html);
@@ -388,33 +378,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
388
378
  };
389
379
  }
390
380
 
391
- /**
392
- * P6.7.5 — return the items for a `nav-menu/<slug>` set when a module's
393
- * slug starts with `nav-menu-`. Returns null when the module is not a
394
- * nav menu (so the composer falls back to its stored HTML).
395
- *
396
- * Convention: a module slug `nav-menu-header-main` resolves to
397
- * structuredSets[`nav-menu/header-main`].
398
- */
399
- function lookupNavMenuItems(
400
- moduleSlug: string,
401
- sets: ComposeStructuredSets | undefined,
402
- ): readonly unknown[] | null {
403
- if (!sets) return null;
404
- const prefix = "nav-menu-";
405
- if (!moduleSlug.startsWith(prefix)) return null;
406
- const setSlug = moduleSlug.slice(prefix.length);
407
- const items = sets.byKindSlug[`nav-menu/${setSlug}`];
408
- return items ?? null;
409
- }
410
-
411
- interface NavMenuItem {
412
- label: string;
413
- href: string;
414
- target?: "_self" | "_blank";
415
- children?: NavMenuItem[];
416
- }
417
-
418
381
  function escapeAttr(s: string): string {
419
382
  return s
420
383
  .replace(/&/g, "&amp;")
@@ -422,49 +385,6 @@ function escapeAttr(s: string): string {
422
385
  .replace(/</g, "&lt;")
423
386
  .replace(/>/g, "&gt;");
424
387
  }
425
- function escapeText(s: string): string {
426
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
427
- }
428
-
429
- /**
430
- * Render a nav-menu's typed items into HTML. Recursively handles
431
- * children for submenus. Plain `<nav><ul><li>` so site CSS can theme
432
- * it via the `caelo-nav-menu` class.
433
- *
434
- * Epic #724 — item hrefs may be internal references; they resolve
435
- * through `resolver` like module content, and a broken one is returned
436
- * as a failure attributed to the nav-menu module.
437
- */
438
- function renderNavMenuHtml(
439
- items: readonly unknown[],
440
- resolver: LinkResolver | undefined,
441
- ): { html: string; failures: NestedRenderFailure[] } {
442
- const missing: string[] = [];
443
- const renderNavItem = (item: NavMenuItem): string => {
444
- const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
445
- const inner =
446
- item.children && item.children.length > 0
447
- ? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
448
- : "";
449
- const href = resolveLinkRefsInHtml(item.href, resolver, missing);
450
- return `<li><a href="${escapeAttr(href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
451
- };
452
- const safeItems = items.filter((it): it is NavMenuItem => {
453
- if (!it || typeof it !== "object") return false;
454
- const o = it as { label?: unknown; href?: unknown };
455
- return typeof o.label === "string" && typeof o.href === "string";
456
- });
457
- // issue #160 — mobile-ready markup: toggle button (three functional
458
- // bars via currentColor) + collapsible list. `data-caelo-ui` pulls the
459
- // functional CSS/JS from ./interactions.ts in once per page.
460
- const toggle =
461
- '<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
462
- '<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
463
- "</button>";
464
- const html = `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
465
- return { html, failures: missing.map((reason) => ({ field: "", reason })) };
466
- }
467
-
468
388
  /**
469
389
  * v0.11.0 — Render the active theme's DTCG tokens as `:root { … }` (+
470
390
  * optional `:root.dark { … }`) CSS. Returns null when no active theme
@@ -516,19 +436,16 @@ function renderModuleFields(
516
436
  m: ComposeModule,
517
437
  input: {
518
438
  readonly theme?: ComposeTheme;
519
- readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
520
- readonly dormantDataLists?: Readonly<Record<string, string>>;
439
+ readonly lists?: ListPageContext;
521
440
  readonly nestedModules?: NestedRenderResolver;
522
441
  readonly linkResolver?: LinkResolver;
523
442
  },
524
443
  ): RenderedModuleFields {
525
444
  // The substitution engine (renderTemplate) already unwraps CDATA
526
445
  // guards; cover the no-op early-return path so a chrome module with no
527
- // fields/values/theme is cleaned too.
528
- const hasLists =
529
- Object.keys(input.dataLists ?? {}).length > 0 ||
530
- Object.keys(input.dormantDataLists ?? {}).length > 0;
531
- if (!m.fields && !m.contentValues && !input.theme && !hasLists) {
446
+ // fields/values/theme is cleaned too. A list context means the HTML
447
+ // may iterate a plugin source or read `{{page.*}}` without any field.
448
+ if (!m.fields && !m.contentValues && !input.theme && !input.lists) {
532
449
  // Still resolve references: pre-rendered (editor preview) and
533
450
  // field-less chrome modules carry links in their raw HTML too.
534
451
  const missing: string[] = [];
@@ -539,11 +456,7 @@ function renderModuleFields(
539
456
  nestedModuleIds: [],
540
457
  };
541
458
  }
542
- const fields: TemplateField[] = (m.fields ?? []).map((f) => ({
543
- name: f.name,
544
- kind: f.kind ?? "text",
545
- default: f.default,
546
- }));
459
+ const fields: TemplateField[] = (m.fields ?? []).map((f) => ({ ...f, kind: f.kind ?? "text" }));
547
460
  // v0.11.1 (issue #76) — thread the active theme's asset URLs so
548
461
  // module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
549
462
  // slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
@@ -565,9 +478,9 @@ function renderModuleFields(
565
478
  input.nestedModules,
566
479
  {
567
480
  themeAssets,
568
- dataLists: input.dataLists,
569
- dormantDataLists: input.dormantDataLists,
570
- linkResolver: input.linkResolver,
481
+ ...(input.lists ? { lists: input.lists } : {}),
482
+ ...(m.contentInstanceId ? { contentInstanceId: m.contentInstanceId } : {}),
483
+ ...(input.linkResolver ? { linkResolver: input.linkResolver } : {}),
571
484
  },
572
485
  );
573
486
  return { html: r.html, failures: r.failures, nestedModuleIds: [...r.touchedModuleIds] };
@@ -576,8 +489,8 @@ function renderModuleFields(
576
489
  html: m.html,
577
490
  fields,
578
491
  contentValues: m.contentValues,
579
- dataLists: input.dataLists,
580
- dormantDataLists: input.dormantDataLists,
492
+ ...(input.lists ? { lists: input.lists } : {}),
493
+ listHolder: { contentInstanceId: m.contentInstanceId ?? null },
581
494
  themeAssets,
582
495
  linkResolver: input.linkResolver,
583
496
  });
@@ -796,35 +709,27 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
796
709
  const deferral = input.deferredModules?.[m.moduleId];
797
710
  // Nested modules rendered inside THIS placement when it is withheld.
798
711
  const gatedNestedIds = new Set<string>();
799
- const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
800
- let baseHtml: string;
801
- if (navMenuItems !== null) {
802
- const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
803
- baseHtml = nav.html;
804
- recordFailures(moduleFailures, blockName, m, nav.failures);
805
- } else {
806
- const rendered = renderModuleFields(m, input);
807
- baseHtml = rendered.html;
808
- recordFailures(moduleFailures, blockName, m, rendered.failures);
809
- for (const id of rendered.nestedModuleIds) {
810
- const nestedDeferral = input.deferredModules?.[id];
811
- if (nestedDeferral) {
812
- const nestedSlug = input.nestedModules?.getModule(id)?.slug ?? id;
813
- throw new ComposeError(
814
- "nested-module-deferred",
815
- `module "${nestedSlug}" is withheld by plugin "${nestedDeferral.pluginSlug}" (${nestedDeferral.reason}) but sits inside module "${m.slug}" in block "${blockName}", where it cannot be gated — ` +
816
- "place it on the page directly instead of inside another module's field",
817
- input.layoutSlug,
818
- );
819
- }
820
- // Inside a withheld parent the nested module is withheld too:
821
- // its CSS/JS go into the parent's <template>, never the page-wide
822
- // bundles, or its `fetch()` / `url(…)` would run before consent.
823
- if (deferral) {
824
- if (id !== m.moduleId) gatedNestedIds.add(id);
825
- } else {
826
- nestedAssetIds.push(id);
827
- }
712
+ const rendered = renderModuleFields(m, input);
713
+ const baseHtml = rendered.html;
714
+ recordFailures(moduleFailures, blockName, m, rendered.failures);
715
+ for (const id of rendered.nestedModuleIds) {
716
+ const nestedDeferral = input.deferredModules?.[id];
717
+ if (nestedDeferral) {
718
+ const nestedSlug = input.nestedModules?.getModule(id)?.slug ?? id;
719
+ throw new ComposeError(
720
+ "nested-module-deferred",
721
+ `module "${nestedSlug}" is withheld by plugin "${nestedDeferral.pluginSlug}" (${nestedDeferral.reason}) but sits inside module "${m.slug}" in block "${blockName}", where it cannot be gated — ` +
722
+ "place it on the page directly instead of inside another module's field",
723
+ input.layoutSlug,
724
+ );
725
+ }
726
+ // Inside a withheld parent the nested module is withheld too:
727
+ // its CSS/JS go into the parent's <template>, never the page-wide
728
+ // bundles, or its `fetch()` / `url(…)` would run before consent.
729
+ if (deferral) {
730
+ if (id !== m.moduleId) gatedNestedIds.add(id);
731
+ } else {
732
+ nestedAssetIds.push(id);
828
733
  }
829
734
  }
830
735
  const tagged = tagModuleId(baseHtml, m.moduleId);
@@ -905,8 +810,8 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
905
810
  let html = replaced.html;
906
811
 
907
812
  // issue #160 — every vetted interaction the page's markup asks for
908
- // (`data-caelo-ui="…"`, from modules, the nav renderer, the layout or
909
- // the template, withheld modules included) ships once, after module
813
+ // (`data-caelo-ui="…"`, from modules, the layout or the template,
814
+ // withheld modules included) ships once, after module
910
815
  // CSS/JS so its functional rules win over a module's display rules.
911
816
  const interactions = interactionAssets(new Set(findInteractionIds(html).known));
912
817
  cssParts.push(...interactions.css);
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { createTemplateToolInput } from "./ai-tools.js";
5
+ import {
6
+ decodeHtmlEscapedSource,
7
+ decodeJsonEncodedSource,
8
+ decodeSourceTextTransport,
9
+ } from "./source-text-transport.js";
10
+
11
+ const markup = '<main class="simple-page-main"><caelo-slot name="content"></caelo-slot></main>';
12
+
13
+ describe("decodeHtmlEscapedSource", () => {
14
+ it("decodes markup escaped as a whole (e2e-livedit run 40344374447)", () => {
15
+ const sent =
16
+ '&lt;main class="simple-page-main"&gt;&lt;caelo-slot name="content"&gt;&lt;/caelo-slot&gt;&lt;/main&gt;';
17
+ expect(decodeHtmlEscapedSource(sent)).toBe(markup);
18
+ expect(
19
+ decodeHtmlEscapedSource("&lt;p class=&quot;x&quot;&gt;Tom &amp;amp; Jerry&lt;/p&gt;"),
20
+ ).toBe('<p class="x">Tom &amp; Jerry</p>');
21
+ });
22
+
23
+ it("leaves real markup and text that only mentions an entity untouched", () => {
24
+ const withEntity = "<p>Use &lt;div&gt; for blocks</p>";
25
+ expect(decodeHtmlEscapedSource(withEntity)).toBe(withEntity);
26
+ expect(decodeHtmlEscapedSource("a &lt; b")).toBe("a &lt; b");
27
+ expect(decodeHtmlEscapedSource(".a > .b { color: red }")).toBe(".a > .b { color: red }");
28
+ });
29
+ });
30
+
31
+ describe("decodeJsonEncodedSource", () => {
32
+ it("decodes a whole-value JSON string literal with escapes", () => {
33
+ expect(decodeJsonEncodedSource(JSON.stringify(markup))).toBe(markup);
34
+ expect(decodeJsonEncodedSource('".a {\\n color: red;\\n}"')).toBe(".a {\n color: red;\n}");
35
+ });
36
+
37
+ it("leaves a literal without escapes and non-literals untouched", () => {
38
+ expect(decodeJsonEncodedSource('"use strict"')).toBe('"use strict"');
39
+ expect(decodeJsonEncodedSource(markup)).toBe(markup);
40
+ expect(decodeJsonEncodedSource('"broken \\" literal')).toBe('"broken \\" literal');
41
+ });
42
+ });
43
+
44
+ describe("decodeSourceTextTransport", () => {
45
+ it("unwraps CDATA, then a JSON literal, then HTML escaping", () => {
46
+ expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`)).toBe(markup);
47
+ expect(
48
+ decodeSourceTextTransport(
49
+ JSON.stringify(markup.replaceAll("<", "&lt;").replaceAll(">", "&gt;")),
50
+ ),
51
+ ).toBe(markup);
52
+ expect(decodeSourceTextTransport(42)).toBe(42);
53
+ });
54
+
55
+ it("is what create_template's html argument goes through", () => {
56
+ const r = createTemplateToolInput.parse({
57
+ slug: "page-template",
58
+ displayName: "Simple Page",
59
+ html: markup.replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
60
+ });
61
+ expect(r.html).toBe(markup);
62
+ });
63
+ });
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Decoding of whole-value transport encodings around AI tool arguments that
5
+ * carry source text (module / template / layout html, css, js).
6
+ *
7
+ * The model sometimes encodes a long markup string as a whole before it
8
+ * sends it: as a CDATA section (`unwrapCdataEnvelope`), as a JSON string
9
+ * literal (`"<footer class=\"x\">…"`), or HTML-escaped (`&lt;main&gt;…`).
10
+ * Each is an encoding of a value the caller did supply, never content:
11
+ * a JSON string literal is not valid markup, CSS or JS as a whole, and
12
+ * markup without a single `<` but with `&lt;tag` has no element at all.
13
+ * Refusing them cost a tool round-trip each time (e2e-livedit runs of
14
+ * PR #1121 and #1143); describing them in every tool cost tokens on every
15
+ * call and still did not stop them. So the boundary decodes them, once,
16
+ * here. Anything not clearly a whole-value encoding is left untouched and
17
+ * the op validator still refuses it (shared `content.ts`), which also
18
+ * keeps human and import writes strict. This is input normalisation, not
19
+ * a fallback for missing data (CLAUDE.md §2).
20
+ */
21
+
22
+ import { unwrapCdataEnvelope } from "./strip-cdata.js";
23
+
24
+ /**
25
+ * The body of a whole-value JSON string literal, or the input unchanged.
26
+ * Only decoded when the literal carries an escape (`\"`, `\n`, `\t`, `\\`,
27
+ * `\/`, `\u…`): a bare `"use strict"` stays as it is.
28
+ */
29
+ export function decodeJsonEncodedSource(value: unknown): unknown {
30
+ if (typeof value !== "string") return value;
31
+ const t = value.trim();
32
+ if (t.length < 2 || !t.startsWith('"') || !t.endsWith('"')) return value;
33
+ if (!/\\["ntr\\/u]/.test(t)) return value;
34
+ try {
35
+ const decoded: unknown = JSON.parse(t);
36
+ return typeof decoded === "string" ? decoded : value;
37
+ } catch {
38
+ return value;
39
+ }
40
+ }
41
+
42
+ const ENTITIES: Readonly<Record<string, string>> = {
43
+ "&lt;": "<",
44
+ "&gt;": ">",
45
+ "&quot;": '"',
46
+ "&#34;": '"',
47
+ "&#39;": "'",
48
+ "&#x27;": "'",
49
+ "&apos;": "'",
50
+ };
51
+
52
+ /**
53
+ * Markup sent HTML-escaped as a whole, decoded once; the input unchanged
54
+ * otherwise. "As a whole" = no raw `<` anywhere and at least one escaped
55
+ * tag (`&lt;div`, `&lt;/div`) — the same test the op's refusal uses.
56
+ * `&amp;` is decoded last, so an entity the author meant (`&amp;nbsp;`
57
+ * sent for `&nbsp;`) comes out as the author wrote it.
58
+ */
59
+ export function decodeHtmlEscapedSource(value: unknown): unknown {
60
+ if (typeof value !== "string") return value;
61
+ if (value.includes("<") || !/&lt;\/?[a-zA-Z]/.test(value)) return value;
62
+ return value
63
+ .replace(/&lt;|&gt;|&quot;|&#34;|&#39;|&#x27;|&apos;/g, (e) => ENTITIES[e] ?? e)
64
+ .replace(/&amp;/g, "&");
65
+ }
66
+
67
+ /** Every whole-value transport encoding, outermost first. */
68
+ export function decodeSourceTextTransport(value: unknown): unknown {
69
+ return decodeHtmlEscapedSource(decodeJsonEncodedSource(unwrapCdataEnvelope(value)));
70
+ }