@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
-
import { createTemplateToolInput } from "./ai-tools.js";
|
|
4
|
+
import { addModuleToolInput, createTemplateToolInput } from "./ai-tools.js";
|
|
5
|
+
import { decodeEscapedLineBreaks } from "./escaped-line-breaks.js";
|
|
5
6
|
import {
|
|
6
7
|
decodeHtmlEscapedSource,
|
|
7
8
|
decodeJsonEncodedSource,
|
|
@@ -43,13 +44,14 @@ describe("decodeJsonEncodedSource", () => {
|
|
|
43
44
|
|
|
44
45
|
describe("decodeSourceTextTransport", () => {
|
|
45
46
|
it("unwraps CDATA, then a JSON literal, then HTML escaping", () => {
|
|
46
|
-
expect(decodeSourceTextTransport(`<![CDATA[${markup}]]
|
|
47
|
+
expect(decodeSourceTextTransport(`<![CDATA[${markup}]]>`, "html")).toBe(markup);
|
|
47
48
|
expect(
|
|
48
49
|
decodeSourceTextTransport(
|
|
49
50
|
JSON.stringify(markup.replaceAll("<", "<").replaceAll(">", ">")),
|
|
51
|
+
"html",
|
|
50
52
|
),
|
|
51
53
|
).toBe(markup);
|
|
52
|
-
expect(decodeSourceTextTransport(42)).toBe(42);
|
|
54
|
+
expect(decodeSourceTextTransport(42, "html")).toBe(42);
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
it("is what create_template's html argument goes through", () => {
|
|
@@ -61,3 +63,96 @@ describe("decodeSourceTextTransport", () => {
|
|
|
61
63
|
expect(r.html).toBe(markup);
|
|
62
64
|
});
|
|
63
65
|
});
|
|
66
|
+
|
|
67
|
+
// e2e-livedit run 40532297620 (release gate v0.11.1): add_module's html, css
|
|
68
|
+
// and js arrived with every line break as the two characters `\` `n`. The
|
|
69
|
+
// stored footer showed literal `\n` text and the shared module <script>
|
|
70
|
+
// threw `SyntaxError: Invalid or unexpected token` on every preview. These
|
|
71
|
+
// are the exact values the AI sent.
|
|
72
|
+
describe("decodeEscapedLineBreaks", () => {
|
|
73
|
+
const sentJs =
|
|
74
|
+
"(function () {\\n document.querySelectorAll('.site-footer__year').forEach(function (el) {\\n el.textContent = new Date().getFullYear();\\n });\\n})();";
|
|
75
|
+
const sentCss =
|
|
76
|
+
".site-footer {\\n padding: 2rem 1.5rem;\\n text-align: center;\\n}\\n.site-footer__link:hover {\\n text-decoration: underline;\\n}";
|
|
77
|
+
const sentHtml =
|
|
78
|
+
'<footer class="site-footer">\\n <nav class="site-footer__nav" aria-label="Footer">\\n <ul class="site-footer__list">\\n {{#nav}}\\n <li class="site-footer__item"><a class="site-footer__link" href="{{link.href}}">{{label}}</a></li>\\n {{/nav}}\\n </ul>\\n </nav>\\n</footer>';
|
|
79
|
+
|
|
80
|
+
it("turns escaped line breaks back into real ones (the run's exact values)", () => {
|
|
81
|
+
expect(decodeEscapedLineBreaks(sentJs, "js")).toBe(
|
|
82
|
+
"(function () {\n document.querySelectorAll('.site-footer__year').forEach(function (el) {\n el.textContent = new Date().getFullYear();\n });\n})();",
|
|
83
|
+
);
|
|
84
|
+
expect(decodeEscapedLineBreaks(sentCss, "css")).toBe(
|
|
85
|
+
".site-footer {\n padding: 2rem 1.5rem;\n text-align: center;\n}\n.site-footer__link:hover {\n text-decoration: underline;\n}",
|
|
86
|
+
);
|
|
87
|
+
const html = decodeEscapedLineBreaks(sentHtml, "html") as string;
|
|
88
|
+
expect(html).not.toContain("\\n");
|
|
89
|
+
expect(html.split("\n")).toHaveLength(9);
|
|
90
|
+
expect(html).toContain('<footer class="site-footer">\n <nav');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("decodes escaped CRLF and escaped tab indentation", () => {
|
|
94
|
+
expect(decodeEscapedLineBreaks(".a {\\r\\n\\tcolor: red;\\r\\n\\t\\tx: y;\\r\\n}", "css")).toBe(
|
|
95
|
+
".a {\n\tcolor: red;\n\t\tx: y;\n}",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("leaves source with real line breaks, single-line code and escaped backslashes alone", () => {
|
|
100
|
+
const multiLine = "const s = 'a\\n b';\nconsole.log(s.split('\\n '));";
|
|
101
|
+
expect(decodeEscapedLineBreaks(multiLine, "js")).toBe(multiLine);
|
|
102
|
+
const minified = "var t=a.join('\\n');console.log(t.split('\\n'))";
|
|
103
|
+
expect(decodeEscapedLineBreaks(minified, "js")).toBe(minified);
|
|
104
|
+
const oneHit = "el.textContent = lines.join('\\n ');";
|
|
105
|
+
expect(decodeEscapedLineBreaks(oneHit, "js")).toBe(oneHit);
|
|
106
|
+
const escapedBackslash = "a('\\\\n x'); b('\\\\n y');";
|
|
107
|
+
expect(decodeEscapedLineBreaks(escapedBackslash, "js")).toBe(escapedBackslash);
|
|
108
|
+
expect(decodeEscapedLineBreaks(7, "js")).toBe(7);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// PR #1189 review: an escape inside a literal is meaningful, so a value
|
|
112
|
+
// holding one is ambiguous and left exactly as sent — decoding it would
|
|
113
|
+
// turn valid source into broken source.
|
|
114
|
+
it("leaves values alone when an escaped line break sits inside a literal", () => {
|
|
115
|
+
const jsString = 'document.querySelector("pre").textContent = "Title\\n Detail\\n More";';
|
|
116
|
+
expect(decodeEscapedLineBreaks(jsString, "js")).toBe(jsString);
|
|
117
|
+
const jsTemplate = "(function () {\\n el.title = `a\\n b`;\\n})();";
|
|
118
|
+
expect(decodeEscapedLineBreaks(jsTemplate, "js")).toBe(jsTemplate);
|
|
119
|
+
const jsRegex = "(function () {\\n var ok = /a\\n b/.test(x);\\n})();";
|
|
120
|
+
expect(decodeEscapedLineBreaks(jsRegex, "js")).toBe(jsRegex);
|
|
121
|
+
const cssContent = '.a::after {\\n content: "x\\n y";\\n}';
|
|
122
|
+
expect(decodeEscapedLineBreaks(cssContent, "css")).toBe(cssContent);
|
|
123
|
+
const htmlAttr = '<div>\\n <span title="a\\n b">x</span>\\n</div>';
|
|
124
|
+
expect(decodeEscapedLineBreaks(htmlAttr, "html")).toBe(htmlAttr);
|
|
125
|
+
const htmlPre = '<div>\\n <pre>printf("a\\n");</pre>\\n</div>';
|
|
126
|
+
expect(decodeEscapedLineBreaks(htmlPre, "html")).toBe(htmlPre);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("decodes structural escapes around literals and comments that hold none", () => {
|
|
130
|
+
expect(
|
|
131
|
+
decodeEscapedLineBreaks(
|
|
132
|
+
"// it's the footer\\n(function () {\\n var s = 'a\\\\n';\\n /x/.test(s);\\n})();",
|
|
133
|
+
"js",
|
|
134
|
+
),
|
|
135
|
+
).toBe("// it's the footer\n(function () {\n var s = 'a\\\\n';\n /x/.test(s);\n})();");
|
|
136
|
+
expect(
|
|
137
|
+
decodeEscapedLineBreaks('<p>It\'s ours</p>\\n<p>\\n <a href="/">Home</a>\\n</p>', "html"),
|
|
138
|
+
).toBe('<p>It\'s ours</p>\n<p>\n <a href="/">Home</a>\n</p>');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("is what add_module's html / css / js arguments go through", () => {
|
|
142
|
+
const r = addModuleToolInput.parse({
|
|
143
|
+
target: "layout",
|
|
144
|
+
targetRef: "site-default",
|
|
145
|
+
blockName: "footer",
|
|
146
|
+
position: "bottom",
|
|
147
|
+
displayName: "Site Footer",
|
|
148
|
+
html: sentHtml,
|
|
149
|
+
css: sentCss,
|
|
150
|
+
js: sentJs,
|
|
151
|
+
});
|
|
152
|
+
for (const v of [r.html, r.css, r.js]) {
|
|
153
|
+
expect(v).toBeString();
|
|
154
|
+
expect(v).not.toContain("\\n");
|
|
155
|
+
expect(v).toContain("\n");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The model sometimes encodes a long markup string as a whole before it
|
|
8
8
|
* sends it: as a CDATA section (`unwrapCdataEnvelope`), as a JSON string
|
|
9
|
-
* literal (`"<footer class=\"x\">…"`),
|
|
9
|
+
* literal (`"<footer class=\"x\">…"`), HTML-escaped (`<main>…`), or
|
|
10
|
+
* with its line breaks string-escaped (`{\n color: red;\n}`, see
|
|
11
|
+
* `escaped-line-breaks.ts`).
|
|
10
12
|
* Each is an encoding of a value the caller did supply, never content:
|
|
11
13
|
* a JSON string literal is not valid markup, CSS or JS as a whole, and
|
|
12
14
|
* markup without a single `<` but with `<tag` has no element at all.
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
* a fallback for missing data (CLAUDE.md §2).
|
|
20
22
|
*/
|
|
21
23
|
|
|
24
|
+
import { decodeEscapedLineBreaks, type SourceLanguage } from "./escaped-line-breaks.js";
|
|
22
25
|
import { unwrapCdataEnvelope } from "./strip-cdata.js";
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -64,7 +67,15 @@ export function decodeHtmlEscapedSource(value: unknown): unknown {
|
|
|
64
67
|
.replace(/&/g, "&");
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Every whole-value transport encoding, outermost first.
|
|
72
|
+
*
|
|
73
|
+
* @param language - the grammar of the value; escaped line breaks are only
|
|
74
|
+
* decoded where they cannot belong to a literal of it.
|
|
75
|
+
*/
|
|
76
|
+
export function decodeSourceTextTransport(value: unknown, language: SourceLanguage): unknown {
|
|
77
|
+
return decodeEscapedLineBreaks(
|
|
78
|
+
decodeHtmlEscapedSource(decodeJsonEncodedSource(unwrapCdataEnvelope(value))),
|
|
79
|
+
language,
|
|
80
|
+
);
|
|
70
81
|
}
|
|
@@ -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
|
+
});
|
package/src/template-sections.ts
CHANGED
|
@@ -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
|
|
255
|
-
?
|
|
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
|
|
325
|
-
?
|
|
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("
|
|
45
|
-
|
|
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
|
-
|
|
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");
|
package/src/theme-render.ts
CHANGED
|
@@ -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,
|
|
523
|
-
*
|
|
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
|
-
* -
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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
|
-
|
|
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
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
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
|
|
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
|
/**
|