@caelo-cms/shared 0.11.0 → 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 (92) hide show
  1. package/dist/ai-tools.d.ts +30 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +41 -141
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +5 -5
  6. package/dist/build-page.js +9 -9
  7. package/dist/build-page.js.map +1 -1
  8. package/dist/content.d.ts +13 -2
  9. package/dist/content.d.ts.map +1 -1
  10. package/dist/content.js +45 -12
  11. package/dist/content.js.map +1 -1
  12. package/dist/css-scope-scan.js +1 -1
  13. package/dist/escaped-line-breaks.d.ts +44 -0
  14. package/dist/escaped-line-breaks.d.ts.map +1 -0
  15. package/dist/escaped-line-breaks.js +203 -0
  16. package/dist/escaped-line-breaks.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/interactions.js +5 -5
  22. package/dist/interactions.js.map +1 -1
  23. package/dist/lists/default-types.d.ts +12 -0
  24. package/dist/lists/default-types.d.ts.map +1 -1
  25. package/dist/lists/default-types.js +36 -0
  26. package/dist/lists/default-types.js.map +1 -1
  27. package/dist/lists/fields.d.ts.map +1 -1
  28. package/dist/lists/fields.js +1 -0
  29. package/dist/lists/fields.js.map +1 -1
  30. package/dist/lists/render-data.d.ts +9 -0
  31. package/dist/lists/render-data.d.ts.map +1 -1
  32. package/dist/lists/render-deps.d.ts +4 -3
  33. package/dist/lists/render-deps.d.ts.map +1 -1
  34. package/dist/lists/render-deps.js +5 -3
  35. package/dist/lists/render-deps.js.map +1 -1
  36. package/dist/lists/resolve.d.ts +13 -0
  37. package/dist/lists/resolve.d.ts.map +1 -1
  38. package/dist/lists/resolve.js +95 -3
  39. package/dist/lists/resolve.js.map +1 -1
  40. package/dist/nested-module-render.d.ts +13 -0
  41. package/dist/nested-module-render.d.ts.map +1 -1
  42. package/dist/nested-module-render.js +34 -0
  43. package/dist/nested-module-render.js.map +1 -1
  44. package/dist/redirect-plan.d.ts +13 -0
  45. package/dist/redirect-plan.d.ts.map +1 -1
  46. package/dist/redirect-plan.js +22 -0
  47. package/dist/redirect-plan.js.map +1 -1
  48. package/dist/seo.d.ts +21 -0
  49. package/dist/seo.d.ts.map +1 -1
  50. package/dist/seo.js +32 -0
  51. package/dist/seo.js.map +1 -1
  52. package/dist/source-text-transport.d.ts +28 -2
  53. package/dist/source-text-transport.d.ts.map +1 -1
  54. package/dist/source-text-transport.js +12 -4
  55. package/dist/source-text-transport.js.map +1 -1
  56. package/dist/template-sections.d.ts.map +1 -1
  57. package/dist/template-sections.js +17 -4
  58. package/dist/template-sections.js.map +1 -1
  59. package/dist/version.d.ts +2 -2
  60. package/dist/version.js +1 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/redos-hardening.test.ts +1 -0
  63. package/src/ai-tools-add-module-modes.test.ts +1 -1
  64. package/src/ai-tools.test.ts +7 -20
  65. package/src/ai-tools.ts +49 -153
  66. package/src/bug-intake.test.ts +1 -1
  67. package/src/build-page.ts +9 -9
  68. package/src/content.test.ts +26 -1
  69. package/src/content.ts +47 -13
  70. package/src/css-scope-scan.ts +1 -1
  71. package/src/escaped-line-breaks.ts +221 -0
  72. package/src/index.ts +1 -0
  73. package/src/interactions.ts +5 -5
  74. package/src/link-refs.test.ts +1 -0
  75. package/src/lists/default-types.ts +39 -0
  76. package/src/lists/fields.ts +1 -0
  77. package/src/lists/list-fixtures.test.ts +9 -3
  78. package/src/lists/render-data.ts +9 -0
  79. package/src/lists/render-deps.ts +6 -3
  80. package/src/lists/resolve.test.ts +182 -5
  81. package/src/lists/resolve.ts +101 -3
  82. package/src/nested-module-render.test.ts +39 -0
  83. package/src/nested-module-render.ts +35 -0
  84. package/src/redirect-plan.test.ts +25 -0
  85. package/src/redirect-plan.ts +24 -0
  86. package/src/seo.test.ts +58 -0
  87. package/src/seo.ts +49 -0
  88. package/src/source-text-transport.test.ts +98 -3
  89. package/src/source-text-transport.ts +15 -4
  90. package/src/template-engine-lists.test.ts +55 -0
  91. package/src/template-sections.ts +18 -4
  92. package/src/version.ts +1 -1
