@caelo-cms/shared 0.10.67 → 0.10.69
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 -14
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +33 -14
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +7 -6
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +17 -13
- package/dist/build-page.js.map +1 -1
- package/dist/html-blank.d.ts +11 -0
- package/dist/html-blank.d.ts.map +1 -0
- package/dist/html-blank.js +17 -0
- package/dist/html-blank.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/link-canonicalise.d.ts +29 -4
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +96 -8
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/markup-structure.d.ts +24 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +52 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +2 -2
- package/dist/phase-timer.d.ts +36 -0
- package/dist/phase-timer.d.ts.map +1 -0
- package/dist/phase-timer.js +54 -0
- package/dist/phase-timer.js.map +1 -0
- package/dist/run-batched.d.ts +12 -0
- package/dist/run-batched.d.ts.map +1 -0
- package/dist/run-batched.js +37 -0
- package/dist/run-batched.js.map +1 -0
- package/dist/seo.d.ts +12 -5
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +40 -8
- package/dist/seo.js.map +1 -1
- package/dist/strip-cdata.d.ts +23 -0
- package/dist/strip-cdata.d.ts.map +1 -1
- package/dist/strip-cdata.js +31 -0
- package/dist/strip-cdata.js.map +1 -1
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +54 -22
- package/dist/template-engine.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +29 -4
- 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/ai-tools.ts +34 -14
- package/src/build-page.test.ts +5 -4
- package/src/build-page.ts +17 -14
- package/src/html-blank.ts +21 -0
- package/src/index.ts +2 -0
- package/src/link-canonicalise.test.ts +27 -0
- package/src/link-canonicalise.ts +100 -5
- package/src/markup-structure.test.ts +21 -0
- package/src/markup-structure.ts +54 -0
- package/src/nested-module-render.test.ts +77 -0
- package/src/phase-timer.test.ts +48 -0
- package/src/phase-timer.ts +62 -0
- package/src/run-batched.test.ts +40 -0
- package/src/run-batched.ts +39 -0
- package/src/seo.test.ts +50 -0
- package/src/seo.ts +40 -8
- package/src/strip-cdata.test.ts +30 -1
- package/src/strip-cdata.ts +31 -0
- package/src/template-engine.test.ts +84 -0
- package/src/template-engine.ts +62 -17
- package/src/theme-dimension.test.ts +61 -0
- package/src/themes.ts +30 -5
- package/src/version.ts +1 -1
|
@@ -211,6 +211,83 @@ describe("renderModuleWithContent — nested module slots", () => {
|
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
describe("renderModuleWithContent — content braces through nesting (#1053)", () => {
|
|
215
|
+
it("a nested richtext value with {{Page URL}} reaches the page literally", () => {
|
|
216
|
+
const resolver = buildResolver(
|
|
217
|
+
[
|
|
218
|
+
{
|
|
219
|
+
moduleId: PARENT_MOD_ID,
|
|
220
|
+
slug: "parent",
|
|
221
|
+
html: "<section>{{>child}}</section>",
|
|
222
|
+
css: "",
|
|
223
|
+
js: "",
|
|
224
|
+
fields: [{ name: "child", kind: "module" }],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
moduleId: CHILD_MOD_ID,
|
|
228
|
+
slug: "child",
|
|
229
|
+
html: "<div>{{{body_html}}}</div>",
|
|
230
|
+
css: "",
|
|
231
|
+
js: "",
|
|
232
|
+
fields: [{ name: "body_html", kind: "richtext" }],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
[
|
|
236
|
+
{
|
|
237
|
+
id: PARENT_CI_ID,
|
|
238
|
+
moduleId: PARENT_MOD_ID,
|
|
239
|
+
values: { child: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID } },
|
|
240
|
+
deletedAt: null,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: CHILD_CI_ID,
|
|
244
|
+
moduleId: CHILD_MOD_ID,
|
|
245
|
+
values: { body_html: "<code>link_en.href = '{{Page URL}}';</code>" },
|
|
246
|
+
deletedAt: null,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
);
|
|
250
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
251
|
+
expect(r.html).toBe(
|
|
252
|
+
`<section><div ${tag(CHILD_MOD_ID)}><code>link_en.href = '{{Page URL}}';</code></div></section>`,
|
|
253
|
+
);
|
|
254
|
+
expect(r.missingSlots).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("renderModuleWithContent — empty optional module field (#1041)", () => {
|
|
259
|
+
it("an unset or null `module` field renders an empty slot without a marker", () => {
|
|
260
|
+
for (const values of [{}, { after: null }]) {
|
|
261
|
+
const resolver = buildResolver(
|
|
262
|
+
[
|
|
263
|
+
{
|
|
264
|
+
moduleId: PARENT_MOD_ID,
|
|
265
|
+
slug: "card-grid",
|
|
266
|
+
html: "<section><h2>{{title}}</h2>{{>after}}</section>",
|
|
267
|
+
css: "",
|
|
268
|
+
js: "",
|
|
269
|
+
fields: [
|
|
270
|
+
{ name: "title", kind: "text" },
|
|
271
|
+
{ name: "after", kind: "module" },
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
[
|
|
276
|
+
{
|
|
277
|
+
id: PARENT_CI_ID,
|
|
278
|
+
moduleId: PARENT_MOD_ID,
|
|
279
|
+
values: { title: "T", ...values },
|
|
280
|
+
deletedAt: null,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
);
|
|
284
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
285
|
+
expect(r.html).toBe("<section><h2>T</h2></section>");
|
|
286
|
+
expect(r.missingSlots).toEqual([]);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
214
291
|
describe("renderModuleWithContent — list-of-primitive slots", () => {
|
|
215
292
|
it("text-list iterates {{.}} per string element", () => {
|
|
216
293
|
const resolver = buildResolver(
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { PhaseTimer, phaseTimingSchema } from "./phase-timer.js";
|
|
5
|
+
|
|
6
|
+
function clock(...ticks: number[]): () => number {
|
|
7
|
+
let i = 0;
|
|
8
|
+
return () => {
|
|
9
|
+
const t = ticks[Math.min(i, ticks.length - 1)];
|
|
10
|
+
i += 1;
|
|
11
|
+
return t ?? 0;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("PhaseTimer", () => {
|
|
16
|
+
it("each lap measures the time since the previous one", () => {
|
|
17
|
+
const t = new PhaseTimer(clock(0, 10.4, 35.6, 40));
|
|
18
|
+
t.lap("load");
|
|
19
|
+
t.lap("render");
|
|
20
|
+
expect(t.phases()).toEqual([
|
|
21
|
+
{ phase: "load", ms: 10 },
|
|
22
|
+
{ phase: "render", ms: 25 },
|
|
23
|
+
]);
|
|
24
|
+
expect(t.totalMs()).toBe(40);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("includes steps timed elsewhere under a prefix, before the lap that spans them", () => {
|
|
28
|
+
const t = new PhaseTimer(clock(0, 120));
|
|
29
|
+
t.include("generator", [
|
|
30
|
+
{ phase: "compose", ms: 80 },
|
|
31
|
+
{ phase: "media", ms: 30 },
|
|
32
|
+
]);
|
|
33
|
+
t.lap("generator");
|
|
34
|
+
expect(t.phases()).toEqual([
|
|
35
|
+
{ phase: "generator.compose", ms: 80 },
|
|
36
|
+
{ phase: "generator.media", ms: 30 },
|
|
37
|
+
{ phase: "generator", ms: 120 },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("phases() is a copy and every entry passes the wire schema", () => {
|
|
42
|
+
const t = new PhaseTimer(clock(0, 5));
|
|
43
|
+
t.lap("x");
|
|
44
|
+
t.phases().pop();
|
|
45
|
+
expect(t.phases()).toHaveLength(1);
|
|
46
|
+
for (const p of t.phases()) expect(phaseTimingSchema.parse(p)).toEqual(p);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wall-clock timings of the steps of one long operation (a Stage: chat
|
|
5
|
+
* merge, generator passes, upload, parity fingerprint, preview selection).
|
|
6
|
+
* The generator subprocess and the admin share this shape, so the
|
|
7
|
+
* generator's phases arrive in the admin's list prefixed and in order.
|
|
8
|
+
*
|
|
9
|
+
* Laps instead of wrapped callbacks: `lap("x")` closes the step that ran
|
|
10
|
+
* since the previous lap, so a long function gets one line per boundary
|
|
11
|
+
* and keeps its control flow (early throws, shared locals) as it is.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
/** One finished step: its name and how long it took, in whole milliseconds. */
|
|
17
|
+
export const phaseTimingSchema = z.object({
|
|
18
|
+
phase: z.string().min(1),
|
|
19
|
+
ms: z.number().int().nonnegative(),
|
|
20
|
+
});
|
|
21
|
+
export type PhaseTiming = z.infer<typeof phaseTimingSchema>;
|
|
22
|
+
|
|
23
|
+
export class PhaseTimer {
|
|
24
|
+
readonly #now: () => number;
|
|
25
|
+
readonly #phases: PhaseTiming[] = [];
|
|
26
|
+
readonly #start: number;
|
|
27
|
+
#last: number;
|
|
28
|
+
|
|
29
|
+
/** @param now clock in milliseconds; injectable for tests. */
|
|
30
|
+
constructor(now: () => number = () => performance.now()) {
|
|
31
|
+
this.#now = now;
|
|
32
|
+
this.#start = now();
|
|
33
|
+
this.#last = this.#start;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Close the step that ran since the previous lap (or construction) as `phase`. */
|
|
37
|
+
lap(phase: string): void {
|
|
38
|
+
const t = this.#now();
|
|
39
|
+
this.#phases.push({ phase, ms: Math.round(t - this.#last) });
|
|
40
|
+
this.#last = t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Append steps timed elsewhere (the generator subprocess, a publisher)
|
|
45
|
+
* as `<prefix>.<phase>`. They ran inside the current lap, so the lap that
|
|
46
|
+
* closes it still measures the whole span (the remainder is the overhead
|
|
47
|
+
* around them, e.g. spawning the subprocess).
|
|
48
|
+
*/
|
|
49
|
+
include(prefix: string, phases: readonly PhaseTiming[]): void {
|
|
50
|
+
for (const p of phases) this.#phases.push({ phase: `${prefix}.${p.phase}`, ms: p.ms });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Milliseconds since construction. */
|
|
54
|
+
totalMs(): number {
|
|
55
|
+
return Math.round(this.#now() - this.#start);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The steps so far, in the order they finished. */
|
|
59
|
+
phases(): PhaseTiming[] {
|
|
60
|
+
return [...this.#phases];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { runBatched } from "./run-batched.js";
|
|
5
|
+
|
|
6
|
+
describe("runBatched", () => {
|
|
7
|
+
it("runs every item with at most `parallelism` in flight", async () => {
|
|
8
|
+
let inFlight = 0;
|
|
9
|
+
let peak = 0;
|
|
10
|
+
const done: number[] = [];
|
|
11
|
+
await runBatched([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
|
|
12
|
+
inFlight += 1;
|
|
13
|
+
peak = Math.max(peak, inFlight);
|
|
14
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
15
|
+
done.push(n);
|
|
16
|
+
inFlight -= 1;
|
|
17
|
+
});
|
|
18
|
+
expect(done.sort()).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
|
19
|
+
expect(peak).toBe(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("stops starting new items after the first failure and rejects with it", async () => {
|
|
23
|
+
const started: number[] = [];
|
|
24
|
+
await expect(
|
|
25
|
+
runBatched([1, 2, 3, 4, 5, 6], 2, async (n) => {
|
|
26
|
+
started.push(n);
|
|
27
|
+
if (n === 2) throw new Error("boom 2");
|
|
28
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
29
|
+
}),
|
|
30
|
+
).rejects.toThrow("boom 2");
|
|
31
|
+
expect(started.length).toBeLessThan(6);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("handles an empty list and refuses a non-positive parallelism", async () => {
|
|
35
|
+
await runBatched([], 4, async () => {
|
|
36
|
+
throw new Error("never");
|
|
37
|
+
});
|
|
38
|
+
await expect(runBatched([1], 0, async () => {})).rejects.toThrow("positive integer");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run `worker` over `items` with at most `parallelism` in flight, in item
|
|
5
|
+
* order. File and network I/O of a build (page writes, the `current/`
|
|
6
|
+
* mirror, uploads) goes through this: one-at-a-time costs a round trip per
|
|
7
|
+
* file, unbounded `Promise.all` exhausts file descriptors and memory on
|
|
8
|
+
* large sites.
|
|
9
|
+
*
|
|
10
|
+
* The first rejection rejects the whole call; workers already running
|
|
11
|
+
* finish, no new item starts.
|
|
12
|
+
*/
|
|
13
|
+
export async function runBatched<T>(
|
|
14
|
+
items: ReadonlyArray<T>,
|
|
15
|
+
parallelism: number,
|
|
16
|
+
worker: (item: T) => Promise<void>,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
if (!Number.isInteger(parallelism) || parallelism < 1) {
|
|
19
|
+
throw new Error(`runBatched: parallelism must be a positive integer, got ${parallelism}`);
|
|
20
|
+
}
|
|
21
|
+
let cursor = 0;
|
|
22
|
+
let failed = false;
|
|
23
|
+
const next = async (): Promise<void> => {
|
|
24
|
+
while (!failed && cursor < items.length) {
|
|
25
|
+
const i = cursor++;
|
|
26
|
+
try {
|
|
27
|
+
// biome-ignore lint/style/noNonNullAssertion: cursor < length checked
|
|
28
|
+
await worker(items[i]!);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
failed = true;
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const workers: Promise<void>[] = [];
|
|
36
|
+
const n = Math.min(parallelism, items.length);
|
|
37
|
+
for (let i = 0; i < n; i++) workers.push(next());
|
|
38
|
+
await Promise.all(workers);
|
|
39
|
+
}
|
package/src/seo.test.ts
CHANGED
|
@@ -208,6 +208,56 @@ describe("injectSeoIntoHead", () => {
|
|
|
208
208
|
expect(out).toContain("<title>PageTitle</title>\n</HEAD>");
|
|
209
209
|
expect(out).toContain("<svg><title>IconTitle</title></svg>");
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
// #1017 — the block goes early, before the inline theme CSS.
|
|
213
|
+
const THEME = '<style data-source="theme">body{}</style><link rel="stylesheet" href="/a.css">';
|
|
214
|
+
|
|
215
|
+
it("goes after charset and viewport, before the first style and stylesheet", () => {
|
|
216
|
+
const html = `<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">${THEME}<title>Old</title></head><body><svg><title>Icon</title></svg></body></html>`;
|
|
217
|
+
const out = injectSeoIntoHead(html, "<title>Page</title>");
|
|
218
|
+
expect(out).toStartWith(
|
|
219
|
+
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">\n<title>Page</title>\n<style',
|
|
220
|
+
);
|
|
221
|
+
expect(out.indexOf("<title>Page</title>")).toBeLessThan(out.indexOf("<style"));
|
|
222
|
+
expect(out.indexOf("<title>Page</title>")).toBeLessThan(out.indexOf('<link rel="stylesheet"'));
|
|
223
|
+
expect(out).not.toContain("Old");
|
|
224
|
+
expect(out).toContain("<svg><title>Icon</title></svg>");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("goes after the charset when there is no viewport", () => {
|
|
228
|
+
const out = injectSeoIntoHead(
|
|
229
|
+
`<html><head><meta charset="utf-8">${THEME}</head><body></body></html>`,
|
|
230
|
+
"<title>Page</title>",
|
|
231
|
+
);
|
|
232
|
+
expect(out).toStartWith('<html><head><meta charset="utf-8">\n<title>Page</title>\n<style');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("goes right after <head> when there is neither", () => {
|
|
236
|
+
const out = injectSeoIntoHead(
|
|
237
|
+
`<html><head lang="en">${THEME}</head><body></body></html>`,
|
|
238
|
+
"<title>Page</title>",
|
|
239
|
+
);
|
|
240
|
+
expect(out).toStartWith('<html><head lang="en">\n<title>Page</title>\n<style');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("ignores a viewport-looking meta in the body", () => {
|
|
244
|
+
const out = injectSeoIntoHead(
|
|
245
|
+
'<html><head><meta charset="utf-8"></head><body><meta name="viewport"></body></html>',
|
|
246
|
+
"<title>Page</title>",
|
|
247
|
+
);
|
|
248
|
+
expect(out).toStartWith('<html><head><meta charset="utf-8">\n<title>Page</title>');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("ignores meta tags inside a comment or a script string", () => {
|
|
252
|
+
const script = `<script>const sample = '<meta name="viewport" content="width=device-width">';</script>`;
|
|
253
|
+
const comment = '<!-- <meta name="viewport" content="x"> -->';
|
|
254
|
+
const html = `<html><head><meta charset="utf-8">${comment}${script}${THEME}</head><body></body></html>`;
|
|
255
|
+
const out = injectSeoIntoHead(html, "<title>Page</title>");
|
|
256
|
+
expect(out).toStartWith('<html><head><meta charset="utf-8">\n<title>Page</title>\n<!--');
|
|
257
|
+
// Comment and script come through byte-for-byte, nothing inserted in them.
|
|
258
|
+
expect(out).toContain(comment);
|
|
259
|
+
expect(out).toContain(script);
|
|
260
|
+
});
|
|
211
261
|
});
|
|
212
262
|
|
|
213
263
|
describe("schemas", () => {
|
package/src/seo.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { z } from "zod";
|
|
11
|
+
import { blankNonMarkup } from "./html-blank.js";
|
|
11
12
|
import { trimSlashes } from "./url.js";
|
|
12
13
|
|
|
13
14
|
export const CHANGEFREQ_VALUES = [
|
|
@@ -333,11 +334,18 @@ export function renderSeoHead(input: SeoMetaInput): string {
|
|
|
333
334
|
}
|
|
334
335
|
|
|
335
336
|
/**
|
|
336
|
-
* Inject `renderSeoHead`'s output
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* the
|
|
337
|
+
* Inject `renderSeoHead`'s output early in `<head>`: right after the last
|
|
338
|
+
* `<meta charset>` / `<meta name="viewport">`, or right after `<head>`
|
|
339
|
+
* when the layout has neither (#1017). Layouts inline the theme CSS, so a
|
|
340
|
+
* block appended before `</head>` put the title tens of kilobytes into
|
|
341
|
+
* the page, past what byte-limited crawlers and link-preview fetchers
|
|
342
|
+
* read; charset and viewport stay first, as the HTML spec wants the
|
|
343
|
+
* charset in the first 1024 bytes.
|
|
344
|
+
*
|
|
345
|
+
* Replaces any existing `<title>` so the page-level title wins over a
|
|
346
|
+
* layout-supplied default. Pre-existing meta tags from the layout are
|
|
347
|
+
* left alone — authors can still drop e.g. `<meta name="theme-color">`
|
|
348
|
+
* in the layout HTML.
|
|
341
349
|
*/
|
|
342
350
|
export function injectSeoIntoHead(html: string, headBlock: string): string {
|
|
343
351
|
const headCloseMatch = /<\/head\s*>/i.exec(html);
|
|
@@ -345,11 +353,35 @@ export function injectSeoIntoHead(html: string, headBlock: string): string {
|
|
|
345
353
|
const headEnd = headCloseMatch.index;
|
|
346
354
|
// Strip a layout-supplied <title> inside <head> only (never an SVG
|
|
347
355
|
// <title> in <body>); we re-emit the page title in headBlock.
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
return `${
|
|
356
|
+
const head = html.slice(0, headEnd).replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
|
|
357
|
+
const at = seoInsertionPoint(head);
|
|
358
|
+
return `${head.slice(0, at)}\n${headBlock}\n${head.slice(at)}${html.slice(headEnd)}`;
|
|
351
359
|
}
|
|
352
360
|
// No closing head tag — prepend the block to the document.
|
|
353
361
|
const titleStripped = html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
|
|
354
362
|
return `${headBlock}\n${titleStripped}`;
|
|
355
363
|
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Where the SEO block goes inside `head` (the document up to `</head>`):
|
|
367
|
+
* after the last charset / viewport meta of the head, else after the
|
|
368
|
+
* `<head>` tag, else (a document with `</head>` but no `<head>` tag) at
|
|
369
|
+
* the end, where it always went.
|
|
370
|
+
*/
|
|
371
|
+
function seoInsertionPoint(head: string): number {
|
|
372
|
+
// Only real elements count: a meta tag inside a comment or a script
|
|
373
|
+
// string would otherwise put the block inside that comment or string.
|
|
374
|
+
const markup = blankNonMarkup(head);
|
|
375
|
+
const open = /<head\b[^>]*>/i.exec(markup);
|
|
376
|
+
if (!open) return head.length;
|
|
377
|
+
const start = open.index + open[0].length;
|
|
378
|
+
let at = start;
|
|
379
|
+
const metaRe = /<meta\b[^>]*>/gi;
|
|
380
|
+
metaRe.lastIndex = start;
|
|
381
|
+
for (let m = metaRe.exec(markup); m !== null; m = metaRe.exec(markup)) {
|
|
382
|
+
if (/\bcharset\s*=/i.test(m[0]) || /\bname\s*=\s*["']?viewport\b/i.test(m[0])) {
|
|
383
|
+
at = m.index + m[0].length;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return at;
|
|
387
|
+
}
|
package/src/strip-cdata.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
-
import { stripCdataGuards } from "./strip-cdata.js";
|
|
4
|
+
import { stripCdataGuards, unwrapCdataEnvelope } from "./strip-cdata.js";
|
|
5
5
|
|
|
6
6
|
describe("stripCdataGuards", () => {
|
|
7
7
|
it("leaves clean HTML untouched (fast path)", () => {
|
|
@@ -39,3 +39,32 @@ describe("stripCdataGuards", () => {
|
|
|
39
39
|
expect(stripCdataGuards(html)).toBe(html);
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
describe("unwrapCdataEnvelope", () => {
|
|
44
|
+
// Shape of the add_module css the real-AI suite filed (run 39936064709).
|
|
45
|
+
const css =
|
|
46
|
+
".consent-banner {\n position: fixed;\n}\n@media (max-width: 640px) {\n .x { width: 100%; }\n}";
|
|
47
|
+
|
|
48
|
+
it("decodes a whole-value envelope to its exact body", () => {
|
|
49
|
+
expect(unwrapCdataEnvelope(`<![CDATA[${css}]]>`)).toBe(css);
|
|
50
|
+
expect(unwrapCdataEnvelope(`\n <![CDATA[${css}]]>\n`)).toBe(css);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves values without an enclosing envelope untouched", () => {
|
|
54
|
+
expect(unwrapCdataEnvelope(css)).toBe(css);
|
|
55
|
+
const guarded = `/*<![CDATA[*/ ${css} /*]]>*/`;
|
|
56
|
+
expect(unwrapCdataEnvelope(guarded)).toBe(guarded);
|
|
57
|
+
const midValue = `.a{} <![CDATA[ .b{} ]]>`;
|
|
58
|
+
expect(unwrapCdataEnvelope(midValue)).toBe(midValue);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not decode two CDATA sections as one envelope", () => {
|
|
62
|
+
const two = "<![CDATA[.a{}]]> <![CDATA[.b{}]]>";
|
|
63
|
+
expect(unwrapCdataEnvelope(two)).toBe(two);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("passes non-strings through for the schema to judge", () => {
|
|
67
|
+
expect(unwrapCdataEnvelope(undefined)).toBeUndefined();
|
|
68
|
+
expect(unwrapCdataEnvelope(42)).toBe(42);
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/strip-cdata.ts
CHANGED
|
@@ -48,3 +48,34 @@ export function stripCdataGuards(html: string): string {
|
|
|
48
48
|
if (!html.includes("CDATA") && !html.includes("]]>")) return html;
|
|
49
49
|
return html.replace(CDATA_GUARDS, "");
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
const CDATA_ENVELOPE = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode a whole-value `<![CDATA[ … ]]>` envelope around an AI tool
|
|
56
|
+
* argument that carries source text (module/template/layout html, css, js).
|
|
57
|
+
*
|
|
58
|
+
* Why at the tool boundary: tool parameters reach the model through an
|
|
59
|
+
* XML-shaped encoding, and for long markup strings the model sometimes
|
|
60
|
+
* escapes the WHOLE value as a CDATA section — a transport envelope, not
|
|
61
|
+
* content. The parameter descriptions say not to (issue #993's note), yet
|
|
62
|
+
* the real-AI suite still filed it on add_module css in roughly one run in
|
|
63
|
+
* seven (e.g. 39654716622 on main, 39936064709, 39971619728). Refusing it
|
|
64
|
+
* cost a tool round-trip every time and, for template/layout css (which has
|
|
65
|
+
* no CDATA refusal), stored a stylesheet the browser cannot parse.
|
|
66
|
+
*
|
|
67
|
+
* Only an envelope spanning the entire value is decoded, and only when its
|
|
68
|
+
* body contains no further `]]>` (a single CDATA section cannot). Anything
|
|
69
|
+
* else — `/*<![CDATA[*\/` guards inside the CSS, a marker mid-value — is
|
|
70
|
+
* left untouched so the op validator still refuses it loudly (CLAUDE.md §2):
|
|
71
|
+
* this decodes a malformed encoding of a value the caller did supply; it is
|
|
72
|
+
* not a fallback for missing data.
|
|
73
|
+
*
|
|
74
|
+
* @returns the envelope's body, or the input unchanged.
|
|
75
|
+
*/
|
|
76
|
+
export function unwrapCdataEnvelope(value: unknown): unknown {
|
|
77
|
+
if (typeof value !== "string") return value;
|
|
78
|
+
const m = CDATA_ENVELOPE.exec(value);
|
|
79
|
+
if (!m || m[1] === undefined || m[1].includes("]]>")) return value;
|
|
80
|
+
return m[1];
|
|
81
|
+
}
|
|
@@ -269,6 +269,90 @@ describe("renderTemplate — single nested {{>name}}", () => {
|
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
describe("renderTemplate — empty single module field (#1041)", () => {
|
|
273
|
+
const moduleField: TemplateField = { name: "after", kind: "module" };
|
|
274
|
+
|
|
275
|
+
it("an unset module field renders empty and is not a failure", () => {
|
|
276
|
+
const r = renderTemplate({
|
|
277
|
+
html: "<aside>{{>after}}</aside>",
|
|
278
|
+
fields: [moduleField],
|
|
279
|
+
contentValues: {},
|
|
280
|
+
partials: {},
|
|
281
|
+
});
|
|
282
|
+
expect(r.html).toBe("<aside></aside>");
|
|
283
|
+
expect(r.missingSlots).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("an explicit null module field renders empty and is not a failure", () => {
|
|
287
|
+
const r = renderTemplate({
|
|
288
|
+
html: "<aside>{{>after}}</aside>",
|
|
289
|
+
fields: [moduleField],
|
|
290
|
+
contentValues: { after: null },
|
|
291
|
+
partials: {},
|
|
292
|
+
});
|
|
293
|
+
expect(r.html).toBe("<aside></aside>");
|
|
294
|
+
expect(r.missingSlots).toEqual([]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("a present non-ref value is still module-ref-malformed", () => {
|
|
298
|
+
const r = renderTemplate({
|
|
299
|
+
html: "<aside>{{>after}}</aside>",
|
|
300
|
+
fields: [moduleField],
|
|
301
|
+
contentValues: { after: { moduleId: "m" } },
|
|
302
|
+
});
|
|
303
|
+
expect(r.missingSlots).toEqual(["module-ref-malformed:after"]);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("renderTemplate — literal braces in content are data (#1053)", () => {
|
|
308
|
+
it("a text-list item keeps a literal {{Page URL}}", () => {
|
|
309
|
+
const r = renderTemplate({
|
|
310
|
+
html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
|
|
311
|
+
fields: [{ name: "items", kind: "text-list" }],
|
|
312
|
+
contentValues: { items: ["link.href = '{{Page URL}}';", "{{title}}"] },
|
|
313
|
+
});
|
|
314
|
+
expect(r.html).toBe("<ul><li>link.href = '{{Page URL}}';</li><li>{{title}}</li></ul>");
|
|
315
|
+
expect(r.missingSlots).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("a link-list label is never re-substituted as a placeholder", () => {
|
|
319
|
+
const r = renderTemplate({
|
|
320
|
+
html: '<nav>{{#nav}}<a href="{{href}}">{{label}}</a>{{/nav}}</nav>',
|
|
321
|
+
fields: [{ name: "nav", kind: "link-list" }],
|
|
322
|
+
contentValues: { nav: [{ label: "{{href}} {{Click URL}}", href: "/x" }] },
|
|
323
|
+
});
|
|
324
|
+
expect(r.html).toBe('<nav><a href="/x">{{href}} {{Click URL}}</a></nav>');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("rendered nested module HTML is pasted verbatim, not parsed again", () => {
|
|
328
|
+
const r = renderTemplate({
|
|
329
|
+
html: "<section>{{>body}}</section>",
|
|
330
|
+
fields: [{ name: "body", kind: "module" }],
|
|
331
|
+
contentValues: { body: { moduleId: "m", contentInstanceId: "c" } },
|
|
332
|
+
partials: { body: "<code>{{Page URL}}</code>" },
|
|
333
|
+
});
|
|
334
|
+
expect(r.html).toBe("<section><code>{{Page URL}}</code></section>");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("a top-level {{{body_html}}} value with braces still renders as written", () => {
|
|
338
|
+
const r = renderTemplate({
|
|
339
|
+
html: "<article>{{{body_html}}}</article>",
|
|
340
|
+
fields: [{ name: "body_html", kind: "richtext" }],
|
|
341
|
+
contentValues: { body_html: "<p><code>{{Page URL}}</code></p>" },
|
|
342
|
+
});
|
|
343
|
+
expect(r.html).toBe("<article><p><code>{{Page URL}}</code></p></article>");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("a non-identifier tag in module HTML stays literal instead of rendering empty", () => {
|
|
347
|
+
const r = renderTemplate({
|
|
348
|
+
html: "<script>dataLayer.push({ url: '{{Page URL}}' })</script>",
|
|
349
|
+
fields: [],
|
|
350
|
+
});
|
|
351
|
+
expect(r.html).toBe("<script>dataLayer.push({ url: '{{Page URL}}' })</script>");
|
|
352
|
+
expect(r.missingSlots).toEqual([]);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
272
356
|
describe("renderTemplate — loud-raw on unknown sections (AC #3)", () => {
|
|
273
357
|
it("unknown {{#unknown}}…{{/unknown}} leaves raw markers + tracks field-not-declared", () => {
|
|
274
358
|
const r = renderTemplate({
|