fix: enforce component selector boundaries

This commit is contained in:
Peter Li 2026-07-16 16:18:48 +08:00
parent d7ea139264
commit bb58006e24
2 changed files with 135 additions and 18 deletions

View file

@ -2,10 +2,36 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { declarationsFor } from "./helpers/css.mjs";
import {
assertThemeStructure,
declarationsFor,
selectorsFor,
} from "./helpers/css.mjs";
const css = await readFile(new URL("../theme.css", import.meta.url), "utf8");
const body = declarationsFor(css, "body");
const allowedSelectors = new Set([
".theme-light",
".theme-dark",
"body",
".markdown-rendered pre",
]);
const officialCalloutTypeVariables = [
"--callout-bug",
"--callout-default",
"--callout-error",
"--callout-example",
"--callout-fail",
"--callout-important",
"--callout-info",
"--callout-question",
"--callout-success",
"--callout-summary",
"--callout-tip",
"--callout-todo",
"--callout-warning",
"--callout-quote",
];
function assertPrefixedDeclarations(prefixes, expected) {
const actual = Object.fromEntries(
@ -17,6 +43,42 @@ function assertPrefixedDeclarations(prefixes, expected) {
assert.deepEqual(actual, expected);
}
test("uses only the allowed low-specificity selectors", () => {
assert.deepEqual(selectorsFor(css), allowedSelectors);
});
test("rejects unexpected selectors in memory", () => {
for (const selector of [".mobile-only", ".markdown-source-view"]) {
assert.throws(
() => assertThemeStructure(`${css}\n${selector} { color: red; }`),
/unexpected theme selector/,
);
}
});
test("rejects callout variables outside body in memory", () => {
assert.throws(
() =>
assertThemeStructure(
`${css}\n.callout[data-callout="warning"] { --callout-color: red; }`,
),
/--callout-color.*body/,
);
});
test("rejects official callout type variables globally in memory", () => {
for (const property of officialCalloutTypeVariables) {
assert.throws(
() => assertThemeStructure(`${css}\nbody { ${property}: red; }`),
new RegExp(`${property}.*semantic type`),
);
}
});
test("the compiled theme respects component structure boundaries", () => {
assert.doesNotThrow(() => assertThemeStructure(css));
});
test("styles callouts without replacing semantic type colors", () => {
assertPrefixedDeclarations(["--callout-"], {
"--callout-padding": "var(--size-4-3) var(--size-4-4)",
@ -31,23 +93,8 @@ test("styles callouts without replacing semantic type colors", () => {
"--callout-content-background": "transparent",
});
for (const type of [
"note",
"info",
"todo",
"tip",
"important",
"success",
"question",
"warning",
"failure",
"danger",
"error",
"bug",
"example",
"quote",
]) {
assert.equal(body.has(`--callout-${type}`), false, `--callout-${type}`);
for (const property of officialCalloutTypeVariables) {
assert.equal(body.has(property), false, property);
}
});

View file

@ -1,5 +1,28 @@
import postcss from "postcss";
const allowedThemeSelectors = new Set([
".theme-light",
".theme-dark",
"body",
".markdown-rendered pre",
]);
const officialCalloutTypeVariables = new Set([
"--callout-bug",
"--callout-default",
"--callout-error",
"--callout-example",
"--callout-fail",
"--callout-important",
"--callout-info",
"--callout-question",
"--callout-success",
"--callout-summary",
"--callout-tip",
"--callout-todo",
"--callout-warning",
"--callout-quote",
]);
export function declarationsFor(css, selector) {
const declarations = new Map();
const root = postcss.parse(css);
@ -14,3 +37,50 @@ export function declarationsFor(css, selector) {
return declarations;
}
export function selectorsFor(css) {
const selectors = new Set();
postcss.parse(css).walkRules((rule) => selectors.add(rule.selector));
return selectors;
}
export function assertThemeStructure(css) {
const root = postcss.parse(css);
root.walkDecls((declaration) => {
if (officialCalloutTypeVariables.has(declaration.prop)) {
throw new Error(
`${declaration.prop} is a forbidden semantic type variable`,
);
}
if (!declaration.prop.startsWith("--callout-")) return;
const selector =
declaration.parent?.type === "rule"
? declaration.parent.selector
: "a non-rule context";
if (selector !== "body") {
throw new Error(
`${declaration.prop} must be declared only on body; found ${selector}`,
);
}
});
const selectors = new Set();
root.walkRules((rule) => selectors.add(rule.selector));
const unexpected = [...selectors].filter(
(selector) => !allowedThemeSelectors.has(selector),
);
const missing = [...allowedThemeSelectors].filter(
(selector) => !selectors.has(selector),
);
if (unexpected.length > 0 || missing.length > 0) {
throw new Error(
`unexpected theme selector set: unexpected [${unexpected.join(", ")}], missing [${missing.join(", ")}]`,
);
}
}