@@ -24,6 +24,7 @@
24
24
 
25
25
  import type { LinkResolver } from "../link-refs.js";
26
26
  import { parseLinkRef } from "../link-refs.js";
27
+ import { SITE_TEXTS_LIST_TYPE } from "./default-types.js";
27
28
  import { type FieldDef, TERM_REF_RE, validateFieldValue } from "./fields.js";
28
29
  import type { ListItem } from "./items.js";
29
30
  import {
@@ -125,6 +126,19 @@ export interface ListPageContext {
125
126
  resolveField(field: ListFieldSpec, value: unknown, holder: ListHolder): ListFieldResolution;
126
127
  /** `{{page.*}}`: the rendering page as a record scope; null outside a page. */
127
128
  pageScope(): ScopeValue | null;
129
+ /**
130
+ * `{{site_text.<key>}}`: the site texts (entries of every list of type
131
+ * {@link SITE_TEXTS_LIST_TYPE}) in the rendering page's variant. An
132
+ * unknown or duplicate key resolves to a failure marker (#1142).
133
+ */
134
+ siteTextScope(): ScopeValue;
135
+ /**
136
+ * The page ids of the breadcrumb trail this render showed (home →
137
+ * current), or null when no `breadcrumbs` source rendered. Drives the
138
+ * `BreadcrumbList` JSON-LD, so structured data always matches the
139
+ * visible trail (#1142).
140
+ */
141
+ breadcrumbTrail(): readonly string[] | null;
128
142
  /**
129
143
  * A section name no module field declares, read as a plugin list
130
144
  * source (`{{#language_links}}` written before the field kind existed).
@@ -420,6 +434,7 @@ function createPageContext(
420
434
  >();
421
435
  let depTree = false;
422
436
  const depPages = new Set<string>();
437
+ let trail: readonly string[] | null = null;
423
438
  /** Entries of a page-shaped system source: their pages become deps. */
424
439
  const readPages = (pages: ListPageRecord[]): ListPageRecord[] => {
425
440
  for (const pg of pages) depPages.add(pg.id);
@@ -696,6 +711,8 @@ function createPageContext(
696
711
  return text(p.id);
697
712
  case "title":
698
713
  return text(p.title);
714
+ case "short_title":
715
+ return text(p.shortTitle);
699
716
  case "link":
700
717
  return linkValue({ href: `caelo:page/${p.id}` }, data);
701
718
  case "published_at":
@@ -728,19 +745,48 @@ function createPageContext(
728
745
  };
729
746
  };
730
747
 
731
- const pageEntries = (pages: readonly ListPageRecord[]): ScopeEntry[] =>
748
+ /**
749
+ * Page-shaped entries. `ancestor` overrides `is_ancestor` where the
750
+ * source knows better than the page tree: a breadcrumb trail's home
751
+ * crumb sits above the current page without being its parent.
752
+ */
753
+ const pageEntries = (
754
+ pages: readonly ListPageRecord[],
755
+ ancestor?: (p: ListPageRecord, index: number) => boolean,
756
+ ): ScopeEntry[] =>
732
757
  pages.map((p, index) => {
733
758
  const get = pageValue(p);
734
759
  const pos = { index, count: pages.length, depth: 1 };
760
+ const isAncestor = ancestor ? bool(ancestor(p, index)) : undefined;
735
761
  return {
736
762
  id: p.id,
737
763
  itemAttr: null,
738
764
  childrenKey: null,
739
765
  depth: 1,
740
- get: (key) => get(key) ?? positionValue(key, pos),
766
+ get: (key) =>
767
+ (key === "is_ancestor" ? isAncestor : undefined) ?? get(key) ?? positionValue(key, pos),
741
768
  };
742
769
  });
743
770
 
771
+ /**
772
+ * The home page in the rendering page's variant: the designated home
773
+ * itself, or its version in the variant's `link-resolve` group. Null
774
+ * when no home is designated or the variant has no version of it yet
775
+ * (an untranslated home is absent, never the other language's page).
776
+ */
777
+ const homeOfVariant = (): ListPageRecord | null => {
778
+ if (data.homePageId === null) return null;
779
+ const home = data.pages.get(data.homePageId) ?? null;
780
+ if (home === null || variant === null) return home;
781
+ const hv = data.pageVariants?.get(home.id);
782
+ if (hv === undefined || hv.variant === variant) return home;
783
+ if (hv.group === null) return null;
784
+ for (const [id, v] of data.pageVariants ?? []) {
785
+ if (v.group === hv.group && v.variant === variant) return data.pages.get(id) ?? null;
786
+ }
787
+ return null;
788
+ };
789
+
744
790
  const sameVariant = (p: ListPageRecord): boolean =>
745
791
  variant === null || data.pageVariants?.get(p.id)?.variant === variant;
746
792
 
@@ -792,7 +838,13 @@ function createPageContext(
792
838
  seen.add(p.id);
793
839
  p = p.parentId === null ? undefined : data.pages.get(p.parentId);
794
840
  }
795
- return listValue(pageEntries(readPages(chain)));
841
+ // The home page is no parent in the tree, yet every trail starts
842
+ // there; its short name is the "Start" crumb, translated with it.
843
+ const home = homeOfVariant();
844
+ if (home && !seen.has(home.id)) chain.unshift(home);
845
+ trail = chain.map((pg) => pg.id);
846
+ const last = chain.length - 1;
847
+ return listValue(pageEntries(readPages(chain), (_pg, index) => index < last));
796
848
  }
797
849
  case "children": {
798
850
  depTree = true;
@@ -1039,10 +1091,56 @@ function createPageContext(
1039
1091
  return { ok: true, entries: entriesOf(checked.items, local, 1) };
1040
1092
  };
1041
1093
 
1094
+ let siteTextMemo: ScopeValue | undefined;
1095
+ /** `{{site_text.<key>}}` — see {@link ListPageContext.siteTextScope}. */
1096
+ const siteTextScope = (): ScopeValue => {
1097
+ if (siteTextMemo !== undefined) return siteTextMemo;
1098
+ depTypes.add(SITE_TEXTS_LIST_TYPE);
1099
+ const byKey = new Map<string, { list: LoadedList; item: ListItem }[]>();
1100
+ for (const list of data.lists.values()) {
1101
+ if (list.typeSlug !== SITE_TEXTS_LIST_TYPE) continue;
1102
+ depLists.add(list.id);
1103
+ for (const item of list.items) {
1104
+ if (overlay?.listHidden.has(`${list.id}/${item.id}`)) continue;
1105
+ const key = item.values.key;
1106
+ if (typeof key !== "string") continue;
1107
+ byKey.set(key, [...(byKey.get(key) ?? []), { list, item }]);
1108
+ }
1109
+ }
1110
+ const memo = new Map<string, ScopeValue>();
1111
+ siteTextMemo = {
1112
+ kind: "record",
1113
+ text: null,
1114
+ present: true,
1115
+ get(key) {
1116
+ const hit = memo.get(key);
1117
+ if (hit) return hit;
1118
+ const found = byKey.get(key) ?? [];
1119
+ let v: ScopeValue;
1120
+ if (found.length === 0) v = { kind: "failed", marker: `site-text-missing:${key}` };
1121
+ else if (found.length > 1) v = { kind: "failed", marker: `site-text-duplicate:${key}` };
1122
+ else {
1123
+ const { list, item } = found[0] as { list: LoadedList; item: ListItem };
1124
+ const translated = overlay?.listValues.get(`${list.id}/${item.id}`)?.get("text");
1125
+ const raw = translated ?? item.values.text;
1126
+ v =
1127
+ typeof raw === "string"
1128
+ ? text(raw)
1129
+ : { kind: "failed", marker: `site-text-missing:${key}` };
1130
+ }
1131
+ memo.set(key, v);
1132
+ return v;
1133
+ },
1134
+ };
1135
+ return siteTextMemo;
1136
+ };
1137
+
1042
1138
  let pageScopeMemo: ScopeValue | null | undefined;
1043
1139
  return {
1044
1140
  pageId,
1045
1141
  resolveField,
1142
+ siteTextScope,
1143
+ breadcrumbTrail: () => trail,
1046
1144
  pageScope: () => {
1047
1145
  if (pageScopeMemo === undefined) {
1048
1146
  if (current === null) pageScopeMemo = null;
@@ -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;
@@ -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
 
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import { createTemplateToolInput } from "./ai-tools.js";
4
+ import { addModuleToolInput, createTemplateToolInput } from "./ai-tools.js";
5
+ import { decodeEscapedLineBreaks } from "./escaped-line-breaks.js";
5
6
  import {
6
7
  decodeHtmlEscapedSource,
7
8
  decodeJsonEncodedSource,
@@ -43,13 +44,14 @@ describe("decodeJsonEncodedSource", () => {
43
44
 
44
45
  describe("decodeSourceTextTransport", () => {
45
46
  it("unwraps CDATA, then a JSON literal, then HTML escaping", () => {
46
- expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`)).toBe(markup);
47
+ expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`, "html")).toBe(markup);
47
48
  expect(
48
49
  decodeSourceTextTransport(
49
50
  JSON.stringify(markup.replaceAll("<", "&lt;").replaceAll(">", "&gt;")),
51
+ "html",
50
52
  ),
51
53
  ).toBe(markup);
52
- expect(decodeSourceTextTransport(42)).toBe(42);
54
+ expect(decodeSourceTextTransport(42, "html")).toBe(42);
53
55
  });
54
56
 
55
57
  it("is what create_template's html argument goes through", () => {
@@ -61,3 +63,96 @@ describe("decodeSourceTextTransport", () => {
61
63
  expect(r.html).toBe(markup);
62
64
  });
63
65
  });
66
+
67
+ // e2e-livedit run 40532297620 (release gate v0.11.1): add_module's html, css
68
+ // and js arrived with every line break as the two characters `\` `n`. The
69
+ // stored footer showed literal `\n` text and the shared module <script>
70
+ // threw `SyntaxError: Invalid or unexpected token` on every preview. These
71
+ // are the exact values the AI sent.
72
+ describe("decodeEscapedLineBreaks", () => {
73
+ const sentJs =
74
+ "(function () {\\n document.querySelectorAll('.site-footer__year').forEach(function (el) {\\n el.textContent = new Date().getFullYear();\\n });\\n})();";
75
+ const sentCss =
76
+ ".site-footer {\\n padding: 2rem 1.5rem;\\n text-align: center;\\n}\\n.site-footer__link:hover {\\n text-decoration: underline;\\n}";
77
+ const sentHtml =
78
+ '<footer class="site-footer">\\n <nav class="site-footer__nav" aria-label="Footer">\\n <ul class="site-footer__list">\\n {{#nav}}\\n <li class="site-footer__item"><a class="site-footer__link" href="{{link.href}}">{{label}}</a></li>\\n {{/nav}}\\n </ul>\\n </nav>\\n</footer>';
79
+
80
+ it("turns escaped line breaks back into real ones (the run's exact values)", () => {
81
+ expect(decodeEscapedLineBreaks(sentJs, "js")).toBe(
82
+ "(function () {\n document.querySelectorAll('.site-footer__year').forEach(function (el) {\n el.textContent = new Date().getFullYear();\n });\n})();",
83
+ );
84
+ expect(decodeEscapedLineBreaks(sentCss, "css")).toBe(
85
+ ".site-footer {\n padding: 2rem 1.5rem;\n text-align: center;\n}\n.site-footer__link:hover {\n text-decoration: underline;\n}",
86
+ );
87
+ const html = decodeEscapedLineBreaks(sentHtml, "html") as string;
88
+ expect(html).not.toContain("\\n");
89
+ expect(html.split("\n")).toHaveLength(9);
90
+ expect(html).toContain('<footer class="site-footer">\n <nav');
91
+ });
92
+
93
+ it("decodes escaped CRLF and escaped tab indentation", () => {
94
+ expect(decodeEscapedLineBreaks(".a {\\r\\n\\tcolor: red;\\r\\n\\t\\tx: y;\\r\\n}", "css")).toBe(
95
+ ".a {\n\tcolor: red;\n\t\tx: y;\n}",
96
+ );
97
+ });
98
+
99
+ it("leaves source with real line breaks, single-line code and escaped backslashes alone", () => {
100
+ const multiLine = "const s = 'a\\n b';\nconsole.log(s.split('\\n '));";
101
+ expect(decodeEscapedLineBreaks(multiLine, "js")).toBe(multiLine);
102
+ const minified = "var t=a.join('\\n');console.log(t.split('\\n'))";
103
+ expect(decodeEscapedLineBreaks(minified, "js")).toBe(minified);
104
+ const oneHit = "el.textContent = lines.join('\\n ');";
105
+ expect(decodeEscapedLineBreaks(oneHit, "js")).toBe(oneHit);
106
+ const escapedBackslash = "a('\\\\n x'); b('\\\\n y');";
107
+ expect(decodeEscapedLineBreaks(escapedBackslash, "js")).toBe(escapedBackslash);
108
+ expect(decodeEscapedLineBreaks(7, "js")).toBe(7);
109
+ });
110
+
111
+ // PR #1189 review: an escape inside a literal is meaningful, so a value
112
+ // holding one is ambiguous and left exactly as sent — decoding it would
113
+ // turn valid source into broken source.
114
+ it("leaves values alone when an escaped line break sits inside a literal", () => {
115
+ const jsString = 'document.querySelector("pre").textContent = "Title\\n Detail\\n More";';
116
+ expect(decodeEscapedLineBreaks(jsString, "js")).toBe(jsString);
117
+ const jsTemplate = "(function () {\\n el.title = `a\\n b`;\\n})();";
118
+ expect(decodeEscapedLineBreaks(jsTemplate, "js")).toBe(jsTemplate);
119
+ const jsRegex = "(function () {\\n var ok = /a\\n b/.test(x);\\n})();";
120
+ expect(decodeEscapedLineBreaks(jsRegex, "js")).toBe(jsRegex);
121
+ const cssContent = '.a::after {\\n content: "x\\n y";\\n}';
122
+ expect(decodeEscapedLineBreaks(cssContent, "css")).toBe(cssContent);
123
+ const htmlAttr = '<div>\\n <span title="a\\n b">x</span>\\n</div>';
124
+ expect(decodeEscapedLineBreaks(htmlAttr, "html")).toBe(htmlAttr);
125
+ const htmlPre = '<div>\\n <pre>printf("a\\n");</pre>\\n</div>';
126
+ expect(decodeEscapedLineBreaks(htmlPre, "html")).toBe(htmlPre);
127
+ });
128
+
129
+ it("decodes structural escapes around literals and comments that hold none", () => {
130
+ expect(
131
+ decodeEscapedLineBreaks(
132
+ "// it's the footer\\n(function () {\\n var s = 'a\\\\n';\\n /x/.test(s);\\n})();",
133
+ "js",
134
+ ),
135
+ ).toBe("// it's the footer\n(function () {\n var s = 'a\\\\n';\n /x/.test(s);\n})();");
136
+ expect(
137
+ decodeEscapedLineBreaks('<p>It\'s ours</p>\\n<p>\\n <a href="/">Home</a>\\n</p>', "html"),
138
+ ).toBe('<p>It\'s ours</p>\n<p>\n <a href="/">Home</a>\n</p>');
139
+ });
140
+
141
+ it("is what add_module's html / css / js arguments go through", () => {
142
+ const r = addModuleToolInput.parse({
143
+ target: "layout",
144
+ targetRef: "site-default",
145
+ blockName: "footer",
146
+ position: "bottom",
147
+ displayName: "Site Footer",
148
+ html: sentHtml,
149
+ css: sentCss,
150
+ js: sentJs,
151
+ });
152
+ for (const v of [r.html, r.css, r.js]) {
153
+ expect(v).toBeString();
154
+ expect(v).not.toContain("\\n");
155
+ expect(v).toContain("\n");
156
+ }
157
+ });
158
+ });
@@ -6,7 +6,9 @@
6
6
  *
7
7
  * The model sometimes encodes a long markup string as a whole before it
8
8
  * sends it: as a CDATA section (`unwrapCdataEnvelope`), as a JSON string
9
- * literal (`"<footer class=\"x\">…"`), or HTML-escaped (`&lt;main&gt;…`).
9
+ * literal (`"<footer class=\"x\">…"`), HTML-escaped (`&lt;main&gt;…`), or
10
+ * with its line breaks string-escaped (`{\n color: red;\n}`, see
11
+ * `escaped-line-breaks.ts`).
10
12
  * Each is an encoding of a value the caller did supply, never content:
11
13
  * a JSON string literal is not valid markup, CSS or JS as a whole, and
12
14
  * markup without a single `<` but with `&lt;tag` has no element at all.
@@ -19,6 +21,7 @@
19
21
  * a fallback for missing data (CLAUDE.md §2).
20
22
  */
21
23
 
24
+ import { decodeEscapedLineBreaks, type SourceLanguage } from "./escaped-line-breaks.js";
22
25
  import { unwrapCdataEnvelope } from "./strip-cdata.js";
23
26
 
24
27
  /**
@@ -64,7 +67,15 @@ export function decodeHtmlEscapedSource(value: unknown): unknown {
64
67
  .replace(/&amp;/g, "&");
65
68
  }
66
69
 
67
- /** Every whole-value transport encoding, outermost first. */
68
- export function decodeSourceTextTransport(value: unknown): unknown {
69
- return decodeHtmlEscapedSource(decodeJsonEncodedSource(unwrapCdataEnvelope(value)));
70
+ /**
71
+ * Every whole-value transport encoding, outermost first.
72
+ *
73
+ * @param language - the grammar of the value; escaped line breaks are only
74
+ * decoded where they cannot belong to a literal of it.
75
+ */
76
+ export function decodeSourceTextTransport(value: unknown, language: SourceLanguage): unknown {
77
+ return decodeEscapedLineBreaks(
78
+ decodeHtmlEscapedSource(decodeJsonEncodedSource(unwrapCdataEnvelope(value))),
79
+ language,
80
+ );
70
81
  }