Files
xamxam/scripts/css-content-sources.mjs
Pontoporeia bfde71caaa style: fix biome check errors (import order + formatting)
npx biome check reported 14 errors on app/public/assets/css/,
app/public/assets/js/app/ and scripts/:
  - assist/source/organizeImports: scripts/css-content-sources.mjs,
    scripts/css-unused-report.mjs
  - format: 9 CSS files + 3 JS files

Fixed via biome check --write. Verified: npx biome check clean,
npm run build succeeds, PHPUnit 316 tests / 628 assertions pass.
2026-09-18 16:26:49 +02:00

210 lines
7.6 KiB
JavaScript

#!/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<string> exact symbols always kept
* DYNAMIC_CLASS_PREFIXES -> string[] prefix patterns (x--*) kept
*/
import { readdirSync, readFileSync, 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 <index>.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<string> }}
*/
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`);
}