@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.
Files changed (137) hide show
  1. package/dist/ai-tools.d.ts +32 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +46 -139
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +46 -3
  6. package/dist/bug-intake.d.ts.map +1 -1
  7. package/dist/bug-intake.js +52 -3
  8. package/dist/bug-intake.js.map +1 -1
  9. package/dist/build-page.d.ts +5 -5
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +14 -9
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +14 -2
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +73 -15
  16. package/dist/content.js.map +1 -1
  17. package/dist/css-scope-scan.js +1 -1
  18. package/dist/css-var-scan.js +3 -3
  19. package/dist/css-var-scan.js.map +1 -1
  20. package/dist/escaped-line-breaks.d.ts +44 -0
  21. package/dist/escaped-line-breaks.d.ts.map +1 -0
  22. package/dist/escaped-line-breaks.js +203 -0
  23. package/dist/escaped-line-breaks.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/interactions.js +5 -5
  29. package/dist/interactions.js.map +1 -1
  30. package/dist/lists/default-types.d.ts +12 -0
  31. package/dist/lists/default-types.d.ts.map +1 -1
  32. package/dist/lists/default-types.js +36 -0
  33. package/dist/lists/default-types.js.map +1 -1
  34. package/dist/lists/fields.d.ts.map +1 -1
  35. package/dist/lists/fields.js +1 -0
  36. package/dist/lists/fields.js.map +1 -1
  37. package/dist/lists/render-data.d.ts +9 -0
  38. package/dist/lists/render-data.d.ts.map +1 -1
  39. package/dist/lists/render-deps.d.ts +4 -3
  40. package/dist/lists/render-deps.d.ts.map +1 -1
  41. package/dist/lists/render-deps.js +5 -3
  42. package/dist/lists/render-deps.js.map +1 -1
  43. package/dist/lists/resolve.d.ts +13 -0
  44. package/dist/lists/resolve.d.ts.map +1 -1
  45. package/dist/lists/resolve.js +95 -3
  46. package/dist/lists/resolve.js.map +1 -1
  47. package/dist/live-events.d.ts +44 -0
  48. package/dist/live-events.d.ts.map +1 -0
  49. package/dist/live-events.js +128 -0
  50. package/dist/live-events.js.map +1 -0
  51. package/dist/markup-structure.d.ts +20 -2
  52. package/dist/markup-structure.d.ts.map +1 -1
  53. package/dist/markup-structure.js +69 -3
  54. package/dist/markup-structure.js.map +1 -1
  55. package/dist/media.d.ts +15 -0
  56. package/dist/media.d.ts.map +1 -1
  57. package/dist/media.js.map +1 -1
  58. package/dist/module-id-tag.d.ts +4 -1
  59. package/dist/module-id-tag.d.ts.map +1 -1
  60. package/dist/module-id-tag.js +11 -2
  61. package/dist/module-id-tag.js.map +1 -1
  62. package/dist/nested-module-render.d.ts +13 -0
  63. package/dist/nested-module-render.d.ts.map +1 -1
  64. package/dist/nested-module-render.js +34 -0
  65. package/dist/nested-module-render.js.map +1 -1
  66. package/dist/preview-compose.d.ts +18 -1
  67. package/dist/preview-compose.d.ts.map +1 -1
  68. package/dist/preview-compose.js +26 -7
  69. package/dist/preview-compose.js.map +1 -1
  70. package/dist/redirect-plan.d.ts +13 -0
  71. package/dist/redirect-plan.d.ts.map +1 -1
  72. package/dist/redirect-plan.js +22 -0
  73. package/dist/redirect-plan.js.map +1 -1
  74. package/dist/seo.d.ts +21 -0
  75. package/dist/seo.d.ts.map +1 -1
  76. package/dist/seo.js +32 -0
  77. package/dist/seo.js.map +1 -1
  78. package/dist/source-text-transport.d.ts +28 -2
  79. package/dist/source-text-transport.d.ts.map +1 -1
  80. package/dist/source-text-transport.js +12 -4
  81. package/dist/source-text-transport.js.map +1 -1
  82. package/dist/template-sections.d.ts.map +1 -1
  83. package/dist/template-sections.js +17 -4
  84. package/dist/template-sections.js.map +1 -1
  85. package/dist/theme-render.js +11 -26
  86. package/dist/theme-render.js.map +1 -1
  87. package/dist/themes.d.ts.map +1 -1
  88. package/dist/themes.js +12 -9
  89. package/dist/themes.js.map +1 -1
  90. package/dist/version.d.ts +2 -2
  91. package/dist/version.js +1 -1
  92. package/package.json +1 -1
  93. package/src/__tests__/redos-hardening.test.ts +1 -0
  94. package/src/ai-tools-add-module-modes.test.ts +1 -1
  95. package/src/ai-tools.test.ts +7 -20
  96. package/src/ai-tools.ts +57 -151
  97. package/src/bug-intake.test.ts +48 -1
  98. package/src/bug-intake.ts +58 -3
  99. package/src/build-page.ts +14 -9
  100. package/src/content.test.ts +26 -1
  101. package/src/content.ts +84 -16
  102. package/src/css-scope-scan.ts +1 -1
  103. package/src/css-var-scan.ts +3 -3
  104. package/src/escaped-line-breaks.ts +221 -0
  105. package/src/index.ts +2 -0
  106. package/src/interactions.ts +5 -5
  107. package/src/link-refs.test.ts +1 -0
  108. package/src/lists/default-types.ts +39 -0
  109. package/src/lists/fields.ts +1 -0
  110. package/src/lists/list-fixtures.test.ts +9 -3
  111. package/src/lists/render-data.ts +9 -0
  112. package/src/lists/render-deps.ts +6 -3
  113. package/src/lists/resolve.test.ts +182 -5
  114. package/src/lists/resolve.ts +101 -3
  115. package/src/live-events.ts +138 -0
  116. package/src/markup-structure.test.ts +47 -0
  117. package/src/markup-structure.ts +74 -3
  118. package/src/media.ts +12 -0
  119. package/src/module-id-tag.test.ts +38 -0
  120. package/src/module-id-tag.ts +11 -2
  121. package/src/nested-module-render.test.ts +39 -0
  122. package/src/nested-module-render.ts +35 -0
  123. package/src/preview-compose-deferrals.test.ts +44 -0
  124. package/src/preview-compose.ts +47 -10
  125. package/src/redirect-plan.test.ts +25 -0
  126. package/src/redirect-plan.ts +24 -0
  127. package/src/seo.test.ts +58 -0
  128. package/src/seo.ts +49 -0
  129. package/src/source-text-transport.test.ts +98 -3
  130. package/src/source-text-transport.ts +15 -4
  131. package/src/template-engine-lists.test.ts +55 -0
  132. package/src/template-sections.ts +18 -4
  133. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  134. package/src/theme-render-sanitize.test.ts +8 -0
  135. package/src/theme-render.ts +11 -26
  136. package/src/themes.ts +12 -7
  137. package/src/version.ts +1 -1
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import { createTemplateToolInput } from "./ai-tools.js";
4
+ import { addModuleToolInput, createTemplateToolInput } from "./ai-tools.js";
5
+ import { decodeEscapedLineBreaks } from "./escaped-line-breaks.js";
5
6
  import {
6
7
  decodeHtmlEscapedSource,
7
8
  decodeJsonEncodedSource,
@@ -43,13 +44,14 @@ describe("decodeJsonEncodedSource", () => {
43
44
 
44
45
  describe("decodeSourceTextTransport", () => {
45
46
  it("unwraps CDATA, then a JSON literal, then HTML escaping", () => {
46
- expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`)).toBe(markup);
47
+ expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`, "html")).toBe(markup);
47
48
  expect(
48
49
  decodeSourceTextTransport(
49
50
  JSON.stringify(markup.replaceAll("<", "&lt;").replaceAll(">", "&gt;")),
51
+ "html",
50
52
  ),
51
53
  ).toBe(markup);
52
- expect(decodeSourceTextTransport(42)).toBe(42);
54
+ expect(decodeSourceTextTransport(42, "html")).toBe(42);
53
55
  });
54
56
 
55
57
  it("is what create_template's html argument goes through", () => {
@@ -61,3 +63,96 @@ describe("decodeSourceTextTransport", () => {
61
63
  expect(r.html).toBe(markup);
62
64
  });
63
65
  });
66
+
67
+ // e2e-livedit run 40532297620 (release gate v0.11.1): add_module's html, css
68
+ // and js arrived with every line break as the two characters `\` `n`. The
69
+ // stored footer showed literal `\n` text and the shared module <script>
70
+ // threw `SyntaxError: Invalid or unexpected token` on every preview. These
71
+ // are the exact values the AI sent.
72
+ describe("decodeEscapedLineBreaks", () => {
73
+ const sentJs =
74
+ "(function () {\\n document.querySelectorAll('.site-footer__year').forEach(function (el) {\\n el.textContent = new Date().getFullYear();\\n });\\n})();";
75
+ const sentCss =
76
+ ".site-footer {\\n padding: 2rem 1.5rem;\\n text-align: center;\\n}\\n.site-footer__link:hover {\\n text-decoration: underline;\\n}";
77
+ const sentHtml =
78
+ '<footer class="site-footer">\\n <nav class="site-footer__nav" aria-label="Footer">\\n <ul class="site-footer__list">\\n {{#nav}}\\n <li class="site-footer__item"><a class="site-footer__link" href="{{link.href}}">{{label}}</a></li>\\n {{/nav}}\\n </ul>\\n </nav>\\n</footer>';
79
+
80
+ it("turns escaped line breaks back into real ones (the run's exact values)", () => {
81
+ expect(decodeEscapedLineBreaks(sentJs, "js")).toBe(
82
+ "(function () {\n document.querySelectorAll('.site-footer__year').forEach(function (el) {\n el.textContent = new Date().getFullYear();\n });\n})();",
83
+ );
84
+ expect(decodeEscapedLineBreaks(sentCss, "css")).toBe(
85
+ ".site-footer {\n padding: 2rem 1.5rem;\n text-align: center;\n}\n.site-footer__link:hover {\n text-decoration: underline;\n}",
86
+ );
87
+ const html = decodeEscapedLineBreaks(sentHtml, "html") as string;
88
+ expect(html).not.toContain("\\n");
89
+ expect(html.split("\n")).toHaveLength(9);
90
+ expect(html).toContain('<footer class="site-footer">\n <nav');
91
+ });
92
+
93
+ it("decodes escaped CRLF and escaped tab indentation", () => {
94
+ expect(decodeEscapedLineBreaks(".a {\\r\\n\\tcolor: red;\\r\\n\\t\\tx: y;\\r\\n}", "css")).toBe(
95
+ ".a {\n\tcolor: red;\n\t\tx: y;\n}",
96
+ );
97
+ });
98
+
99
+ it("leaves source with real line breaks, single-line code and escaped backslashes alone", () => {
100
+ const multiLine = "const s = 'a\\n b';\nconsole.log(s.split('\\n '));";
101
+ expect(decodeEscapedLineBreaks(multiLine, "js")).toBe(multiLine);
102
+ const minified = "var t=a.join('\\n');console.log(t.split('\\n'))";
103
+ expect(decodeEscapedLineBreaks(minified, "js")).toBe(minified);
104
+ const oneHit = "el.textContent = lines.join('\\n ');";
105
+ expect(decodeEscapedLineBreaks(oneHit, "js")).toBe(oneHit);
106
+ const escapedBackslash = "a('\\\\n x'); b('\\\\n y');";
107
+ expect(decodeEscapedLineBreaks(escapedBackslash, "js")).toBe(escapedBackslash);
108
+ expect(decodeEscapedLineBreaks(7, "js")).toBe(7);
109
+ });
110
+
111
+ // PR #1189 review: an escape inside a literal is meaningful, so a value
112
+ // holding one is ambiguous and left exactly as sent — decoding it would
113
+ // turn valid source into broken source.
114
+ it("leaves values alone when an escaped line break sits inside a literal", () => {
115
+ const jsString = 'document.querySelector("pre").textContent = "Title\\n Detail\\n More";';
116
+ expect(decodeEscapedLineBreaks(jsString, "js")).toBe(jsString);
117
+ const jsTemplate = "(function () {\\n el.title = `a\\n b`;\\n})();";
118
+ expect(decodeEscapedLineBreaks(jsTemplate, "js")).toBe(jsTemplate);
119
+ const jsRegex = "(function () {\\n var ok = /a\\n b/.test(x);\\n})();";
120
+ expect(decodeEscapedLineBreaks(jsRegex, "js")).toBe(jsRegex);
121
+ const cssContent = '.a::after {\\n content: "x\\n y";\\n}';
122
+ expect(decodeEscapedLineBreaks(cssContent, "css")).toBe(cssContent);
123
+ const htmlAttr = '<div>\\n <span title="a\\n b">x</span>\\n</div>';
124
+ expect(decodeEscapedLineBreaks(htmlAttr, "html")).toBe(htmlAttr);
125
+ const htmlPre = '<div>\\n <pre>printf("a\\n");</pre>\\n</div>';
126
+ expect(decodeEscapedLineBreaks(htmlPre, "html")).toBe(htmlPre);
127
+ });
128
+
129
+ it("decodes structural escapes around literals and comments that hold none", () => {
130
+ expect(
131
+ decodeEscapedLineBreaks(
132
+ "// it's the footer\\n(function () {\\n var s = 'a\\\\n';\\n /x/.test(s);\\n})();",
133
+ "js",
134
+ ),
135
+ ).toBe("// it's the footer\n(function () {\n var s = 'a\\\\n';\n /x/.test(s);\n})();");
136
+ expect(
137
+ decodeEscapedLineBreaks('<p>It\'s ours</p>\\n<p>\\n <a href="/">Home</a>\\n</p>', "html"),
138
+ ).toBe('<p>It\'s ours</p>\n<p>\n <a href="/">Home</a>\n</p>');
139
+ });
140
+
141
+ it("is what add_module's html / css / js arguments go through", () => {
142
+ const r = addModuleToolInput.parse({
143
+ target: "layout",
144
+ targetRef: "site-default",
145
+ blockName: "footer",
146
+ position: "bottom",
147
+ displayName: "Site Footer",
148
+ html: sentHtml,
149
+ css: sentCss,
150
+ js: sentJs,
151
+ });
152
+ for (const v of [r.html, r.css, r.js]) {
153
+ expect(v).toBeString();
154
+ expect(v).not.toContain("\\n");
155
+ expect(v).toContain("\n");
156
+ }
157
+ });
158
+ });
@@ -6,7 +6,9 @@
6
6
  *
7
7
  * The model sometimes encodes a long markup string as a whole before it
8
8
  * sends it: as a CDATA section (`unwrapCdataEnvelope`), as a JSON string
9
- * literal (`"<footer class=\"x\">…"`), or HTML-escaped (`&lt;main&gt;…`).
9
+ * literal (`"<footer class=\"x\">…"`), HTML-escaped (`&lt;main&gt;…`), or
10
+ * with its line breaks string-escaped (`{\n color: red;\n}`, see
11
+ * `escaped-line-breaks.ts`).
10
12
  * Each is an encoding of a value the caller did supply, never content:
11
13
  * a JSON string literal is not valid markup, CSS or JS as a whole, and
12
14
  * markup without a single `<` but with `&lt;tag` has no element at all.
@@ -19,6 +21,7 @@
19
21
  * a fallback for missing data (CLAUDE.md §2).
20
22
  */
21
23
 
24
+ import { decodeEscapedLineBreaks, type SourceLanguage } from "./escaped-line-breaks.js";
22
25
  import { unwrapCdataEnvelope } from "./strip-cdata.js";
23
26
 
24
27
  /**
@@ -64,7 +67,15 @@ export function decodeHtmlEscapedSource(value: unknown): unknown {
64
67
  .replace(/&amp;/g, "&");
65
68
  }
66
69
 
67
- /** Every whole-value transport encoding, outermost first. */
68
- export function decodeSourceTextTransport(value: unknown): unknown {
69
- return decodeHtmlEscapedSource(decodeJsonEncodedSource(unwrapCdataEnvelope(value)));
70
+ /**
71
+ * Every whole-value transport encoding, outermost first.
72
+ *
73
+ * @param language - the grammar of the value; escaped line breaks are only
74
+ * decoded where they cannot belong to a literal of it.
75
+ */
76
+ export function decodeSourceTextTransport(value: unknown, language: SourceLanguage): unknown {
77
+ return decodeEscapedLineBreaks(
78
+ decodeHtmlEscapedSource(decodeJsonEncodedSource(unwrapCdataEnvelope(value))),
79
+ language,
80
+ );
70
81
  }
@@ -551,3 +551,58 @@ describe("lists — loud markers", () => {
551
551
  expect(r.missingSlots).toEqual(["list-context-unavailable:bullets"]);
552
552
  });
553
553
  });
554
+
555
+ describe("lists — breadcrumbs and {{site_text.*}} (#1142)", () => {
556
+ const SITE_TEXTS = "00000000-0000-4000-8000-0000000000e1";
557
+ const withTexts = (pageId: string, texts: Record<string, string>): ListPageContext =>
558
+ createListRenderContext(
559
+ fixtureData({
560
+ lists: [
561
+ {
562
+ id: SITE_TEXTS,
563
+ slug: "site-texts",
564
+ name: "Site texts",
565
+ typeSlug: "site-texts",
566
+ items: Object.entries(texts).map(([key, text], i) => ({
567
+ id: uuid(900 + i),
568
+ node: "text",
569
+ values: { key, text },
570
+ })),
571
+ },
572
+ ],
573
+ }),
574
+ ).forPage(pageId, { linkResolver: fixtureLinkIndex().forPage(pageId) });
575
+ const CRUMBS: TemplateField = {
576
+ name: "crumbs",
577
+ kind: "list",
578
+ listType: "links",
579
+ bind: "source",
580
+ source: { kind: "system", name: "breadcrumbs" },
581
+ };
582
+ const NAV =
583
+ '<nav aria-label="{{site_text.breadcrumb_label}}"><ol>{{#crumbs}}<li>{{#is_current}}<span aria-current="page">{{short_title}}</span>{{/is_current}}{{^is_current}}<a href="{{link}}">{{short_title}}</a>{{/is_current}}</li>{{/crumbs}}</ol></nav>';
584
+
585
+ it("renders a trail home → current with the aria-label from the site texts", () => {
586
+ const r = render(NAV, [CRUMBS], {}, withTexts(PAGE.team, { breadcrumb_label: "Breadcrumb" }));
587
+ expect(r.html).toBe(
588
+ '<nav aria-label="Breadcrumb"><ol><li><a href="/">Home</a></li><li><a href="/about/">About</a></li><li><span aria-current="page">Team</span></li></ol></nav>',
589
+ );
590
+ expect(r.missingSlots).toEqual([]);
591
+ });
592
+
593
+ it("a missing site text stays loud", () => {
594
+ const r = render(
595
+ '<nav aria-label="{{site_text.breadcrumb_label}}"></nav>',
596
+ [],
597
+ {},
598
+ withTexts(PAGE.team, {}),
599
+ );
600
+ expect(r.html).toBe('<nav aria-label="{{site_text.breadcrumb_label}}"></nav>');
601
+ expect(r.missingSlots).toContain("site-text-missing:breadcrumb_label");
602
+ });
603
+
604
+ it("is loud without a list context", () => {
605
+ const r = render("{{site_text.breadcrumb_label}}", [], {}, null);
606
+ expect(r.missingSlots).toContain("list-context-unavailable:site_text");
607
+ });
608
+ });
@@ -68,6 +68,17 @@ const TAG_RE = /\{\{\s*([#^/>]\s*)?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)*)\s
68
68
  const SECTION_NAME_RE = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)*$/;
69
69
  const DOT_RE = /\{\{\s*\.\s*\}\}/g;
70
70
 
71
+ /** Dotted heads the list context answers, not an entry: `{{page.*}}`, `{{site_text.*}}`. */
72
+ const CONTEXT_HEADS = new Set(["page", "site_text"]);
73
+
74
+ function contextHead(name: string): string {
75
+ return (name.split(".")[0] ?? "").toLowerCase();
76
+ }
77
+
78
+ function isContextHead(name: string): boolean {
79
+ return CONTEXT_HEADS.has(contextHead(name));
80
+ }
81
+
71
82
  function tokenize(html: string): Token[] {
72
83
  const out: Token[] = [];
73
84
  TAG_RE.lastIndex = 0;
@@ -201,6 +212,9 @@ class SectionRenderer {
201
212
  v = this.env.lists?.pageScope() ?? undefined;
202
213
  if (v === undefined)
203
214
  return parts.length > 1 || st.chain.length > 0 ? { missing: true } : undefined;
215
+ } else if (head === "site_text") {
216
+ v = this.env.lists?.siteTextScope();
217
+ if (v === undefined) return { missing: true };
204
218
  } else {
205
219
  for (let k = st.chain.length - 1; k >= 0 && v === undefined; k -= 1) {
206
220
  v = st.chain[k]?.entry.get(head);
@@ -251,8 +265,8 @@ class SectionRenderer {
251
265
  }
252
266
  if ("missing" in v) {
253
267
  const marker =
254
- t.name.split(".")[0]?.toLowerCase() === "page" && this.env.lists === undefined
255
- ? "list-context-unavailable:page"
268
+ isContextHead(t.name) && this.env.lists === undefined
269
+ ? `list-context-unavailable:${contextHead(t.name)}`
256
270
  : this.notDeclared(t.name, st);
257
271
  return this.raw(source, marker);
258
272
  }
@@ -321,8 +335,8 @@ class SectionRenderer {
321
335
  if (v !== undefined) {
322
336
  if ("missing" in v) {
323
337
  const marker =
324
- t.name.split(".")[0] === "page" && this.env.lists === undefined
325
- ? "list-context-unavailable:page"
338
+ isContextHead(t.name) && this.env.lists === undefined
339
+ ? `list-context-unavailable:${contextHead(t.name)}`
326
340
  : this.notDeclared(t.name, st);
327
341
  return this.raw(source, marker);
328
342
  }
@@ -10,6 +10,7 @@
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import { isUnsafeKey } from "../../safe-keys.js";
12
12
  import { applyDtcgWrites, removeDtcgPath } from "../../themes.js";
13
+ import { UnknownTokenName } from "../../themes-errors.js";
13
14
  import { importTailwind } from "../tailwind.js";
14
15
 
15
16
  describe("isUnsafeKey", () => {
@@ -41,10 +42,19 @@ describe("importTailwind prototype-pollution guard (S5)", () => {
41
42
  });
42
43
 
43
44
  describe("applyDtcgWrites / setLeafAtPath path guard (S5)", () => {
44
- it("rejects a write whose path contains __proto__ (no pollution, no write)", () => {
45
- const out = applyDtcgWrites({}, { "a.__proto__.polluted": "x" }, {});
45
+ it("refuses a write whose path contains __proto__, naming the path (no pollution)", () => {
46
+ expect(() => applyDtcgWrites({}, { "a.__proto__.polluted": "x" }, {})).toThrow(
47
+ /UnknownTokenName: cannot infer canonical path for 'a\.__proto__\.polluted'/,
48
+ );
46
49
  expect(({} as Record<string, unknown>).polluted).toBeUndefined();
47
- expect(out).not.toHaveProperty("a");
50
+ });
51
+
52
+ it("refuses constructor/prototype segments, including as the leaf", () => {
53
+ expect(() => applyDtcgWrites({}, { "color.constructor": "#fff" }, {})).toThrow(
54
+ UnknownTokenName,
55
+ );
56
+ expect(() => applyDtcgWrites({}, { "prototype.x": "#fff" }, {})).toThrow(UnknownTokenName);
57
+ expect(({} as Record<string, unknown>).x).toBeUndefined();
48
58
  });
49
59
 
50
60
  it("still applies a legitimate dotted write", () => {
@@ -38,6 +38,14 @@ describe("renderThemeCss CSS-token sanitization (S6)", () => {
38
38
  expect(css).not.toContain("-->");
39
39
  });
40
40
 
41
+ it("drops every < and > in one pass, so no nesting re-creates a breakout", () => {
42
+ for (const hostile of ["<</style>/style>", "<<!--!---->>", "</sty</style>le>", "a--!>b"]) {
43
+ const css = renderThemeCss(fontDoc(hostile));
44
+ expect(css).not.toMatch(/[<>]/);
45
+ expect(css.toLowerCase()).not.toContain("</style");
46
+ }
47
+ });
48
+
41
49
  it("leaves a normal fontFamily untouched", () => {
42
50
  const css = renderThemeCss(fontDoc("Inter, sans-serif"));
43
51
  expect(css).toContain("Inter, sans-serif");
@@ -519,16 +519,17 @@ function asString(value: unknown): string {
519
519
  * CSS values are otherwise unrestricted in character set (font-family
520
520
  * names legitimately contain quotes, commas, parens, hyphens), so we
521
521
  * don't HTML-encode broadly — that would break perfectly-valid
522
- * `font-family: "Segoe UI", sans-serif` declarations. Instead, scrub
523
- * the two sequences that let an attacker break out of the surrounding
524
- * `<style>…</style>` context:
522
+ * `font-family: "Segoe UI", sans-serif` declarations. Instead, drop the
523
+ * single characters that let a value leave its context:
525
524
  *
526
- * - `</style` (case-insensitive) — premature end-tag would close
527
- * the style block and let everything after it parse as HTML
528
- * (`fontFamily: "Inter</style><script>alert(1)</script>"`).
529
- * - `<!--` / `-->` — CDO/CDC tokens; some HTML parsers treat them
530
- * specially inside `<style>` and they're never valid in a real
531
- * CSS value anyway.
525
+ * - `<` and `>` — every way out of the surrounding `<style>…</style>`
526
+ * starts with one (`</style`, `<!--`, `-->`, `--!>`), and no color,
527
+ * dimension, shadow or font name contains them. Removing single
528
+ * characters cannot re-create a removed sequence, so one pass is
529
+ * complete (a sequence-based scrub needs a fixed-point loop, which
530
+ * CodeQL's js/incomplete-multi-character-sanitization cannot see).
531
+ * - `;`, `{`, `}` — they would end the declaration or the rule and
532
+ * inject CSS of the value's choosing.
532
533
  *
533
534
  * The themeColorToken / themeDimensionToken Zod regexes already
534
535
  * constrain those token types to character sets that exclude `<`, so
@@ -536,21 +537,5 @@ function asString(value: unknown): string {
536
537
  * (`typography.X.fontFamily`, `$description` if it ever lands in CSS).
537
538
  */
538
539
  function sanitizeCssTokenValue(value: string): string {
539
- // Run to a fixed point: removing one sequence can re-create another
540
- // (e.g. `<!--</style>-->` → `</style` only appears after the comment
541
- // tokens go), which a single pass would leave behind (CodeQL
542
- // js/incomplete-multi-character-sanitization). `--!>` is the HTML5
543
- // alternative comment-close and must be scrubbed alongside `-->`
544
- // (CodeQL js/bad-tag-filter).
545
- let out = value;
546
- let prev: string;
547
- do {
548
- prev = out;
549
- out = out
550
- .replace(/<\/style/gi, "")
551
- .replace(/<!--/g, "")
552
- .replace(/--!?>/g, "")
553
- .replace(/[;{}]/g, "");
554
- } while (out !== prev);
555
- return out;
540
+ return value.replace(/[<>;{}]/g, "");
556
541
  }
package/src/themes.ts CHANGED
@@ -880,21 +880,26 @@ function setLeafAtPath(doc: Record<string, unknown>, path: string, leaf: unknown
880
880
  const parts = path.split(".");
881
881
  // The dotted path comes from the caller's `writes` map (form / AI input),
882
882
  // so a segment like `__proto__` or `constructor` would let the walk below
883
- // (`cur[k] = {}`, `cur[last] = leaf`) pollute the prototype chain (CodeQL
884
- // js/prototype-pollution-utility). Reject the whole write on any unsafe
885
- // segment rather than partially applying it.
886
- if (parts.some(isUnsafeKey)) return;
883
+ // (`cur[k] = {}`, `cur[last] = leaf`) pollute the prototype chain. No
884
+ // token is named that, so the write fails loudly instead of being dropped
885
+ // (CLAUDE.md §2). The comparison sits inline at the assignment so CodeQL's
886
+ // js/prototype-pollution-utility sees the guard.
887
887
  let cur: Record<string, unknown> = doc;
888
- for (let i = 0; i < parts.length - 1; i++) {
888
+ for (let i = 0; i < parts.length; i++) {
889
889
  const k = parts[i];
890
890
  if (!k) continue;
891
+ if (k === "__proto__" || k === "constructor" || k === "prototype") {
892
+ throw new UnknownTokenName(path, []);
893
+ }
894
+ if (i === parts.length - 1) {
895
+ cur[k] = leaf;
896
+ return;
897
+ }
891
898
  if (cur[k] === undefined || cur[k] === null || typeof cur[k] !== "object") {
892
899
  cur[k] = {};
893
900
  }
894
901
  cur = cur[k] as Record<string, unknown>;
895
902
  }
896
- const last = parts[parts.length - 1];
897
- if (last) cur[last] = leaf;
898
903
  }
899
904
 
900
905
  /**
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.11.0";
25
+ export const CAELO_VERSION = "0.11.2";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this