#!/usr/bin/env node /** * Content corpus for the unusedSymbols report (task 11). * * Reliable unused-selector detection needs a "content corpus": all the text that * carries CSS class/id names that could appear in the HTML at runtime. We feed * lightningcss's `unusedSymbols` option the set of symbols that do NOT appear in * this corpus. Anything absent from the corpus is a candidate for pruning. * * Sources gathered (in logical order): * - app/templates/*.php recursively (full-page templates + partials/fragments) * - app/public/*.php recursively (admin\/partage\/root dispatchers + fragments) * - app/src/*.php recursively (controllers, icon.php, helpers) * - app/public/assets/js/app/*.js (first-party JS toggling/inserting classes) * * Vendor JS is EXCLUDED: minified third-party bundles (htmx, filepond, pdf) * reference class names internal to their own CSS, and those names would only * *under*-report project usage — the safe direction. Their CSS is already * independently bundled by build-css.mjs. * * Dynamic-class emitters (ternaries, string concat with `$var` inside * class="...", icon() helper) are handled in two ways: * 1. The literal class names in the PHP source are still captured by the raw * scan (the static part of `class="btn btn--"` yields "btn"). * 2. Names produced only at runtime (the `$var` part) are NOT literals — we * list them in DYNAMIC_CLASS_SAFELIST (task 13) so the report flags them as * "needs review" instead of silently marking them removable. * * Export: * buildCorpus() -> { corpus, sources, totalBytes } * DYNAMIC_CLASS_SAFELIST -> Set exact symbols always kept * DYNAMIC_CLASS_PREFIXES -> string[] prefix patterns (x--*) kept */ import { readFileSync } from "node:fs"; import { readdirSync, statSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); const appDir = resolve(root, "app"); /** * Exact class names produced ONLY at runtime (PHP `$var`, ternaries, helper * return values) — the raw corpus text always contains them because they appear * as the literal value in the source (e.g. `return 'status-ok'`, `'active'`). * Listing them here makes the link between dynamic emitters and the CSS explicit * and reviewable. If a reviewer later confirms one is truly unused, it can be * dropped from this set. * * Mined from: status-badge.php ($cssClass), SystemController::statusClass / * logLineClass, and the dynamic class="" ternaries in templates/public. */ const DYNAMIC_CLASS_SAFELIST = new Set([ // status-badge.php ($cssClass) — publication + access states "status-published", "status-pending", "status-badge", // SystemController::statusClass($status) "status-ok", "status-warn", "status-err", "status-unknown", // SystemController::logLineClass($line) "log-crit", "log-error", "log-warn", "log-notice", // form field validation state "input-error", // conditional tab / button / pagination state "active", "disabled", "btn--primary", "btn--secondary", "pagination-btn", // admin form-help-inline toggles "fhb-dot--on", "fhb-dot--off", "fhb-inline--disabled", // admin icon button publish states "admin-icon-btn--publish", "admin-icon-btn--unpublish", ]); /** * Prefix patterns whose SUFFIX is derived from runtime/DB data (so the exact * symbol is never a literal in the corpus). The report must keep ANY symbol that * starts with one of these prefixes. E.g. `status-access--{slug}` (DB slug), * `toc-level-{n}` (computed), `admin-import-log__item--{type}` (DB type). */ const DYNAMIC_CLASS_PREFIXES = [ "status-access", // status-access--{accessSlug} "toc-level", // toc-level-{n} "admin-import-log__item", // admin-import-log__item--{type} "admin-body", // from $bodyClass var (also student-body) "student-body", ]; /** * Vendor-generated class families. These are assembled by third-party JS at * runtime (string concat), so no literal appears in the first-party corpus — a * naive scan would (mis-)report them as unused, inflating reclaimable bytes. * * - `filepond--*` : FilePond builds its DOM/sceleton from class name parts * (filepond--item, --panel-root, --action-*, ...) at runtime * (see vendor/filepond.min.js). Its CSS is bundled into * admin/form/partage-form. MUST stay. * - `htmx-*` : HTMX toggles htmx-settling/htmx-swapping during swaps. * - add more as the report surfaces vendor families. */ const VENDOR_CLASS_PREFIXES = [ "filepond", // filepond--* (also filepond--image-preview-*, --action-*) "htmx", // htmx-settling, htmx-swapping, htmx-request, htmx-adding-class ]; const SCAN_GLOBS = [ join(appDir, "templates"), // app/templates/**/*.php join(appDir, "public"), // app/public/**/*.php (fragments + dispatch) join(appDir, "src"), // app/src/**/*.php (controllers, icon.php, helpers) join(appDir, "public/assets/js/app"), // first-party JS ]; /** * Try .php-style values; returns paths to read. * @param {string} dir */ function collectPhp(dir) { const out = []; const walk = (d) => { let entries; try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of entries) { const p = join(d, e.name); if (e.isDirectory()) { // Skip heavy/generated subtrees we never want in the corpus. if (["assets", "vendor", "node_modules", "dist"].includes(e.name)) continue; // For app/public/assets we only want js/app, handled as its own root. if (e.name === "css" || e.name === "img" || e.name === "icons" || e.name === "fonts") continue; walk(p); } else if (e.isFile() && extname(e.name) === ".php") { out.push(p); } } }; walk(dir); return out.sort(); } function collectJs(appJsDir) { const out = []; let entries; try { entries = readdirSync(appJsDir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { if (e.isFile() && extname(e.name) === ".js") { out.push(join(appJsDir, e.name)); } } return out.sort(); } /** * Build the full content corpus. * @returns {{ corpus: string, sources: Array<{path,bytes}>, totalBytes: number, safelist: Set }} */ export function buildCorpus() { const files = []; for (const dir of SCAN_GLOBS) { if (dir.endsWith("js/app")) { files.push(...collectJs(dir).map((f) => ({ path: f, bytes: statSync(f).size }))); } else { files.push(...collectPhp(dir).map((f) => ({ path: f, bytes: statSync(f).size }))); } } files.sort((a, b) => a.path.localeCompare(b.path)); const parts = []; for (const f of files) { parts.push(readFileSync(f.path, "utf8")); } return { corpus: parts.join("\n"), sources: files, totalBytes: files.reduce((s, f) => s + f.bytes, 0), safelist: DYNAMIC_CLASS_SAFELIST, prefixes: DYNAMIC_CLASS_PREFIXES, vendorPrefixes: VENDOR_CLASS_PREFIXES, }; } // CLI: print an inventory summary. if (process.argv[1] === fileURLToPath(import.meta.url)) { const c = buildCorpus(); console.log(`Content corpus: ${c.corpus.length.toLocaleString()} chars across ${c.sources.length} files`); for (const s of c.sources) { console.log(` ${relative(root, s.path).padEnd(70)} ${s.bytes.toLocaleString()} B`); } console.log(`Total source bytes: ${c.totalBytes.toLocaleString()} B`); console.log(`Dynamic safelist: ${c.safelist.size} exact + ${c.prefixes.length} prefixes`); }