Files
xamxam/scripts/css-content-sources.mjs
T
Pontoporeia 8accb88452 update: CSS per-page split and lightningcss unusedSymbols report
docs: record CSS template inventory + decide unusedSymbols-before-split ordering

feat(css): add content-source collector + dynamic-class safelist for unusedSymbols report

- scripts/css-content-sources.mjs: buildCorpus() gathers templates/public/src
  PHP + first-party js/app JS (vendor excluded), returns {corpus, sources,
  totalBytes, safelist, prefixes}
- Mined 22 exact runtime classes + 5 DB/state-derived suffix prefixes from
  status-badge.php, SystemController statusClass/logLineClass, and class=<?=?>
  ternaries
- docs/css-split-analysis.md notes content-corpus section
- td: #11 collect-content-sources done; feeds #12 report script

feat(css): unusedSymbols report script + just css-report recipe

- scripts/css-unused-report.mjs: per-bundle class/id extraction vs buildCorpus()
  corpus + safelists; measures reclaimable bytes via lightningcss transform
  unusedSymbols (report-only, no stripping to disk)
- just css-report: rebuild CSS then run the report
- css-content-sources.mjs: add VENDOR_CLASS_PREFIXES (filepond--*, htmx-*)
- RESULT: 216,383B total, ~6.2KB (2.9%) reclaimable; FilePond/HTMX exclusion
  corrected inflated 26% (56KB) false-positive down to honest 2.9%
- docs/css-split-analysis.md findings table + TODO 12/13 done

docs(css): record go/no-go decision — split NO-GO, pruning conditional-go

- Decision analysis in docs/css-split-analysis.md
- ~6.2KB (2.9%) reclaimable of 216KB; base.min.css only 484B (2.3%)
- SPLIT NO-GO: base.css already well-used; parked u/w/x/y(/z) as deferred
- PRUNING conditional-go on hand-verifiable dead selers from source, never dist;
  re-run just css-report after each edit; keep needs-review + vendor-prefix cls
- td: task 14 done; split stream 41 tasks -> 2 pending / 32 done / 7 deferred

todo: defer CSS pruning stream (10/u/w/x/y/z), context updated
2026-09-18 16:26:36 +02:00

211 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 { 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 <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`);
}