@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
@@ -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);
@@ -11,6 +11,7 @@ import {
11
11
  formatRedirectsFile,
12
12
  parseRedirectsFile,
13
13
  type RedirectTableRow,
14
+ redirectsToUnservedPaths,
14
15
  } from "./redirect-plan.js";
15
16
 
16
17
  const NO_FILES: ReadonlySet<string> = new Set();
@@ -141,3 +142,27 @@ describe("buildRedirectPlan", () => {
141
142
  expect(plan(rows)).toEqual(plan([...rows]));
142
143
  });
143
144
  });
145
+
146
+ describe("redirectsToUnservedPaths (#1157)", () => {
147
+ const served = new Set(["index.html", "live/index.html", "doc.pdf"]);
148
+ it("reports redirects whose target the build does not serve, nothing else", () => {
149
+ const p = plan(
150
+ [
151
+ row("/a", "/live"),
152
+ row("/b", "/draft-page"),
153
+ row("/c", "/doc.pdf"),
154
+ row("/d", "/missing.pdf"),
155
+ row("/e", "/api/forms/x"),
156
+ row("/f", "/live?ref=1"),
157
+ row("/g", "/"),
158
+ ],
159
+ served,
160
+ );
161
+ expect(redirectsToUnservedPaths(p, served).map((r) => r.fromPath)).toEqual(["/b", "/d"]);
162
+ });
163
+
164
+ it("judges a chain by its final hop", () => {
165
+ const p = plan([row("/x", "/y"), row("/y", "/live")], served);
166
+ expect(redirectsToUnservedPaths(p, served)).toEqual([]);
167
+ });
168
+ });
@@ -178,6 +178,30 @@ export function buildRedirectPlan(args: BuildRedirectPlanArgs): RedirectPlan {
178
178
  return { redirects, gone, shadowedByBuild };
179
179
  }
180
180
 
181
+ /**
182
+ * The planned redirects whose Location is a same-site path the build does
183
+ * not serve (#1157). Redirects reach the live site on save, pages only with
184
+ * Publish live, so a redirect to a page that is still a draft answers 404 at
185
+ * its target until that page is published. Callers report these; they are
186
+ * not an error. Paths under `/api/` are answered by the gateway, not the
187
+ * build, and are never reported.
188
+ *
189
+ * @example
190
+ * redirectsToUnservedPaths(plan, new Set(["index.html"]))
191
+ * // → the entries of `plan.redirects` pointing anywhere but `/`
192
+ */
193
+ export function redirectsToUnservedPaths(
194
+ plan: RedirectPlan,
195
+ servedFiles: ReadonlySet<string>,
196
+ ): PlannedRedirect[] {
197
+ return plan.redirects.filter((r) => {
198
+ const path = pathBeforeQuery(r.location);
199
+ if (path === "/api" || path.startsWith("/api/")) return false;
200
+ if (looksLikeFile(path)) return !servedFiles.has(path.slice(1));
201
+ return !servedByBuild(stripTrailingSlash(path), servedFiles);
202
+ });
203
+ }
204
+
181
205
  function normaliseRows(rows: readonly RedirectTableRow[]): NormalisedRow[] {
182
206
  const byBare = new Map<string, NormalisedRow>();
183
207
  const out: NormalisedRow[] = [];
package/src/seo.test.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
+ breadcrumbJsonLdItems,
5
6
  checkPublicSiteBaseUrl,
6
7
  injectSeoIntoHead,
7
8
  renderSeoHead,
@@ -402,3 +403,60 @@ describe("checkPublicSiteBaseUrl", () => {
402
403
  }
403
404
  });
404
405
  });
