diff --git a/CLEAN_CODE_ANALYSIS.md b/CLEAN_CODE_ANALYSIS.md index 5c25292..4362b4c 100644 --- a/CLEAN_CODE_ANALYSIS.md +++ b/CLEAN_CODE_ANALYSIS.md @@ -485,7 +485,7 @@ styles/ - [x] Introduce a lightweight CSS lint configuration (`stylelint` + [`.stylelintrc.json`](.stylelintrc.json:1)) scoped to `styles/**/*.css` to enforce: - No ID selectors in plugin CSS. - A small, explicit set of allowed class prefixes for plugin and layout classes (`coalesce-`, `backlinks-`, `markdown-`, `theme-`, `is-`, `has-`, `no-`). - - [ ] Optionally add a simple “unused selector” check in CI (build + grep for key class names in HTML/DOM tests). + - [x] Add a simple “unused selector” check via `npm run lint:css:unused` using [`scripts/check-unused-css.mjs`](scripts/check-unused-css.mjs:1), which heuristically reports plugin-scoped CSS classes not referenced in `src/**/*.ts`/`.js` files (informational only; does not fail the build). This plan keeps the runtime surface unchanged (still a single `dist/styles.css`) while making the CSS layout more modular and slice-aware. diff --git a/package.json b/package.json index 403a636..932ef2a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "lint:css": "stylelint \"styles/**/*.css\"" + "lint:css": "stylelint \"styles/**/*.css\"", + "lint:css:unused": "node scripts/check-unused-css.mjs" }, "keywords": [], "author": "", diff --git a/scripts/check-unused-css.mjs b/scripts/check-unused-css.mjs new file mode 100644 index 0000000..5078b4a --- /dev/null +++ b/scripts/check-unused-css.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { promises as fs } from 'fs'; +import path from 'path'; + +const ROOT = process.cwd(); +const CSS_DIR = path.join(ROOT, 'styles'); +const SRC_DIR = path.join(ROOT, 'src'); + +const SKIP_DIRS = ['node_modules', '.git', 'dist']; +const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.jsx']; + +async function collectFiles(dir, exts) { + const files = []; + async function walk(current) { + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIRS.some(skip => full.includes(path.sep + skip + path.sep))) { + continue; + } + await walk(full); + } else if (exts.some(ext => full.endsWith(ext))) { + files.push(full); + } + } + } + await walk(dir); + return files; +} + +async function collectCssFiles() { + return collectFiles(CSS_DIR, ['.css']); +} + +async function collectSourceFiles() { + return collectFiles(SRC_DIR, SOURCE_EXTS); +} + +function extractClassesFromCss(css) { + const classRegex = /\.([a-zA-Z0-9_-]+)/g; + const classes = new Set(); + let match; + while ((match = classRegex.exec(css)) !== null) { + const name = match[1]; + if (/^(coalesce|backlinks|markdown|theme|is|has|no)-/.test(name)) { + classes.add(name); + } + } + return classes; +} + +async function buildFileCache(files) { + const cache = new Map(); + for (const file of files) { + try { + const content = await fs.readFile(file, 'utf8'); + cache.set(file, content); + } catch { + // ignore read errors + } + } + return cache; +} + +function isClassUsedInSources(className, sourceCache) { + for (const content of sourceCache.values()) { + if (content.includes(className)) { + return true; + } + } + return false; +} + +async function main() { + console.log('[coalesce] CSS unused-selector check starting...'); + + const cssFiles = await collectCssFiles(); + const sourceFiles = await collectSourceFiles(); + + if (cssFiles.length === 0) { + console.log('[coalesce] No CSS files found under styles/'); + return; + } + + const sourceCache = await buildFileCache(sourceFiles); + + const allClasses = new Set(); + for (const cssFile of cssFiles) { + const css = await fs.readFile(cssFile, 'utf8'); + const classes = extractClassesFromCss(css); + for (const cls of classes) { + allClasses.add(cls); + } + } + + const possiblyUnused = []; + for (const cls of allClasses) { + const used = isClassUsedInSources(cls, sourceCache); + if (!used) { + possiblyUnused.push(cls); + } + } + + if (possiblyUnused.length === 0) { + console.log('[coalesce] No obvious unused plugin CSS classes found.'); + } else { + console.log('[coalesce] Potentially unused plugin CSS classes (heuristic):'); + for (const cls of possiblyUnused) { + console.log(' - .' + cls); + } + console.log('[coalesce] Review these and remove any truly unused selectors. This check is informational and does not fail the build.'); + } +} + +main().catch((err) => { + console.error('[coalesce] CSS unused-selector check failed:', err); + process.exitCode = 1; +}); \ No newline at end of file