Files
homarr/packages/custom-widgets/src/core/scoped-styles.ts
T

103 lines
4.1 KiB
TypeScript

import { generate, ident, parse, walk } from "css-tree";
const allowedAtRules = new Set(["media", "supports", "container", "keyframes", "-webkit-keyframes"]);
const animationKeywords = new Set([
"none",
"infinite",
"normal",
"reverse",
"alternate",
"alternate-reverse",
"forwards",
"backwards",
"both",
"running",
"paused",
"ease",
"ease-in",
"ease-out",
"ease-in-out",
"linear",
"step-start",
"step-end",
]);
/** Parse and namespace authored CSS; no resource loading or document-wide rules. */
export function compileCustomWidgetStyles(stylesheet: string, scopeId: string): string {
if (!/^[a-zA-Z0-9_-]+$/u.test(scopeId)) throw new Error("Invalid stylesheet scope");
if (stylesheet.length > 30_000) throw new Error("Stylesheets are limited to 30,000 characters");
const ast = parse(stylesheet, {
positions: true,
parseCustomProperty: true,
onParseError: (error) => {
throw error;
},
});
const keyframes = new Map<string, string>();
let count = 0;
walk(ast, function (node) {
if (++count > 10_000) throw new Error("Stylesheet exceeds the complexity limit");
if (node.type === "Raw" || node.type === "Url") throw new Error("CSS resources and unparsed CSS are not supported");
if (node.type === "Atrule") {
if (!allowedAtRules.has(normalize(node.name))) throw new Error(`Unsupported CSS rule @${node.name}`);
if (normalize(node.name).endsWith("keyframes")) {
const name = node.prelude && generate(node.prelude);
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_-]*$/u.test(name)) throw new Error("Use an identifier for keyframes");
keyframes.set(name, `cw-${scopeId}-${name}`);
}
}
if (
node.type === "Function" &&
["url", "expression", "image-set", "-webkit-image-set", "attr"].includes(normalize(node.name))
) {
throw new Error(`Unsupported CSS function ${node.name}`);
}
if (node.type === "Declaration" && ["behavior", "-moz-binding"].includes(normalize(node.property))) {
throw new Error(`Unsupported CSS property ${node.property}`);
}
});
walk(ast, function (node) {
if (node.type === "Atrule" && normalize(node.name).endsWith("keyframes") && node.prelude) {
const prelude = parse(keyframes.get(generate(node.prelude)) ?? "", { context: "atrulePrelude" });
if (prelude.type === "AtrulePrelude") node.prelude = prelude;
}
if (node.type === "ClassSelector") node.name = `cw-${scopeId}-${node.name}`;
if (node.type === "Declaration" && /^(?:-webkit-)?animation(?:-name)?$/u.test(normalize(node.property))) {
walk(node.value, (value) => {
if (value.type === "Function" && !["cubic-bezier", "steps", "linear"].includes(normalize(value.name))) {
throw new Error("Animation values must reference declared keyframes directly");
}
if (value.type !== "Identifier") return;
const localName = keyframes.get(value.name);
if (localName) value.name = localName;
else if (!animationKeywords.has(value.name)) throw new Error(`Unknown widget keyframes '${value.name}'`);
});
}
});
walk(ast, {
visit: "Rule",
enter(node) {
if (this.atrule && normalize(this.atrule.name).endsWith("keyframes")) return;
if (this.rule && this.rule !== node) throw new Error("Use flat CSS selectors instead of nested rules");
if (node.prelude.type !== "SelectorList") throw new Error("Invalid widget selector");
node.prelude.children.forEach((selector) => {
if (selector.type !== "Selector") return;
const prefix = parse(`[data-cw-scope="${scopeId}"] ${generate(selector)}`, { context: "selector" });
if (prefix.type === "Selector") selector.children = prefix.children;
});
},
});
return generate(ast);
}
const normalize = (name: string) => ident.decode(name).toLowerCase();
export function scopeCustomWidgetClassNames(value: unknown, scopeId?: string) {
if (!scopeId || typeof value !== "string") return undefined;
return value
.split(/\s+/u)
.filter((name) => /^[a-zA-Z_][a-zA-Z0-9_-]*$/u.test(name))
.map((name) => `cw-${scopeId}-${name}`)
.join(" ");
}