@caelo-cms/shared 0.11.0 → 0.11.2

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 (137) hide show
  1. package/dist/ai-tools.d.ts +32 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +46 -139
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +46 -3
  6. package/dist/bug-intake.d.ts.map +1 -1
  7. package/dist/bug-intake.js +52 -3
  8. package/dist/bug-intake.js.map +1 -1
  9. package/dist/build-page.d.ts +5 -5
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +14 -9
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +14 -2
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +73 -15
  16. package/dist/content.js.map +1 -1
  17. package/dist/css-scope-scan.js +1 -1
  18. package/dist/css-var-scan.js +3 -3
  19. package/dist/css-var-scan.js.map +1 -1
  20. package/dist/escaped-line-breaks.d.ts +44 -0
  21. package/dist/escaped-line-breaks.d.ts.map +1 -0
  22. package/dist/escaped-line-breaks.js +203 -0
  23. package/dist/escaped-line-breaks.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/interactions.js +5 -5
  29. package/dist/interactions.js.map +1 -1
  30. package/dist/lists/default-types.d.ts +12 -0
  31. package/dist/lists/default-types.d.ts.map +1 -1
  32. package/dist/lists/default-types.js +36 -0
  33. package/dist/lists/default-types.js.map +1 -1
  34. package/dist/lists/fields.d.ts.map +1 -1
  35. package/dist/lists/fields.js +1 -0
  36. package/dist/lists/fields.js.map +1 -1
  37. package/dist/lists/render-data.d.ts +9 -0
  38. package/dist/lists/render-data.d.ts.map +1 -1
  39. package/dist/lists/render-deps.d.ts +4 -3
  40. package/dist/lists/render-deps.d.ts.map +1 -1
  41. package/dist/lists/render-deps.js +5 -3
  42. package/dist/lists/render-deps.js.map +1 -1
  43. package/dist/lists/resolve.d.ts +13 -0
  44. package/dist/lists/resolve.d.ts.map +1 -1
  45. package/dist/lists/resolve.js +95 -3
  46. package/dist/lists/resolve.js.map +1 -1
  47. package/dist/live-events.d.ts +44 -0
  48. package/dist/live-events.d.ts.map +1 -0
  49. package/dist/live-events.js +128 -0
  50. package/dist/live-events.js.map +1 -0
  51. package/dist/markup-structure.d.ts +20 -2
  52. package/dist/markup-structure.d.ts.map +1 -1
  53. package/dist/markup-structure.js +69 -3
  54. package/dist/markup-structure.js.map +1 -1
  55. package/dist/media.d.ts +15 -0
  56. package/dist/media.d.ts.map +1 -1
  57. package/dist/media.js.map +1 -1
  58. package/dist/module-id-tag.d.ts +4 -1
  59. package/dist/module-id-tag.d.ts.map +1 -1
  60. package/dist/module-id-tag.js +11 -2
  61. package/dist/module-id-tag.js.map +1 -1
  62. package/dist/nested-module-render.d.ts +13 -0
  63. package/dist/nested-module-render.d.ts.map +1 -1
  64. package/dist/nested-module-render.js +34 -0
  65. package/dist/nested-module-render.js.map +1 -1
  66. package/dist/preview-compose.d.ts +18 -1
  67. package/dist/preview-compose.d.ts.map +1 -1
  68. package/dist/preview-compose.js +26 -7
  69. package/dist/preview-compose.js.map +1 -1
  70. package/dist/redirect-plan.d.ts +13 -0
  71. package/dist/redirect-plan.d.ts.map +1 -1
  72. package/dist/redirect-plan.js +22 -0
  73. package/dist/redirect-plan.js.map +1 -1
  74. package/dist/seo.d.ts +21 -0
  75. package/dist/seo.d.ts.map +1 -1
  76. package/dist/seo.js +32 -0
  77. package/dist/seo.js.map +1 -1
  78. package/dist/source-text-transport.d.ts +28 -2
  79. package/dist/source-text-transport.d.ts.map +1 -1
  80. package/dist/source-text-transport.js +12 -4
  81. package/dist/source-text-transport.js.map +1 -1
  82. package/dist/template-sections.d.ts.map +1 -1
  83. package/dist/template-sections.js +17 -4
  84. package/dist/template-sections.js.map +1 -1
  85. package/dist/theme-render.js +11 -26
  86. package/dist/theme-render.js.map +1 -1
  87. package/dist/themes.d.ts.map +1 -1
  88. package/dist/themes.js +12 -9
  89. package/dist/themes.js.map +1 -1
  90. package/dist/version.d.ts +2 -2
  91. package/dist/version.js +1 -1
  92. package/package.json +1 -1
  93. package/src/__tests__/redos-hardening.test.ts +1 -0
  94. package/src/ai-tools-add-module-modes.test.ts +1 -1
  95. package/src/ai-tools.test.ts +7 -20
  96. package/src/ai-tools.ts +57 -151
  97. package/src/bug-intake.test.ts +48 -1
  98. package/src/bug-intake.ts +58 -3
  99. package/src/build-page.ts +14 -9
  100. package/src/content.test.ts +26 -1
  101. package/src/content.ts +84 -16
  102. package/src/css-scope-scan.ts +1 -1
  103. package/src/css-var-scan.ts +3 -3
  104. package/src/escaped-line-breaks.ts +221 -0
  105. package/src/index.ts +2 -0
  106. package/src/interactions.ts +5 -5
  107. package/src/link-refs.test.ts +1 -0
  108. package/src/lists/default-types.ts +39 -0
  109. package/src/lists/fields.ts +1 -0
  110. package/src/lists/list-fixtures.test.ts +9 -3
  111. package/src/lists/render-data.ts +9 -0
  112. package/src/lists/render-deps.ts +6 -3
  113. package/src/lists/resolve.test.ts +182 -5
  114. package/src/lists/resolve.ts +101 -3
  115. package/src/live-events.ts +138 -0
  116. package/src/markup-structure.test.ts +47 -0
  117. package/src/markup-structure.ts +74 -3
  118. package/src/media.ts +12 -0
  119. package/src/module-id-tag.test.ts +38 -0
  120. package/src/module-id-tag.ts +11 -2
  121. package/src/nested-module-render.test.ts +39 -0
  122. package/src/nested-module-render.ts +35 -0
  123. package/src/preview-compose-deferrals.test.ts +44 -0
  124. package/src/preview-compose.ts +47 -10
  125. package/src/redirect-plan.test.ts +25 -0
  126. package/src/redirect-plan.ts +24 -0
  127. package/src/seo.test.ts +58 -0
  128. package/src/seo.ts +49 -0
  129. package/src/source-text-transport.test.ts +98 -3
  130. package/src/source-text-transport.ts +15 -4
  131. package/src/template-engine-lists.test.ts +55 -0
  132. package/src/template-sections.ts +18 -4
  133. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  134. package/src/theme-render-sanitize.test.ts +8 -0
  135. package/src/theme-render.ts +11 -26
  136. package/src/themes.ts +12 -7
  137. package/src/version.ts +1 -1
