#!/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 { readFileSync, readdirSync } 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();