@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.
Files changed (73) hide show
  1. package/dist/ai-tools.d.ts +30 -14
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +33 -14
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +7 -6
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +17 -13
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/html-blank.d.ts +11 -0
  10. package/dist/html-blank.d.ts.map +1 -0
  11. package/dist/html-blank.js +17 -0
  12. package/dist/html-blank.js.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/link-canonicalise.d.ts +29 -4
  18. package/dist/link-canonicalise.d.ts.map +1 -1
  19. package/dist/link-canonicalise.js +96 -8
  20. package/dist/link-canonicalise.js.map +1 -1
  21. package/dist/markup-structure.d.ts +24 -0
  22. package/dist/markup-structure.d.ts.map +1 -1
  23. package/dist/markup-structure.js +52 -0
  24. package/dist/markup-structure.js.map +1 -1
  25. package/dist/media.d.ts +2 -2
  26. package/dist/phase-timer.d.ts +36 -0
  27. package/dist/phase-timer.d.ts.map +1 -0
  28. package/dist/phase-timer.js +54 -0
  29. package/dist/phase-timer.js.map +1 -0
  30. package/dist/run-batched.d.ts +12 -0
  31. package/dist/run-batched.d.ts.map +1 -0
  32. package/dist/run-batched.js +37 -0
  33. package/dist/run-batched.js.map +1 -0
  34. package/dist/seo.d.ts +12 -5
  35. package/dist/seo.d.ts.map +1 -1
  36. package/dist/seo.js +40 -8
  37. package/dist/seo.js.map +1 -1
  38. package/dist/strip-cdata.d.ts +23 -0
  39. package/dist/strip-cdata.d.ts.map +1 -1
  40. package/dist/strip-cdata.js +31 -0
  41. package/dist/strip-cdata.js.map +1 -1
  42. package/dist/template-engine.d.ts.map +1 -1
  43. package/dist/template-engine.js +54 -22
  44. package/dist/template-engine.js.map +1 -1
  45. package/dist/themes.d.ts.map +1 -1
  46. package/dist/themes.js +29 -4
  47. package/dist/themes.js.map +1 -1
  48. package/dist/version.d.ts +2 -2
  49. package/dist/version.js +1 -1
  50. package/package.json +1 -1
  51. package/src/ai-tools.ts +34 -14
  52. package/src/build-page.test.ts +5 -4
  53. package/src/build-page.ts +17 -14
  54. package/src/html-blank.ts +21 -0
  55. package/src/index.ts +2 -0
  56. package/src/link-canonicalise.test.ts +27 -0
  57. package/src/link-canonicalise.ts +100 -5
  58. package/src/markup-structure.test.ts +21 -0
  59. package/src/markup-structure.ts +54 -0
  60. package/src/nested-module-render.test.ts +77 -0
  61. package/src/phase-timer.test.ts +48 -0
  62. package/src/phase-timer.ts +62 -0
  63. package/src/run-batched.test.ts +40 -0
  64. package/src/run-batched.ts +39 -0
  65. package/src/seo.test.ts +50 -0
  66. package/src/seo.ts +40 -8
  67. package/src/strip-cdata.test.ts +30 -1
  68. package/src/strip-cdata.ts +31 -0
  69. package/src/template-engine.test.ts +84 -0
  70. package/src/template-engine.ts +62 -17
  71. package/src/theme-dimension.test.ts +61 -0
  72. package/src/themes.ts +30 -5
  73. 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 just before `</head>`. Replaces any
337
- * existing `<title>` so the page-level title wins over a layout-
338
- * supplied default. Pre-existing meta tags from the layout are left
339
- * alone — authors can still drop e.g. `<meta name="theme-color">` in
340
- * the layout HTML.
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 beforeClose = html.slice(0, headEnd).replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
349
- const afterClose = html.slice(headEnd);
350
- return `${beforeClose}${headBlock}\n${afterClose}`;
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
+ }
@@ -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
+ });
@@ -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({