@@ -120,13 +120,84 @@ function splitClasses(raw: string | undefined): string[] {
120
120
  return [...new Set(raw.split(/\s+/).filter((c) => c.length > 0))].sort();
121
121
  }
122
122
 
123
+ /** Options of {@link stripMustache}. */
124
+ export interface StripMustacheOptions {
125
+ /**
126
+ * Keep the text of a section inside a tag (`class="a {{#s}}b{{/s}}"` →
127
+ * `class="a b"`): every class the template CAN render. Default false:
128
+ * such a section is dropped, leaving the classes it ALWAYS renders.
129
+ */
130
+ readonly keepOptionalAttributeText?: boolean;
131
+ }
132
+
123
133
  /**
124
134
  * Remove Mustache tags (`{{x}}`, `{{{x}}}`, `{{#s}}`, `{{/s}}`, `{{>p}}`)
125
135
  * from a module template so it parses as the markup it renders: a section
126
- * body stays once, a placeholder becomes empty.
136
+ * body between elements stays once (a list renders it per entry), a
137
+ * placeholder becomes empty.
138
+ *
139
+ * A section INSIDE a tag (`<div class="split {{#reverse}}rev{{/reverse}}">`,
140
+ * a boolean variant) is optional: the rendered element may or may not
141
+ * carry its text. By default it is dropped, so the template node has only
142
+ * the classes every render has (#1104). Keeping it made `rev` a required
143
+ * class — every render without it looked like free markup — and a root
144
+ * whose classes all sit in sections matched no render, so the module's
145
+ * free markup went unchecked.
146
+ */
147
+ export function stripMustache(template: string, opts: StripMustacheOptions = {}): string {
148
+ const base = opts.keepOptionalAttributeText ? template : dropInTagSections(template);
149
+ return stripDelimited(stripDelimited(base, 3), 2);
150
+ }
151
+
152
+ /**
153
+ * Remove every `{{#s}}…{{/s}}` / `{{^s}}…{{/s}}` that opens inside a tag
154
+ * (between `<name` and its `>`), body included. One linear scan; Mustache
155
+ * tags are skipped whole, so a `>` in `{{>partial}}` never ends a tag.
127
156
  */
