forensic-pathways/check-unused-css.js
2025-08-05 13:03:33 +02:00

103 lines
3.5 KiB
JavaScript

#!/usr/bin/env node
// find-unused-css.js
// Usage: node find-unused-css <cssFile> <sourceRoot> [--verbose]
import fs from 'fs/promises';
import path from 'path';
import fg from 'fast-glob';
import pc from 'picocolors';
import postcss from 'postcss';
import safeParser from 'postcss-safe-parser';
const [,, cssPath, srcRoot = '.', ...rest] = process.argv;
const verbose = rest.includes('--verbose');
if (!cssPath) {
console.error('Usage: node find-unused-css <cssFile> <sourceRoot>');
process.exit(1);
}
/* -------------------------------------------------- */
/* 1. Parse the CSS and harvest class/id tokens */
/* -------------------------------------------------- */
const cssRaw = await fs.readFile(cssPath, 'utf8');
const root = postcss().process(cssRaw, { parser: safeParser }).root;
const selectorTokens = new Map(); // selector → Set('.foo', '#bar')
const CLASS = /\.([\w-]+)/g;
const ID = /#([\w-]+)/g;
root.walkRules(rule => {
rule.selectors.forEach(sel => {
const tokens = new Set();
sel.replace(CLASS, (_, c) => tokens.add('.'+c));
sel.replace(ID, (_, i) => tokens.add('#'+i));
if (tokens.size) selectorTokens.set(sel, tokens);
});
});
/* -------------------------------------------------- */
/* 2. Dynamic classes you add via JS (safe keep) */
/* -------------------------------------------------- */
const dynamicAllow = new Set([
'hidden', 'active', 'loading', 'open', 'closed'
]);
/* -------------------------------------------------- */
/* 3. Read every source file once */
/* -------------------------------------------------- */
const files = await fg([
`${srcRoot}/**/*.{html,htm,js,jsx,ts,tsx,vue,svelte,astro}`,
`!${srcRoot}/**/node_modules/**`
]);
const sources = await Promise.all(files.map(f => fs.readFile(f, 'utf8')));
/* -------------------------------------------------- */
/* 4. Fast search helpers */
/* -------------------------------------------------- */
const makeClassRE = cls =>
new RegExp(
`(class|className)=['"][^'"]*\\b${cls}\\b[^'"]*['"]|['"\`]${cls}['"\`]`,
'i'
);
const makeIdRE = id =>
new RegExp(`id=['"]${id}['"]|['"\`]${id}['"\`]`, 'i');
const tokenInSources = token => {
// dynamic allow-list
if (dynamicAllow.has(token)) return true;
const re = token.startsWith('.')
? makeClassRE(token.slice(1))
: makeIdRE(token.slice(1));
return sources.some(txt => re.test(txt));
};
/* -------------------------------------------------- */
/* 5. Decide used vs unused */
/* -------------------------------------------------- */
const used = [];
const unused = [];
for (const [selector, tokens] of selectorTokens.entries()) {
const isUsed = [...tokens].some(tokenInSources); // **ANY** token keeps rule
(isUsed ? used : unused).push(selector);
if (verbose) {
console.log(isUsed ? pc.green('✓ '+selector) : pc.red('✗ '+selector));
}
}
/* -------------------------------------------------- */
/* 6. Report & write list */
/* -------------------------------------------------- */
console.log(
`\n${pc.bold(pc.blue('🎯 CSS usage summary'))}\n` +
` selectors total : ${selectorTokens.size}\n` +
` still used : ${pc.green(used.length)}\n` +
` maybe unused : ${pc.red(unused.length)}\n`
);
const outFile = path.resolve('unused-selectors.txt');
await fs.writeFile(outFile, unused.join('\n'));
console.log(`📝 Unused selector list → ${pc.yellow(outFile)}\n`);