406
+
407
+ describe("BreadcrumbList JSON-LD (#1142)", () => {
408
+ const base = {
409
+ title: "Team",
410
+ metaDescription: "",
411
+ canonical: "https://example.com/about/team/",
412
+ noindex: false,
413
+ ogImageUrl: null,
414
+ organization: {},
415
+ };
416
+ const ldBlocks = (head: string): unknown[] =>
417
+ [...head.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) =>
418
+ JSON.parse(m[1] as string),
419
+ );
420
+
421
+ it("lists the trail with positions, short titles and public URLs", () => {
422
+ const head = renderSeoHead({
423
+ ...base,
424
+ breadcrumbs: [
425
+ { name: "Start", url: "https://example.com/" },
426
+ { name: "About </script>", url: "https://example.com/about/" },
427
+ { name: "Team", url: "https://example.com/about/team/" },
428
+ ],
429
+ });
430
+ expect(head).not.toContain("About </script>");
431
+ expect(ldBlocks(head)[1]).toEqual({
432
+ "@context": "https://schema.org",
433
+ "@type": "BreadcrumbList",
434
+ itemListElement: [
435
+ { "@type": "ListItem", position: 1, name: "Start", item: "https://example.com/" },
436
+ {
437
+ "@type": "ListItem",
438
+ position: 2,
439
+ name: "About </script>",
440
+ item: "https://example.com/about/",
441
+ },
442
+ { "@type": "ListItem", position: 3, name: "Team", item: "https://example.com/about/team/" },
443
+ ],
444
+ });
445
+ });
446
+
447
+ it("emits no BreadcrumbList without a trail", () => {
448
+ expect(ldBlocks(renderSeoHead(base))).toHaveLength(1);
449
+ expect(ldBlocks(renderSeoHead({ ...base, breadcrumbs: [] }))).toHaveLength(1);
450
+ });
451
+
452
+ it("builds items from the trail, leaving out pages without a public URL", () => {
453
+ const known: Record<string, { name: string; url: string }> = {
454
+ home: { name: "Start", url: "https://example.com/" },
455
+ team: { name: "Team", url: "https://example.com/about/team/" },
456
+ };
457
+ expect(
458
+ breadcrumbJsonLdItems(["home", "draft-parent", "team"], (id) => known[id] ?? null),
459
+ ).toEqual([known.home, known.team]);
460
+ expect(breadcrumbJsonLdItems(null, () => null)).toEqual([]);
461
+ });
462
+ });
package/src/seo.ts CHANGED
@@ -265,6 +265,40 @@ export interface SeoMetaInput {
265
265
  noindex: boolean;
266
266
  ogImageUrl: string | null;
267
267
  organization: SiteSeoSettings["organization"];
268
+ /**
269
+ * #1142 — the breadcrumb trail the page SHOWS (home → current), as
270
+ * built by {@link breadcrumbJsonLdItems}; null or empty = the page shows
271
+ * none and gets no `BreadcrumbList`, so structured data never claims a
272
+ * trail a visitor cannot see.
273
+ */
274
+ breadcrumbs?: readonly BreadcrumbJsonLdItem[] | null;
275
+ }
276
+
277
+ /** One crumb of a `BreadcrumbList`: the page's short title and public URL. */
278
+ export interface BreadcrumbJsonLdItem {
279
+ readonly name: string;
280
+ readonly url: string;
281
+ }
282
+
283
+ /**
284
+ * The `BreadcrumbList` items of a rendered trail (#1142). `lookup` answers
285
+ * a trail page's short title and public URL, or null when the page has no
286
+ * public URL in this render (not published). Such a crumb is left out:
287
+ * its visible link is already flagged by the unpublished-link gate, and a
288
+ * `ListItem` without a URL is only valid in the last position. The
289
+ * generator and the editor preview both build their items through here.
290
+ */
291
+ export function breadcrumbJsonLdItems(
292
+ trail: readonly string[] | null,
293
+ lookup: (pageId: string) => BreadcrumbJsonLdItem | null,
294
+ ): BreadcrumbJsonLdItem[] {
295
+ if (trail === null) return [];
296
+ const out: BreadcrumbJsonLdItem[] = [];
297
+ for (const id of trail) {
298
+ const item = lookup(id);
299
+ if (item !== null) out.push(item);
300
+ }
301
+ return out;
268
302
  }
269
303
 
270
304
  /**
@@ -330,6 +364,21 @@ export function renderSeoHead(input: SeoMetaInput): string {
330
364
  lines.push(
331
365
  `<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`,
332
366
  );
367
+ if (input.breadcrumbs && input.breadcrumbs.length > 0) {
368
+ const list = {
369
+ "@context": "https://schema.org",
370
+ "@type": "BreadcrumbList",
371
+ itemListElement: input.breadcrumbs.map((b, i) => ({
372
+ "@type": "ListItem",
373
+ position: i + 1,
374
+ name: b.name,
375
+ item: b.url,
376
+ })),
377
+ };
378
+ lines.push(
379
+ `<script type="application/ld+json">${JSON.stringify(list).replace(/</g, "\\u003c")}</script>`,
380
+ );
381
+ }
333
382
  return lines.join("\n");
334
383
  }
335
384