119 lines
4.4 KiB
JavaScript
119 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Every rule in the built stylesheet must sit inside a cascade layer
|
|
// (milestone 23). Unlayered author CSS outranks every layer whatever its
|
|
// selector says, so a single unlayered rule silently beats the StyleX atomic
|
|
// rules it was written to sit under. That failure renders wrong and passes
|
|
// every other gate: no test asserts computed style, and the bundler is happy.
|
|
// It also checks the layer ORDER, which is the invariant that actually matters:
|
|
// a later layer beats an earlier one, so `reset` has to be declared first.
|
|
//
|
|
// What it does not catch, so nobody reads more into a pass than is there: an
|
|
// unlayered rule that sets only custom properties is allowed, because StyleX
|
|
// emits its token `:root` block exactly that way and this cannot tell that
|
|
// block from an override of it; a declaration value containing `@layer` or a
|
|
// brace inside a string blinds the stripper; and with several stylesheets it
|
|
// judges each alone, not their load order in the document.
|
|
//
|
|
// This check runs from admin/ as part of `npm run build`.
|
|
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
|
|
|
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
|
|
if (sheets.length === 0) {
|
|
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
|
|
process.exit(1);
|
|
}
|
|
|
|
// At-rules that describe a resource or a name rather than styling an element.
|
|
// They carry no cascade priority against a layer, so being outside one is
|
|
// correct, and StyleX emits `@property` for its custom properties.
|
|
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
|
|
|
|
/** Strip comments, then every balanced block and statement the rule above allows. */
|
|
function outsideLayers(css) {
|
|
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
for (;;) {
|
|
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
|
|
if (at === -1) break;
|
|
let depth = 0;
|
|
let end = rest.indexOf("{", at);
|
|
for (let i = end; i < rest.length; i += 1) {
|
|
if (rest[i] === "{") depth += 1;
|
|
else if (rest[i] === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
rest = rest.slice(0, at) + rest.slice(end + 1);
|
|
}
|
|
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
|
|
}
|
|
|
|
/**
|
|
* A rule that only sets custom properties styles nothing on its own — StyleX
|
|
* emits its token `:root` block that way, ahead of its layers, and a variable
|
|
* is consumed through `var()` rather than competing with a layered rule.
|
|
*/
|
|
function stylesSomething(body) {
|
|
return body
|
|
.split(";")
|
|
.map((declaration) => declaration.trim())
|
|
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
|
|
}
|
|
|
|
/**
|
|
* Layer names in the order their position is fixed, which is where each name is
|
|
* first mentioned — a later block under an already-named layer does not move it.
|
|
*/
|
|
function layerOrder(css) {
|
|
const seen = [];
|
|
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
|
|
for (const name of names.split(",")) {
|
|
const trimmed = name.trim();
|
|
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
|
|
}
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
let failed = false;
|
|
for (const sheet of sheets) {
|
|
const css = readFileSync(join(distDir, sheet), "utf8");
|
|
|
|
// Order is the whole point: a later layer wins, so the reset has to be first.
|
|
const order = layerOrder(css);
|
|
if (order.length > 0 && order[0] !== "reset") {
|
|
console.error(
|
|
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")} — ` +
|
|
`'reset' must come first or it outranks the StyleX rules written against it.`,
|
|
);
|
|
failed = true;
|
|
}
|
|
|
|
const leftover = outsideLayers(css);
|
|
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
if (!stylesSomething(body)) continue;
|
|
console.error(
|
|
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
|
|
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
|
|
);
|
|
failed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (failed) {
|
|
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
|
|
);
|