#!/usr/bin/env node // find-unused-css.js // Usage: node find-unused-css [--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 '); 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`);