@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.
- package/dist/ai-tools.d.ts +32 -144
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +46 -139
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +46 -3
- package/dist/bug-intake.d.ts.map +1 -1
- package/dist/bug-intake.js +52 -3
- package/dist/bug-intake.js.map +1 -1
- package/dist/build-page.d.ts +5 -5
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +14 -9
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +14 -2
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +73 -15
- package/dist/content.js.map +1 -1
- package/dist/css-scope-scan.js +1 -1
- package/dist/css-var-scan.js +3 -3
- package/dist/css-var-scan.js.map +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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/live-events.d.ts +44 -0
- package/dist/live-events.d.ts.map +1 -0
- package/dist/live-events.js +128 -0
- package/dist/live-events.js.map +1 -0
- package/dist/markup-structure.d.ts +20 -2
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +69 -3
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +15 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js.map +1 -1
- package/dist/module-id-tag.d.ts +4 -1
- package/dist/module-id-tag.d.ts.map +1 -1
- package/dist/module-id-tag.js +11 -2
- package/dist/module-id-tag.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/preview-compose.d.ts +18 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +26 -7
- package/dist/preview-compose.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/theme-render.js +11 -26
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +12 -9
- package/dist/themes.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 +57 -151
- package/src/bug-intake.test.ts +48 -1
- package/src/bug-intake.ts +58 -3
- package/src/build-page.ts +14 -9
- package/src/content.test.ts +26 -1
- package/src/content.ts +84 -16
- package/src/css-scope-scan.ts +1 -1
- package/src/css-var-scan.ts +3 -3
- package/src/escaped-line-breaks.ts +221 -0
- package/src/index.ts +2 -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/live-events.ts +138 -0
- package/src/markup-structure.test.ts +47 -0
- package/src/markup-structure.ts +74 -3
- package/src/media.ts +12 -0
- package/src/module-id-tag.test.ts +38 -0
- package/src/module-id-tag.ts +11 -2
- package/src/nested-module-render.test.ts +39 -0
- package/src/nested-module-render.ts +35 -0
- package/src/preview-compose-deferrals.test.ts +44 -0
- package/src/preview-compose.ts +47 -10
- 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/theme-importers/__tests__/proto-pollution.test.ts +13 -3
- package/src/theme-render-sanitize.test.ts +8 -0
- package/src/theme-render.ts +11 -26
- package/src/themes.ts +12 -7
- package/src/version.ts +1 -1
package/src/markup-structure.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
+
});
|
package/src/module-id-tag.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/preview-compose.ts
CHANGED
|
@@ -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
|
|
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:
|
|
585
|
+
deferral: ComposeGateDeferral,
|
|
570
586
|
): string {
|
|
571
|
-
const attr = (v: string): string =>
|
|
572
|
-
v.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
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("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
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
|
|
697
|
-
//
|
|
698
|
-
const deferredCss = new
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
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
|
|