Files
wehub-resource-sync bf122cd71b
AI Engine CI / engine (push) Failing after 0s
Check generated models / generated-models (push) Failing after 1s
Enterprise E2E (Playwright) / pick (push) Failing after 8s
Enterprise E2E (Playwright) / playwright-e2e-enterprise (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:59:14 +08:00

130 lines
4.8 KiB
JavaScript

// Conservative unused-CSS detector (no deps).
// Reports CSS class selectors + custom properties that have ZERO references
// anywhere in the frontend source (className strings, `styles.x` module access,
// template-literal tokens, `var(--x)`, inline `"--x":` custom props, `composes:`).
// Conservative by design: if a token appears ANYWHERE outside its own selector
// definition, it's treated as USED (so dynamically-built classes are kept).
//
// Usage: cd frontend && node scripts/find-unused-css.mjs [filterSubstring]
import { readFileSync, readdirSync } from "node:fs";
import { join, extname, isAbsolute } from "node:path";
const ROOTS = ["editor/src", "shared", "portal/src"];
const SRC_EXT = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".html",
".mdx",
]);
const filter = process.argv[2] ?? "";
function walk(dir, acc = []) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const e of entries) {
if (
e.name === "node_modules" ||
e.name === "dist" ||
e.name.startsWith(".")
)
continue;
// Dirents from readdirSync are always single path components, but guard
// anyway so a name can never escape `dir` (satisfies Aikido's path-traversal
// check on the readFile below).
if (e.name.includes("..") || isAbsolute(e.name)) continue;
const p = join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
// Exclude the temporary button-catalog gallery so its reproduced classes
// don't mask genuinely-dead CSS.
else if (!p.includes("_buttonGallery")) acc.push(p);
}
return acc;
}
const allFiles = ROOTS.flatMap((r) => walk(r));
const cssFiles = allFiles.filter((f) => extname(f) === ".css");
const srcFiles = allFiles.filter((f) => SRC_EXT.has(extname(f)));
const srcCorpus = srcFiles.map((f) => readFileSync(f, "utf8")).join("\n");
const cssCorpus = cssFiles.map((f) => readFileSync(f, "utf8")).join("\n");
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// --- collect definitions ---------------------------------------------------
const classDef = new Map(); // class -> Set(file)
const varDef = new Map(); // --var -> Set(file)
for (const f of cssFiles) {
const clean = readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
// class names only from SELECTOR preludes (text before each `{`), so we don't
// pick up `.png`/`.svg` etc. inside property values.
for (const rule of clean.matchAll(/([^{}]*)\{/g)) {
for (const m of rule[1].matchAll(/\.(-?[a-zA-Z_][\w-]*)/g)) {
if (!classDef.has(m[1])) classDef.set(m[1], new Set());
classDef.get(m[1]).add(f);
}
}
for (const m of clean.matchAll(/(--[a-zA-Z][\w-]*)\s*:/g)) {
if (!varDef.has(m[1])) varDef.set(m[1], new Set());
varDef.get(m[1]).add(f);
}
}
// --- usage checks ----------------------------------------------------------
function classUsed(c) {
// token in any source file (className="..", clsx, `tpl-${}`, styles.camel, etc.)
if (new RegExp(`(^|[^\\w-])${esc(c)}([^\\w-]|$)`).test(srcCorpus))
return true;
// composed by another stylesheet
if (new RegExp(`composes:[^;]*\\b${esc(c)}\\b`).test(cssCorpus)) return true;
// dynamically constructed: a `-`/`_`-bounded prefix of `c` is immediately
// followed by a template `${...}` in source, e.g. `sui-btn--${variant}`.
for (let i = c.length - 1; i >= 3; i--) {
if (c[i] === "-" || c[i] === "_") {
if (new RegExp(`${esc(c.slice(0, i + 1))}\\$\\{`).test(srcCorpus))
return true;
}
}
return false;
}
function varUsed(v) {
if (new RegExp(`var\\(\\s*${esc(v)}\\b`).test(cssCorpus + "\n" + srcCorpus))
return true;
if (new RegExp(`["']${esc(v)}["']\\s*:`).test(srcCorpus)) return true; // inline custom prop
return false;
}
const rel = (f) =>
f.replace("editor/src/", "").replace("portal/src/", "portal/");
// `mantine-*` are the framework's own runtime classes (applied by Mantine, not
// referenced in our source) — exclude; they can't be validated by source refs.
const unusedClasses = [...classDef.keys()]
.filter((c) => !c.startsWith("mantine-") && !classUsed(c))
.sort();
const unusedVars = [...varDef.keys()].filter((v) => !varUsed(v)).sort();
const show = (name) =>
!filter || name.toLowerCase().includes(filter.toLowerCase());
console.log(
`\n=== UNUSED CLASSES (${unusedClasses.filter(show).length}${filter ? ` matching "${filter}"` : ""} of ${unusedClasses.length} total) ===`,
);
for (const c of unusedClasses.filter(show)) {
console.log(`.${c}\t${[...classDef.get(c)].map(rel).join(", ")}`);
}
console.log(
`\n=== UNUSED VARS (${unusedVars.filter(show).length}${filter ? ` matching "${filter}"` : ""} of ${unusedVars.length} total) ===`,
);
for (const v of unusedVars.filter(show)) {
console.log(`${v}\t${[...varDef.get(v)].map(rel).join(", ")}`);
}