128
- export function stripMustache(template: string): string {
129
- return stripDelimited(stripDelimited(template, 3), 2);
157
+ function dropInTagSections(text: string): string {
158
+ let out = "";
159
+ let i = 0;
160
+ let inTag = false;
161
+ let quote: string | null = null;
162
+ // Names with no closer after some point have none after any later point
163
+ // either: remembering them keeps the scan linear on unclosed openers.
164
+ const unclosed = new Set<string>();
165
+ while (i < text.length) {
166
+ if (text.startsWith("{{", i)) {
167
+ const end = text.indexOf("}}", i + 2);
168
+ if (end < 0) break;
169
+ const sigil = text[i + 2];
170
+ if (inTag && (sigil === "#" || sigil === "^")) {
171
+ const name = text.slice(i + 3, end).trim();
172
+ const closer = `{{/${name}}}`;
173
+ const close = unclosed.has(name) ? -1 : text.indexOf(closer, end + 2);
174
+ if (close < 0) unclosed.add(name);
175
+ if (name.length > 0 && close >= 0) {
176
+ i = close + closer.length;
177
+ continue;
178
+ }
179
+ }
180
+ out += text.slice(i, end + 2);
181
+ i = end + 2;
182
+ continue;
183
+ }
184
+ const ch = text[i] as string;
185
+ if (inTag) {
186
+ if (quote !== null) {
187
+ if (ch === quote) quote = null;
188
+ } else if (ch === '"' || ch === "'") {
189
+ quote = ch;
190
+ } else if (ch === ">") {
191
+ inTag = false;
192
+ }
193
+ } else if (ch === "<" && /[a-zA-Z]/.test(text[i + 1] ?? "")) {
194
+ inTag = true;
195
+ quote = null;
196
+ }
197
+ out += ch;
198
+ i++;
199
+ }
200
+ return out + text.slice(i);
130
201
  }
131
202
 
132
203
  /**
package/src/media.ts CHANGED
@@ -394,6 +394,18 @@ export type MediaSetCdnInput = z.infer<typeof mediaSetCdnInputSchema>;
394
394
  export interface MediaStorageAdapter {
395
395
  put(key: string, body: Uint8Array, contentType: string): Promise<void>;
396
396
  get(key: string): Promise<Uint8Array>;
397
+ /**
398
+ * #1216 — the object's size in bytes without reading it, so a caller can
399
+ * refuse an oversized object before it is loaded. Throws when the key
400
+ * does not exist.
401
+ */
402
+ size(key: string): Promise<number>;
403
+ /**
404
+ * #1216 — the object as a byte stream plus its size, for serving it
405
+ * without holding the whole file in memory. Throws when the key does not
406
+ * exist.
407
+ */
408
+ openStream(key: string): Promise<{ body: ReadableStream<Uint8Array>; sizeBytes: number }>;
397
409
  delete(key: string): Promise<void>;
398
410
  exists(key: string): Promise<boolean>;
399
411
  /** Bytes-on-disk for capacity reporting. */
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { tagModuleId } from "./module-id-tag.js";
5
+
6
+ describe("tagModuleId", () => {
7
+ it("stamps the first opening tag", () => {
8
+ expect(tagModuleId('<p class="a">hi</p>', "m1")).toBe(
9
+ '<p class="a" data-caelo-module-id="m1">hi</p>',
10
+ );
11
+ });
12
+
13
+ it("keeps a self-closing root self-closed (#1099)", () => {
14
+ expect(tagModuleId('<line x1="0" stroke="#E3E7EE"/>', "m1")).toBe(
15
+ '<line x1="0" stroke="#E3E7EE" data-caelo-module-id="m1"/>',
16
+ );
17
+ expect(tagModuleId('<circle r="2" />', "m1")).toBe('<circle r="2" data-caelo-module-id="m1"/>');
18
+ expect(tagModuleId("<br/>", "m1")).toBe('<br data-caelo-module-id="m1"/>');
19
+ });
20
+
21
+ it("does not mistake a slash inside a quoted value for self-closing", () => {
22
+ expect(tagModuleId('<a href="/">x</a>', "m1")).toBe(
23
+ '<a href="/" data-caelo-module-id="m1">x</a>',
24
+ );
25
+ });
26
+
27
+ it("is idempotent", () => {
28
+ const once = tagModuleId('<polyline points="0,0 1,1"/>', "m1");
29
+ expect(tagModuleId(once, "m2")).toBe(once);
30
+ });
31
+
32
+ it("stays linear on a long whitespace run inside the root tag", () => {
33
+ const html = `<div${"\t".repeat(200_000)}x>`;
34
+ const start = performance.now();
35
+ expect(tagModuleId(html, "m1").endsWith(' data-caelo-module-id="m1">')).toBe(true);
36
+ expect(performance.now() - start).toBeLessThan(500);
37
+ });
38
+ });
@@ -22,9 +22,12 @@ export const MODULE_ID_ATTRIBUTE = "data-caelo-module-id";
22
22
  * stays attributed to itself. Comments / DOCTYPE / leading whitespace
23
23
  * before the first tag are tolerated. HTML without an opening tag (pure
24
24
  * text, a failure comment) returns unchanged because there's nothing to
25
- * attach to.
25
+ * attach to. A self-closing root (`<line … />`, common for SVG parts)
26
+ * gets the attribute before its `/` (#1099) — after it, the tag would no
27
+ * longer self-close and swallow every following sibling.
26
28
  *
27
29
  * @example tagModuleId("<p>hi</p>", "abc") // '<p data-caelo-module-id="abc">hi</p>'
30
+ * @example tagModuleId('<line x1="0"/>', "abc") // '<line x1="0" data-caelo-module-id="abc"/>'
28
31
  */
29
32
  export function tagModuleId(html: string, moduleId: string): string {
30
33
  if (!html) return html;
@@ -33,6 +36,12 @@ export function tagModuleId(html: string, moduleId: string): string {
33
36
  if (!m) return html;
34
37
  const tagAttrs = m[2] ?? "";
35
38
  if (/\sdata-caelo-module-id\s*=/.test(tagAttrs)) return html;
36
- const replaced = `<${m[1]}${tagAttrs} ${MODULE_ID_ATTRIBUTE}="${moduleId}">`;
39
+ // String ops, not a `\s*\/\s*$` regex: that backtracks polynomially on
40
+ // long whitespace runs (CodeQL js/polynomial-redos).
41
+ const trimmed = tagAttrs.trimEnd();
42
+ const selfClosing = trimmed.endsWith("/");
43
+ const attrs = selfClosing ? trimmed.slice(0, -1).trimEnd() : tagAttrs;
44
+ const close = selfClosing ? "/>" : ">";
45
+ const replaced = `<${m[1]}${attrs} ${MODULE_ID_ATTRIBUTE}="${moduleId}"${close}`;
37
46
  return html.slice(0, m.index) + replaced + html.slice(m.index + m[0].length);
38
47
  }
@@ -29,6 +29,7 @@ import {
29
29
  type NestedRenderResolver,
30
30
  renderModuleWithContent,
31
31
  renderPlacedModule,
32
+ rewriteNestedRefs,
32
33
  } from "./nested-module-render.js";
33
34
 
34
35
  function buildResolver(
@@ -461,6 +462,44 @@ describe("renderModuleWithContent — list fields (epic #1107)", () => {
461
462
  }),
462
463
  ).toEqual([{ moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser }]);
463
464
  });
465
+
466
+ it("rewriteNestedRefs remaps every ref collectNestedRefs finds and copies the rest", () => {
467
+ const COPY = "00000000-0000-4000-8000-00000000c0c0";
468
+ const values = {
469
+ heading: "Keep me",
470
+ cta: { moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser },
471
+ cards: [{ moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser }],
472
+ mega: [
473
+ {
474
+ id: T(1),
475
+ node: "entry",
476
+ values: { label: "Entry" },
477
+ children: [
478
+ {
479
+ id: T(2),
480
+ node: "promo",
481
+ values: { teaser: { moduleId: MODULE.teaser, contentInstanceId: INSTANCE.teaser } },
482
+ },
483
+ ],
484
+ },
485
+ ],
486
+ };
487
+ const rewritten = rewriteNestedRefs(values, (id) => {
488
+ if (id !== INSTANCE.teaser) throw new Error(`no copy for ${id}`);
489
+ return COPY;
490
+ });
491
+ expect(collectNestedRefs(rewritten)).toEqual([
492
+ { moduleId: MODULE.teaser, contentInstanceId: COPY },
493
+ { moduleId: MODULE.teaser, contentInstanceId: COPY },
494
+ { moduleId: MODULE.teaser, contentInstanceId: COPY },
495
+ ]);
496
+ expect(rewritten.heading).toBe("Keep me");
497
+ expect((rewritten.mega as { values: unknown }[])[0]?.values).toEqual({ label: "Entry" });
498
+ // The source is not mutated.
499
+ expect(collectNestedRefs(values).every((r) => r.contentInstanceId === INSTANCE.teaser)).toBe(
500
+ true,
501
+ );
502
+ });
464
503
  });
