mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 09:53:08 +02:00
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
211 lines
7.6 KiB
JavaScript
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`);
|
|
}
|