@caelo-cms/shared 0.10.53 → 0.10.55
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/auto-update.d.ts +5 -2
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +13 -6
- package/dist/auto-update.js.map +1 -1
- package/dist/fonts.d.ts +12 -0
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +37 -1
- package/dist/fonts.js.map +1 -1
- 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-refs.d.ts +250 -0
- package/dist/link-refs.d.ts.map +1 -0
- package/dist/link-refs.js +416 -0
- package/dist/link-refs.js.map +1 -0
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +4 -1
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +14 -2
- package/dist/nested-module-render.js.map +1 -1
- package/dist/platform-updates.d.ts +7 -3
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +13 -2
- package/dist/platform-updates.js.map +1 -1
- package/dist/preview-compose.d.ts +10 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +58 -20
- package/dist/preview-compose.js.map +1 -1
- package/dist/raw-text-escape.d.ts +18 -0
- package/dist/raw-text-escape.d.ts.map +1 -0
- package/dist/raw-text-escape.js +40 -0
- package/dist/raw-text-escape.js.map +1 -0
- package/dist/responsive-images.d.ts.map +1 -1
- package/dist/responsive-images.js +3 -3
- package/dist/responsive-images.js.map +1 -1
- package/dist/seo.js +1 -1
- package/dist/seo.js.map +1 -1
- package/dist/template-balance.d.ts +3 -0
- package/dist/template-balance.d.ts.map +1 -0
- package/dist/template-balance.js +214 -0
- package/dist/template-balance.js.map +1 -0
- package/dist/template-engine.d.ts +11 -0
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +12 -0
- package/dist/template-engine.js.map +1 -1
- package/dist/theme-render.d.ts +2 -2
- package/dist/theme-render.d.ts.map +1 -1
- package/dist/theme-render.js +29 -5
- package/dist/theme-render.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/auto-update.test.ts +32 -1
- package/src/auto-update.ts +13 -6
- package/src/fonts.test.ts +37 -0
- package/src/fonts.ts +38 -1
- package/src/index.ts +2 -0
- package/src/link-refs.test.ts +552 -0
- package/src/link-refs.ts +569 -0
- package/src/nested-module-render.ts +18 -1
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +13 -2
- package/src/preview-compose-deferrals.test.ts +103 -0
- package/src/preview-compose.ts +73 -24
- package/src/raw-text-escape.ts +44 -0
- package/src/responsive-images.test.ts +10 -0
- package/src/responsive-images.ts +6 -3
- package/src/seo.test.ts +7 -0
- package/src/seo.ts +1 -1
- package/src/template-balance.test.ts +97 -0
- package/src/template-balance.ts +216 -0
- package/src/template-engine.ts +23 -0
- package/src/theme-render-sanitize.test.ts +40 -0
- package/src/theme-render.ts +40 -8
- package/src/version.ts +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep an HTML fragment from closing a `<template>` it is placed inside.
|
|
5
|
+
*
|
|
6
|
+
* The composer parks a consent-withheld module inside an inert
|
|
7
|
+
* `<template data-caelo-deferred-content>` (#450). In the HTML parser the
|
|
8
|
+
* only thing that ends a template from within is a `</template` end tag
|
|
9
|
+
* (an ASCII-case-insensitive literal; tag names never decode character
|
|
10
|
+
* references), so a fragment with an unmatched one would put the rest of
|
|
11
|
+
* the module live on the page before the visitor consents. Balanced nested
|
|
12
|
+
* templates are legitimate markup and must survive, which rules out
|
|
13
|
+
* escaping every `</template`.
|
|
14
|
+
*
|
|
15
|
+
* The walk is FAIL-CLOSED: it does not have to agree with the browser's
|
|
16
|
+
* tokenizer and tree builder everywhere, only in one direction. Every
|
|
17
|
+
* `</template` it does not keep as the close of a template it counted is
|
|
18
|
+
* rewritten — inside tags, attribute values, comments and raw text too,
|
|
19
|
+
* because those are exactly the places where a hand-written walk and the
|
|
20
|
+
* HTML parser can disagree (abrupt comments like `<!-->`, raw-text
|
|
21
|
+
* elements that are ordinary elements in SVG/MathML, ...). A template is
|
|
22
|
+
* only counted where it is certainly an HTML `<template>`: never inside
|
|
23
|
+
* `<svg>` / `<math>`, where the tree builder can pop a foreign `template`
|
|
24
|
+
* element implicitly (e.g. by `</svg>`) and a following `</template>`
|
|
25
|
+
* would then close the wrapper. When the walk and the browser disagree,
|
|
26
|
+
* the worst outcome is a template that stays open and keeps the rest of
|
|
27
|
+
* the page inert — never withheld content running before consent.
|
|
28
|
+
*
|
|
29
|
+
* Rewrites: `</template` in markup, attribute values and comments (the
|
|
30
|
+
* same characters once decoded or shown); `<\/template` in `<script>` /
|
|
31
|
+
* `<style>` text (the same string in JS and CSS). Templates the fragment
|
|
32
|
+
* leaves open are closed at its end so the wrapper's own `</template>`
|
|
33
|
+
* closes the wrapper.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Elements whose content the HTML tokenizer reads as text until their end tag. */
|
|
37
|
+
const RAW_TEXT_ELEMENTS = new Set([
|
|
38
|
+
"script",
|
|
39
|
+
"style",
|
|
40
|
+
"textarea",
|
|
41
|
+
"title",
|
|
42
|
+
"xmp",
|
|
43
|
+
"iframe",
|
|
44
|
+
"noembed",
|
|
45
|
+
"noframes",
|
|
46
|
+
"noscript",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Raw-text elements whose content is JS or CSS, where `<\/` keeps the meaning. */
|
|
50
|
+
const CODE_ELEMENTS = new Set(["script", "style"]);
|
|
51
|
+
|
|
52
|
+
/** Roots of foreign (SVG / MathML) content. */
|
|
53
|
+
const FOREIGN_ROOTS = new Set(["svg", "math"]);
|
|
54
|
+
|
|
55
|
+
/** The HTML tokenizer's whitespace (not JS `\s`, which also matches U+00A0 etc.). */
|
|
56
|
+
const HTML_WS = /[\t\n\f\r ]/;
|
|
57
|
+
|
|
58
|
+
/** A tag name ends at HTML whitespace, `/` or `>`. */
|
|
59
|
+
const TAG_NAME_END = /[\t\n\f\r />]/;
|
|
60
|
+
|
|
61
|
+
const TEMPLATE_CLOSE_RE = /<\/template/gi;
|
|
62
|
+
|
|
63
|
+
/** Neutralize every `</template` in markup / attribute / comment text. */
|
|
64
|
+
function neutralizeMarkup(s: string): string {
|
|
65
|
+
return s.replace(TEMPLATE_CLOSE_RE, (m) => `<${m.slice(1)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Neutralize every `</template` in JS / CSS text. */
|
|
69
|
+
function neutralizeCode(s: string): string {
|
|
70
|
+
return s.replace(TEMPLATE_CLOSE_RE, (m) => `<\\${m.slice(1)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rewrite `<!--` in script text to `<\!--`. A `<!--` puts the HTML
|
|
75
|
+
* tokenizer's script data into its escaped states, where a nested
|
|
76
|
+
* `<script` makes the next `</script>` NOT end the element — the browser's
|
|
77
|
+
* script would then run past the end this walk found. Without `<!--` both
|
|
78
|
+
* agree. `\!` is `!` in JS strings and non-unicode regexes.
|
|
79
|
+
*/
|
|
80
|
+
function neutralizeScriptEscapes(s: string): string {
|
|
81
|
+
return s.replaceAll("<!--", () => "<\\!--");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** ASCII-only lowercase: same length as the input, like the HTML tokenizer. */
|
|
85
|
+
function asciiLower(s: string): string {
|
|
86
|
+
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Neutralize unmatched `</template>` closes and close templates left open. */
|
|
90
|
+
export function balanceTemplateCloses(html: string): string {
|
|
91
|
+
let out = "";
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let foreignDepth = 0;
|
|
94
|
+
let i = 0;
|
|
95
|
+
while (i < html.length) {
|
|
96
|
+
const lt = html.indexOf("<", i);
|
|
97
|
+
if (lt === -1) {
|
|
98
|
+
out += html.slice(i);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
out += html.slice(i, lt);
|
|
102
|
+
if (html.startsWith("<!--", lt)) {
|
|
103
|
+
const end = commentEnd(html, lt + 4);
|
|
104
|
+
out += neutralizeMarkup(html.slice(lt, end));
|
|
105
|
+
i = end;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (html[lt + 1] === "!" || html[lt + 1] === "?") {
|
|
109
|
+
// Doctype / CDATA / bogus comment: text up to the next `>`.
|
|
110
|
+
const end = html.indexOf(">", lt);
|
|
111
|
+
const stop = end === -1 ? html.length : end + 1;
|
|
112
|
+
out += neutralizeMarkup(html.slice(lt, stop));
|
|
113
|
+
i = stop;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const isEnd = html[lt + 1] === "/";
|
|
117
|
+
const nameStart = lt + (isEnd ? 2 : 1);
|
|
118
|
+
if (!/[A-Za-z]/.test(html[nameStart] ?? "")) {
|
|
119
|
+
// Not a tag (`a < b`, `</ >`): the `<` is text.
|
|
120
|
+
out += "<";
|
|
121
|
+
i = lt + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let nameEnd = nameStart;
|
|
125
|
+
while (nameEnd < html.length && !TAG_NAME_END.test(html[nameEnd] ?? "")) nameEnd++;
|
|
126
|
+
const name = asciiLower(html.slice(nameStart, nameEnd));
|
|
127
|
+
const tagEnd = tagClose(html, nameEnd);
|
|
128
|
+
const tag = html.slice(lt, tagEnd);
|
|
129
|
+
const selfClosing = tag.endsWith("/>");
|
|
130
|
+
i = tagEnd;
|
|
131
|
+
if (isEnd) {
|
|
132
|
+
if (name === "template" && depth > 0 && foreignDepth === 0) {
|
|
133
|
+
depth--;
|
|
134
|
+
out += `</template${neutralizeMarkup(tag.slice("</template".length))}`;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (FOREIGN_ROOTS.has(name) && foreignDepth > 0) foreignDepth--;
|
|
138
|
+
out += neutralizeMarkup(tag);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
out += neutralizeMarkup(tag);
|
|
142
|
+
if (foreignDepth > 0) {
|
|
143
|
+
// Foreign content: no raw-text elements, no HTML templates counted.
|
|
144
|
+
if (FOREIGN_ROOTS.has(name) && !selfClosing) foreignDepth++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (name === "template") {
|
|
148
|
+
depth++;
|
|
149
|
+
} else if (FOREIGN_ROOTS.has(name)) {
|
|
150
|
+
if (!selfClosing) foreignDepth++;
|
|
151
|
+
} else if (RAW_TEXT_ELEMENTS.has(name)) {
|
|
152
|
+
const close = rawTextEnd(html, tagEnd, name);
|
|
153
|
+
const body = html.slice(tagEnd, close);
|
|
154
|
+
out +=
|
|
155
|
+
name === "script"
|
|
156
|
+
? neutralizeScriptEscapes(neutralizeCode(body))
|
|
157
|
+
: CODE_ELEMENTS.has(name)
|
|
158
|
+
? neutralizeCode(body)
|
|
159
|
+
: neutralizeMarkup(body);
|
|
160
|
+
i = close;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out + "</template>".repeat(depth);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Index just past the comment whose `<!--` ends at `from`. Per the HTML
|
|
168
|
+
* tokenizer `<!-->` and `<!--->` are complete (empty) comments; otherwise
|
|
169
|
+
* the comment ends at `-->` or `--!>`, or at the end of input.
|
|
170
|
+
*/
|
|
171
|
+
function commentEnd(html: string, from: number): number {
|
|
172
|
+
if (html[from] === ">") return from + 1;
|
|
173
|
+
if (html.startsWith("->", from)) return from + 2;
|
|
174
|
+
const m = /--!?>/g;
|
|
175
|
+
m.lastIndex = from;
|
|
176
|
+
const hit = m.exec(html);
|
|
177
|
+
return hit === null ? html.length : hit.index + hit[0].length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Index just past the `>` that ends the tag whose name ends at `from`.
|
|
182
|
+
* Quoted attribute values may hold `>`; they are only quotes after `=`.
|
|
183
|
+
*/
|
|
184
|
+
function tagClose(html: string, from: number): number {
|
|
185
|
+
let i = from;
|
|
186
|
+
while (i < html.length) {
|
|
187
|
+
const ch = html[i];
|
|
188
|
+
if (ch === ">") return i + 1;
|
|
189
|
+
if (ch === "=") {
|
|
190
|
+
i++;
|
|
191
|
+
while (i < html.length && HTML_WS.test(html[i] ?? "")) i++;
|
|
192
|
+
const quote = html[i];
|
|
193
|
+
if (quote === '"' || quote === "'") {
|
|
194
|
+
const close = html.indexOf(quote, i + 1);
|
|
195
|
+
if (close === -1) return html.length;
|
|
196
|
+
i = close + 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
i++;
|
|
202
|
+
}
|
|
203
|
+
return html.length;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Index where the raw-text element `name`'s end tag starts, or the end. */
|
|
207
|
+
function rawTextEnd(html: string, from: number, name: string): number {
|
|
208
|
+
const lower = asciiLower(html);
|
|
209
|
+
let at = lower.indexOf(`</${name}`, from);
|
|
210
|
+
while (at !== -1) {
|
|
211
|
+
const next = html[at + 2 + name.length];
|
|
212
|
+
if (next === undefined || TAG_NAME_END.test(next)) return at;
|
|
213
|
+
at = lower.indexOf(`</${name}`, at + 1);
|
|
214
|
+
}
|
|
215
|
+
return html.length;
|
|
216
|
+
}
|
package/src/template-engine.ts
CHANGED
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
* v0.11.1 (issue #76): `theme-asset-unbound:<slot>` for the four
|
|
42
42
|
* `{{theme_<slot>_url}}` placeholders when the active theme's asset
|
|
43
43
|
* slot isn't bound.
|
|
44
|
+
* Epic #724: `link-target-missing:page/<id>` / `…:media/<id>` for an
|
|
45
|
+
* internal reference (`caelo:page/<id>`, `caelo:media/<id>`) whose
|
|
46
|
+
* target does not exist, `link-resolver-unavailable:<kind>/<id>` for
|
|
47
|
+
* one rendered without a resolver, and `link-ref-malformed:<text>` for
|
|
48
|
+
* text that starts like a reference but breaks the grammar (see
|
|
49
|
+
* `link-refs.ts`).
|
|
44
50
|
* The chat-runner diag pass + editor missing-content surface read
|
|
45
51
|
* these literal strings; any rename is a silent regression for them.
|
|
46
52
|
*
|
|
@@ -63,6 +69,7 @@
|
|
|
63
69
|
|
|
64
70
|
import Mustache from "mustache";
|
|
65
71
|
import { MODULE_FIELD_SECTION_KINDS, type ModuleFieldKind } from "./content.js";
|
|
72
|
+
import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
|
|
66
73
|
import { stripCdataGuards } from "./strip-cdata.js";
|
|
67
74
|
|
|
68
75
|
// Override Mustache's default HTML escape — module HTML substitutes
|
|
@@ -139,6 +146,16 @@ export interface RenderTemplateInput {
|
|
|
139
146
|
readonly favicon: string | null;
|
|
140
147
|
readonly socialShare: string | null;
|
|
141
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* Epic #724 — turns internal references (`caelo:page/<id>`,
|
|
151
|
+
* `caelo:media/<id>`) into URLs for the page being rendered. Applied
|
|
152
|
+
* to the fully substituted output, so a reference resolves wherever it
|
|
153
|
+
* came from: module HTML attributes, a field value, a field default, a
|
|
154
|
+
* link-list item, rich text. A missing target stays raw and pushes
|
|
155
|
+
* `link-target-missing:<kind>/<id>`; with no resolver every reference
|
|
156
|
+
* pushes `link-resolver-unavailable:<kind>/<id>` (loud, never empty).
|
|
157
|
+
*/
|
|
158
|
+
readonly linkResolver?: LinkResolver;
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
export interface RenderTemplateOutput {
|
|
@@ -373,6 +390,12 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
|
|
|
373
390
|
final = final.split(sentinel).join(original);
|
|
374
391
|
}
|
|
375
392
|
|
|
393
|
+
// 6. Resolve internal references (epic #724). Last, so values,
|
|
394
|
+
// defaults and list items substituted above are covered; nested
|
|
395
|
+
// partials arrive already resolved by their own render, and a
|
|
396
|
+
// resolved URL never matches the reference grammar again.
|
|
397
|
+
final = resolveLinkRefsInHtml(final, input.linkResolver, missing);
|
|
398
|
+
|
|
376
399
|
return { html: final, missingSlots: missing };
|
|
377
400
|
}
|
|
378
401
|
|
|
@@ -42,4 +42,44 @@ describe("renderThemeCss CSS-token sanitization (S6)", () => {
|
|
|
42
42
|
const css = renderThemeCss(fontDoc("Inter, sans-serif"));
|
|
43
43
|
expect(css).toContain("Inter, sans-serif");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("scrubs </style> from string shadow tokens", () => {
|
|
47
|
+
const doc = {
|
|
48
|
+
shadow: {
|
|
49
|
+
card: { $value: "0 2px 4px #000</style><script>alert(1)</script>", $type: "shadow" },
|
|
50
|
+
},
|
|
51
|
+
} as unknown as ThemeDocument;
|
|
52
|
+
const css = renderThemeCss(doc);
|
|
53
|
+
expect(css.toLowerCase()).not.toContain("</style");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects selectors containing CSS block delimiters or </style>", () => {
|
|
57
|
+
expect(() => renderThemeCss(fontDoc("Inter"), { selector: ":root{color:red}body" })).toThrow(
|
|
58
|
+
/Invalid CSS selector/,
|
|
59
|
+
);
|
|
60
|
+
expect(() => renderThemeCss(fontDoc("Inter"), { selector: "</style><script>" })).toThrow(
|
|
61
|
+
/Invalid CSS selector/,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("escapes token keys outside CSS identifier characters instead of letting them end the rule", () => {
|
|
66
|
+
const doc = {
|
|
67
|
+
color: {
|
|
68
|
+
"primary;--x:1}</style><script>": { $type: "color", $value: "#000" },
|
|
69
|
+
valid: { $type: "color", $value: "#fff" },
|
|
70
|
+
},
|
|
71
|
+
} as unknown as ThemeDocument;
|
|
72
|
+
const css = renderThemeCss(doc);
|
|
73
|
+
expect(css.toLowerCase()).not.toContain("</style");
|
|
74
|
+
expect(css).not.toContain("}</");
|
|
75
|
+
expect(css).toContain("--color-primary\\3b --x\\3a 1\\7d \\3c \\2f style\\3e ");
|
|
76
|
+
expect(css).toContain("--color-valid:#fff;");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps non-ASCII token names as they are (valid CSS identifiers)", () => {
|
|
80
|
+
const doc = {
|
|
81
|
+
color: { grün: { $type: "color", $value: "#0a0" } },
|
|
82
|
+
} as unknown as ThemeDocument;
|
|
83
|
+
expect(renderThemeCss(doc)).toContain("--color-grün:#0a0;");
|
|
84
|
+
});
|
|
45
85
|
});
|
package/src/theme-render.ts
CHANGED
|
@@ -34,9 +34,13 @@ import { type AnyThemeToken, flattenTokens, type ThemeDocument } from "./themes.
|
|
|
34
34
|
|
|
35
35
|
/** Renderer-level error for cyclic / unresolved aliases. */
|
|
36
36
|
export class ThemeRenderError extends Error {
|
|
37
|
-
readonly kind: "cyclic-alias" | "missing-alias";
|
|
37
|
+
readonly kind: "cyclic-alias" | "missing-alias" | "invalid-selector";
|
|
38
38
|
readonly path: string;
|
|
39
|
-
constructor(
|
|
39
|
+
constructor(
|
|
40
|
+
kind: "cyclic-alias" | "missing-alias" | "invalid-selector",
|
|
41
|
+
path: string,
|
|
42
|
+
message: string,
|
|
43
|
+
) {
|
|
40
44
|
super(message);
|
|
41
45
|
this.name = "ThemeRenderError";
|
|
42
46
|
this.kind = kind;
|
|
@@ -74,6 +78,13 @@ export interface RenderThemeCssOptions {
|
|
|
74
78
|
*/
|
|
75
79
|
export function renderThemeCss(tokens: ThemeDocument, options: RenderThemeCssOptions = {}): string {
|
|
76
80
|
const selector = options.selector ?? ":root";
|
|
81
|
+
if (/[{};]|<\/style|<!--|--!?>/i.test(selector)) {
|
|
82
|
+
throw new ThemeRenderError(
|
|
83
|
+
"invalid-selector",
|
|
84
|
+
selector,
|
|
85
|
+
`Invalid CSS selector for theme rendering: '${selector}'`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
77
88
|
const flat = flattenTokens(tokens);
|
|
78
89
|
const resolveCache = new Map<string, unknown>();
|
|
79
90
|
const lightLines: string[] = [];
|
|
@@ -337,6 +348,25 @@ function lookupPath(doc: ThemeDocument, path: string): AnyThemeToken | null {
|
|
|
337
348
|
// Per-category CSS emission
|
|
338
349
|
// ────────────────────────────────────────────────────────────────────
|
|
339
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Serialize a token-path segment as part of a CSS custom-property name, the
|
|
353
|
+
* way `CSS.escape` does: ASCII letters, digits, `-`, `_` and every
|
|
354
|
+
* non-ASCII character stay as they are (so `color.grün` is still
|
|
355
|
+
* `--color-grün`), anything else becomes a hex escape. A theme key can
|
|
356
|
+
* therefore never end the declaration, the rule, or the surrounding
|
|
357
|
+
* `<style>` element.
|
|
358
|
+
*/
|
|
359
|
+
function escapeCssIdentPart(part: string): string {
|
|
360
|
+
let out = "";
|
|
361
|
+
for (const ch of part) {
|
|
362
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
363
|
+
if (cp === 0) out += "\\fffd ";
|
|
364
|
+
else if (/[A-Za-z0-9_-]/.test(ch) || cp >= 0x80) out += ch;
|
|
365
|
+
else out += `\\${cp.toString(16)} `;
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
340
370
|
function emitTokenLines(
|
|
341
371
|
category: string,
|
|
342
372
|
path: string,
|
|
@@ -358,10 +388,12 @@ function emitTokenLines(
|
|
|
358
388
|
// every category (color/spacing/radius/etc.) so any future
|
|
359
389
|
// DTCG-group-with-DEFAULT-alias works the same way.
|
|
360
390
|
const restRaw = path.slice(category.length + 1);
|
|
361
|
-
const rest =
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
391
|
+
const rest = escapeCssIdentPart(
|
|
392
|
+
restRaw.endsWith(".DEFAULT")
|
|
393
|
+
? restRaw.slice(0, -".DEFAULT".length).replace(/\./g, "-")
|
|
394
|
+
: restRaw.replace(/\./g, "-"),
|
|
395
|
+
);
|
|
396
|
+
const baseName = `--${category === "ease" ? "ease" : escapeCssIdentPart(category)}-${rest}`;
|
|
365
397
|
|
|
366
398
|
switch (category) {
|
|
367
399
|
case "color":
|
|
@@ -430,9 +462,9 @@ function emitTypography(rest: string, value: unknown, out: string[]): void {
|
|
|
430
462
|
|
|
431
463
|
function emitShadow(baseName: string, value: unknown, out: string[]): void {
|
|
432
464
|
// Literal CSS box-shadow string (the tolerant `shadowValueString` form).
|
|
433
|
-
//
|
|
465
|
+
// Scrubbed through sanitizeCssTokenValue like every other token value.
|
|
434
466
|
if (typeof value === "string") {
|
|
435
|
-
const s = value.trim();
|
|
467
|
+
const s = sanitizeCssTokenValue(value.trim());
|
|
436
468
|
if (s.length > 0) out.push(`${baseName}:${s};`);
|
|
437
469
|
return;
|
|
438
470
|
}
|