103 lines
3.5 KiB
JavaScript
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`);
|