Files
xamxam/scripts/css-unused-report.mjs
Pontoporeia bfde71caaa style: fix biome check errors (import order + formatting)
npx biome check reported 14 errors on app/public/assets/css/,
app/public/assets/js/app/ and scripts/:
  - assist/source/organizeImports: scripts/css-content-sources.mjs,
    scripts/css-unused-report.mjs
  - format: 9 CSS files + 3 JS files

Fixed via biome check --write. Verified: npx biome check clean,
npm run build succeeds, PHPUnit 316 tests / 628 assertions pass.
2026-09-18 16:26:49 +02:00

169 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/**
* CSS unusedSymbols report — DIAGNOSTIC ONLY. Does not modify any bundle.
*
* Computes which class/id selectors in each built `.min.css` bundle are never
* referenced by the runtime content corpus (templates, first-party JS, helpers),
* then measures how many bytes lightningcss would reclaim if those unused
* symbols were stripped. No stripping is applied to disk.
*
* Method:
* 1. Extract every class/id symbol from the minified CSS text.
* 2. Build the content corpus via buildCorpus() (scripts/css-content-sources.mjs).
* 3. A symbol is "candidate-unused" if its name does NOT appear (word-bounded)
* anywhere in the corpus and is not covered by the dynamic safelist/prefixes.
* 4. Symbols in the dynamic safelist/prefixes but absent from corpus are listed
* as "needs review" (kept, never flagged removable).
* 5. Reclaimable bytes = size(minified) - size(minified with `unusedSymbols`
* set to the candidate-unused set).
*
* Run: node scripts/css-unused-report.mjs [bundle ...]
* (no args => all dist/*.min.css)
*/
import { readdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { transform } from "lightningcss";
import { buildCorpus } from "./css-content-sources.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
const distDir = resolve(root, "app/public/assets/dist");
const TARGETS = { chrome: 115 << 16, firefox: 115 << 16, safari: 16 << 16 };
/** Extract distinct `.class` and `#id` symbols from CSS text. */
function extractSymbols(css) {
const names = new Set();
const re = /[^\\]([.#])(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g;
let m = re.exec(css);
while (m) {
const prefix = m[1]; // '.' or '#'
const name = m[2];
// Skip CSS escapes/urls: names immediately followed by nothing else are fine.
// url("...png") file extensions (e.g. .otf, .svg) are not real symbols; drop
// tokens that are part of a url() or @import by checking the preceding char.
const before = css[m.index - 1];
// Exclude when preceded by `.` inside a url (e.g. `styles.otf` → ends `.otf`)
// by requiring the char before the prefix to be whitespace, `{`, `,`, `(`, `[`, `>`, `~`, `+`, `:`, or start.
if (/[\w-]/.test(before)) {
m = re.exec(css); // advance
continue;
}
names.add(prefix + name);
m = re.exec(css); // advance
}
return names;
}
/** True if `name` (a CSS identifier) appears as a whole token in corpus. */
function symbolInCorpus(name, corpus) {
// Escape regex special chars (names are already [A-Za-z0-9-], keep simple).
const re = new RegExp(`\\b${name}\\b`, "g");
return re.test(corpus);
}
/** Compress whitespace for a canonical multi-term search on dynamic prefixes. */
function analyze(bundleName) {
const file = resolve(distDir, bundleName);
let css;
try {
css = readFileSync(file, "utf8");
} catch {
return { bundle: bundleName, error: "not found" };
}
const { corpus, safelist, prefixes, vendorPrefixes } = buildCorpus();
const symbols = extractSymbols(css);
const unused = [];
const needsReview = [];
const used = [];
for (const sym of symbols) {
const name = sym.slice(1); // drop '.' or '#'
const idOrClass = sym[0];
// Id symbols are handled separately; unusedSymbols supports ids, but to keep
// the report conservative we only strip class symbols here. (ids are usually
// JS/app entry points).
const inCorpus = idOrClass === "." ? symbolInCorpus(name, corpus) : true;
const safelisted = safelist.has(name);
const prefixSafe = prefixes.some((p) => name.startsWith(p));
const vendorSafe = vendorPrefixes.some((p) => name.startsWith(p));
if (inCorpus || prefixSafe || vendorSafe) {
used.push(name);
} else if (safelisted) {
needsReview.push(name); // dynamic emitter exists, but not as literal — keep
} else {
unused.push(name);
}
}
// Reclaimable bytes: original vs stripped.
const originalSize = Buffer.byteLength(css, "utf8");
// NOTE: unusedSymbols in lightningcss expects bare names (no '.'/'#' prefix).
const stripped = transform({
filename: file,
code: Buffer.from(css, "utf8"),
minify: true,
targets: TARGETS,
unusedSymbols: unused,
});
const strippedSize = Buffer.byteLength(stripped.code, "utf8");
const reclaimable = originalSize - strippedSize;
return {
bundle: bundleName,
symbols: symbols.size,
used,
needsReview,
unused,
originalSize,
strippedSize,
reclaimable,
};
}
function printReport(results) {
let totalOriginal = 0;
let totalReclaim = 0;
console.log("\nCSS unusedSymbols report (diagnostic only — no stripping applied)\n");
for (const r of results) {
if (r.error) {
console.log(` ✗ ${r.bundle}: ${r.error}`);
continue;
}
totalOriginal += r.originalSize;
totalReclaim += r.reclaimable;
console.log(`\n══ ${r.bundle} ══`);
console.log(` symbols: ${r.symbols} original: ${r.originalSize} B reclaimable: ${r.reclaimable} B (${((r.reclaimable / r.originalSize) * 100).toFixed(1)}%)`);
if (r.unused.length) {
console.log(` ── ${r.unused.length} candidate-unused (would be stripped):`);
console.log(` ${r.unused.join(" ")}`);
}
if (r.needsReview.length) {
console.log(` ── ${r.needsReview.length} needs-review (dynamic emitter, kept):`);
console.log(` ${r.needsReview.join(" ")}`);
}
}
console.log(`\n────────────────────────────────────`);
console.log(`TOTAL original: ${totalOriginal} B reclaimable: ${totalReclaim} B (${((totalReclaim / totalOriginal) * 100).toFixed(1)}%)`);
}
function main() {
const args = process.argv.slice(2);
let bundles;
if (args.length) {
bundles = args;
} else {
bundles = readdirSync(distDir).filter((f) => f.endsWith(".min.css"));
}
const results = bundles.map(analyze);
printReport(results);
}
main();