@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.
- package/dist/ai-tools.d.ts +30 -144
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +41 -141
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +5 -5
- package/dist/build-page.js +9 -9
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +13 -2
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +45 -12
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +1 -1
- package/dist/escaped-line-breaks.d.ts +44 -0
- package/dist/escaped-line-breaks.d.ts.map +1 -0
- package/dist/escaped-line-breaks.js +203 -0
- package/dist/escaped-line-breaks.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.js +5 -5
- package/dist/interactions.js.map +1 -1
- package/dist/lists/default-types.d.ts +12 -0
- package/dist/lists/default-types.d.ts.map +1 -1
- package/dist/lists/default-types.js +36 -0
- package/dist/lists/default-types.js.map +1 -1
- package/dist/lists/fields.d.ts.map +1 -1
- package/dist/lists/fields.js +1 -0
- package/dist/lists/fields.js.map +1 -1
- package/dist/lists/render-data.d.ts +9 -0
- package/dist/lists/render-data.d.ts.map +1 -1
- package/dist/lists/render-deps.d.ts +4 -3
- package/dist/lists/render-deps.d.ts.map +1 -1
- package/dist/lists/render-deps.js +5 -3
- package/dist/lists/render-deps.js.map +1 -1
- package/dist/lists/resolve.d.ts +13 -0
- package/dist/lists/resolve.d.ts.map +1 -1
- package/dist/lists/resolve.js +95 -3
- package/dist/lists/resolve.js.map +1 -1
- package/dist/nested-module-render.d.ts +13 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +34 -0
- package/dist/nested-module-render.js.map +1 -1
- package/dist/redirect-plan.d.ts +13 -0
- package/dist/redirect-plan.d.ts.map +1 -1
- package/dist/redirect-plan.js +22 -0
- package/dist/redirect-plan.js.map +1 -1
- package/dist/seo.d.ts +21 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +32 -0
- package/dist/seo.js.map +1 -1
- package/dist/source-text-transport.d.ts +28 -2
- package/dist/source-text-transport.d.ts.map +1 -1
- package/dist/source-text-transport.js +12 -4
- package/dist/source-text-transport.js.map +1 -1
- package/dist/template-sections.d.ts.map +1 -1
- package/dist/template-sections.js +17 -4
- package/dist/template-sections.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/redos-hardening.test.ts +1 -0
- package/src/ai-tools-add-module-modes.test.ts +1 -1
- package/src/ai-tools.test.ts +7 -20
- package/src/ai-tools.ts +49 -153
- package/src/bug-intake.test.ts +1 -1
- package/src/build-page.ts +9 -9
- package/src/content.test.ts +26 -1
- package/src/content.ts +47 -13
- package/src/css-scope-scan.ts +1 -1
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +1 -0
- package/src/interactions.ts +5 -5
- package/src/link-refs.test.ts +1 -0
- package/src/lists/default-types.ts +39 -0
- package/src/lists/fields.ts +1 -0
- package/src/lists/list-fixtures.test.ts +9 -3
- package/src/lists/render-data.ts +9 -0
- package/src/lists/render-deps.ts +6 -3
- package/src/lists/resolve.test.ts +182 -5
- package/src/lists/resolve.ts +101 -3
- package/src/nested-module-render.test.ts +39 -0
- package/src/nested-module-render.ts +35 -0
- package/src/redirect-plan.test.ts +25 -0
- package/src/redirect-plan.ts +24 -0
- package/src/seo.test.ts +58 -0
- package/src/seo.ts +49 -0
- package/src/source-text-transport.test.ts +98 -3
- package/src/source-text-transport.ts +15 -4
- package/src/template-engine-lists.test.ts +55 -0
- package/src/template-sections.ts +18 -4
- package/src/version.ts +1 -1
package/src/lists/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
+
});
|
package/src/redirect-plan.ts
CHANGED
|
@@ -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}]]
|
|
47
|
+
expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`, "html")).toBe(markup);
|
|
47
48
|
expect(
|
|
48
49
|
decodeSourceTextTransport(
|
|
49
50
|
JSON.stringify(markup.replaceAll("<", "<").replaceAll(">", ">")),
|
|
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\">…"`),
|
|
9
|
+
* literal (`"<footer class=\"x\">…"`), HTML-escaped (`<main>…`), 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 `<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(/&/g, "&");
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
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
|
}
|