465
504
 
466
505
  describe("renderModuleWithContent — safety invariants", () => {
@@ -542,5 +542,40 @@ export function collectNestedRefs(values: Readonly<Record<string, unknown>>): Ne
542
542
  return refs;
543
543
  }
544
544
 
545
+ /**
546
+ * A copy of `values` in which every nested-module reference
547
+ * {@link collectNestedRefs} finds points at `remap(contentInstanceId)`.
548
+ * Walks exactly the shapes the collector walks, so every ref the collector
549
+ * reports is the ref this rewrites; everything else is copied unchanged.
550
+ * Deep copies (`pages.duplicate`, `placement.fork_content`) use it to point
551
+ * a copied parent at its copied children instead of the source's.
552
+ *
553
+ * @param remap the new instance id for a source id. It should throw for an
554
+ * id it has no copy for: keeping the source id would leave the copy
555
+ * sharing nested content with the original.
556
+ */
557
+ export function rewriteNestedRefs(
558
+ values: Readonly<Record<string, unknown>>,
559
+ remap: (contentInstanceId: string) => string,
560
+ ): Record<string, unknown> {
561
+ const visitAll = (v: object, depth: number): unknown =>
562
+ Array.isArray(v)
563
+ ? v.map((el) => visit(el, depth))
564
+ : Object.fromEntries(Object.entries(v).map(([k, el]) => [k, visit(el, depth)]));
565
+ const visit = (v: unknown, depth: number): unknown => {
566
+ if (depth > MAX_COLLECT_DEPTH || typeof v !== "object" || v === null) return v;
567
+ if (isNestedRef(v)) return { ...v, contentInstanceId: remap(v.contentInstanceId) };
568
+ if (Array.isArray(v)) return v.map((el) => visit(el, depth + 1));
569
+ const entry = v as { values?: unknown; children?: unknown };
570
+ const out: Record<string, unknown> = { ...(v as Record<string, unknown>) };
571
+ if (typeof entry.values === "object" && entry.values !== null) {
572
+ out.values = visitAll(entry.values, depth + 1);
573
+ }
574
+ if (Array.isArray(entry.children)) out.children = visit(entry.children, depth + 1);
575
+ return out;
576
+ };
577
+ return visitAll(values, 0) as Record<string, unknown>;
578
+ }
579
+
545
580
  /** Bound for {@link collectNestedRefs}: list depth (8) × two levels per entry, plus slack. */
546
581
  const MAX_COLLECT_DEPTH = 24;
@@ -245,3 +245,47 @@ function liveScripts(html: string): string[] {
245
245
  parser.end();
246
246
  return scripts;
247
247
  }
248
+
249
+ describe("composer — omitted modules", () => {
250
+ const omitted = (editorNotice: { html: string; css: string } | null): ComposeDeferral => ({
251
+ pluginSlug: "consent-manager",
252
+ reason: "unclassified",
253
+ omit: true,
254
+ editorNotice,
255
+ });
256
+ const MODULE_WITH_ASSETS = { ...VIDEO, css: ".video-frame{}", js: "fetch('https://t.example')" };
257
+ const composeOmitted = (d: ComposeDeferral) =>
258
+ composePageWithLayout({
259
+ templateHtml: TEMPLATE,
260
+ templateCss: "",
261
+ blocks: [{ blockName: "main", modules: [MODULE_WITH_ASSETS] }],
262
+ layoutHtml: LAYOUT,
263
+ layoutCss: "",
264
+ layoutBlocks: [],
265
+ deferredModules: { [VIDEO.moduleId]: d },
266
+ }).html;
267
+
268
+ it("leaves a build with nothing in the module's place", () => {
269
+ // A visitor cannot release it, so no placeholder and no parked copy:
270
+ // neither the markup nor the module's CSS/JS reach the page.
271
+ const html = composeOmitted(omitted(null));
272
+ expect(html).not.toContain("youtube.com");
273
+ expect(html).not.toContain(".video-frame");
274
+ expect(html).not.toContain("t.example");
275
+ expect(html).not.toContain("data-caelo-deferred");
276
+ expect(html).not.toContain("<template");
277
+ });
278
+
279
+ it("shows the editor notice in the preview, attributed to the module", () => {
280
+ const html = composeOmitted(
281
+ omitted({ html: '<div class="note">Hidden</div>', css: ".note{color:red}" }),
282
+ );
283
+ expect(html).toContain('<div class="note">Hidden</div>');
284
+ expect(html).toContain(".note{color:red}");
285
+ expect(html).toContain("data-caelo-deferred-omitted");
286
+ // The chat affordances reference the module the notice stands in for.
287
+ expect(html).toContain(`data-caelo-module-id="${VIDEO.moduleId}"`);
288
+ expect(html).not.toContain("youtube.com");
289
+ expect(html).not.toContain("<template");
290
+ });
291
+ });
@@ -30,7 +30,7 @@ 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
32
  import type { ListPageContext } from "./lists/resolve.js";
33
- import { tagModuleId } from "./module-id-tag.js";
33
+ import { MODULE_ID_ATTRIBUTE, tagModuleId } from "./module-id-tag.js";
34
34
  import {
35
35
  type NestedModuleResource,
36
36
  type NestedRenderFailure,
@@ -530,12 +530,28 @@ function recordFailures(
530
530
  * A module withheld from this render, as resolved by the plugin host
531
531
  * (#450). The composer never decides this — it only knows the verdict.
532
532
  */
533
- export interface ComposeDeferral {
533
+ export type ComposeDeferral = ComposeGateDeferral | ComposeOmitDeferral;
534
+
535
+ /** Withheld behind a placeholder until the plugin's runtime releases it. */
536
+ export interface ComposeGateDeferral {
534
537
  readonly pluginSlug: string;
535
538
  readonly reason: string;
536
539
  readonly placeholderModuleSlug: string;
537
540
  readonly placeholderHtml: string;
538
541
  readonly placeholderCss: string;
542
+ readonly omit?: undefined;
543
+ }
544
+
545
+ /**
546
+ * Left out entirely: nothing on the visitor's side can release it. The
547
+ * plugin host passes the editor notice only to the editor preview; a
548
+ * build gets `null` and the module's place stays empty.
549
+ */
550
+ export interface ComposeOmitDeferral {
551
+ readonly pluginSlug: string;
552
+ readonly reason: string;
553
+ readonly omit: true;
554
+ readonly editorNotice: { readonly html: string; readonly css: string } | null;
539
555
  }
540
556
 
541
557
  /**
@@ -566,10 +582,8 @@ function wrapDeferredModule(
566
582
  moduleHtml: string,
567
583
  module: Pick<ComposeModule, "moduleId" | "slug" | "css" | "js">,
568
584
  nestedModules: readonly Pick<NestedModuleResource, "moduleId" | "css" | "js">[],
569
- deferral: ComposeDeferral,
585
+ deferral: ComposeGateDeferral,
570
586
  ): string {
571
- const attr = (v: string): string =>
572
- v.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
573
587
  // Inside the inert <template>, only an element's own end tag ends a
574
588
  // <style> / <script>; a `</template` there is text. So CSS needs just
575
589
  // its own close escaped, JS that could end its <script> ships base64
@@ -598,6 +612,25 @@ function wrapDeferredModule(
598
612
  ].join("");
599
613
  }
600
614
 
615
+ /**
616
+ * Emit an omitted module: nothing in a build, the editor notice in the
617
+ * preview. The module's markup, CSS and JS never reach the page — not
618
+ * even in a `<template>`, since nothing would ever clone it out. The
619
+ * notice carries the module id (and its slug as the label) so the
620
+ * editor's chat affordances reference the module it stands in for.
621
+ */
622
+ function omittedModuleNotice(
623
+ module: Pick<ComposeModule, "moduleId" | "slug">,
624
+ deferral: ComposeOmitDeferral,
625
+ ): string {
626
+ if (deferral.editorNotice === null) return "";
627
+ return `<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}" data-caelo-deferred-omitted ${MODULE_ID_ATTRIBUTE}="${attr(module.moduleId)}" aria-label="${attr(module.slug)}">${deferral.editorNotice.html}</div>`;
628
+ }
629
+
630
+ function attr(v: string): string {
631
+ return v.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
632
+ }
633
+
601
634
  export interface ComposeLayoutBlock {
602
635
  readonly blockName: string;
603
636
  readonly modules: readonly ComposeModule[];
@@ -693,9 +726,9 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
693
726
  // Duplicate rule blocks made the cascade order-dependent and bloated
694
727
  // every page the same module appeared on twice.
695
728
  const seenAssetModules = new Set<string>();
696
- // Placeholder CSS, keyed by placeholder slug: one withheld module's
697
- // placeholder used on five placements is emitted once.
698
- const deferredCss = new Map<string, string>();
729
+ // Placeholder and notice CSS: one withheld module's placeholder used
730
+ // on five placements is emitted once.
731
+ const deferredCss = new Set<string>();
699
732
  const moduleFailures: ComposeModuleFailure[] = [];
700
733
  // Nested modules rendered since the last asset flush (see flushNestedAssets).
701
734
  const nestedAssetIds: string[] = [];
@@ -734,7 +767,11 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
734
767
  }
735
768
  const tagged = tagModuleId(baseHtml, m.moduleId);
736
769
  if (!deferral) return tagged;
737
- deferredCss.set(deferral.placeholderModuleSlug, deferral.placeholderCss);
770
+ if (deferral.omit) {
771
+ if (deferral.editorNotice) deferredCss.add(deferral.editorNotice.css);
772
+ return omittedModuleNotice(m, deferral);
773
+ }
774
+ deferredCss.add(deferral.placeholderCss);
738
775
  const gatedNested = [...gatedNestedIds].map((id) => {
739
776
  const nested = input.nestedModules?.getModule(id);
740
777
  // renderModuleFields resolved every id it reports through this
@@ -791,7 +828,7 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
791
828
  flushNestedAssets();
792
829
  }
793
830
 
794
- for (const css of deferredCss.values()) {
831
+ for (const css of deferredCss) {
795
832
  if (css.trim().length > 0) cssParts.push(css);
796
833
  }
797
834
 
@@ -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