mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +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
169 lines
6.0 KiB
JavaScript
169 lines
6.0 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* CSS unusedSymbols report — DIAGNOSTIC ONLY. Does not modify any bundle.
|
|
*
|
|
* Computes which class/id selectors in each built `.min.css` bundle are never
|
|
* referenced by the runtime content corpus (templates, first-party JS, helpers),
|
|
* then measures how many bytes lightningcss would reclaim if those unused
|
|
* symbols were stripped. No stripping is applied to disk.
|
|
*
|
|
* Method:
|
|
* 1. Extract every class/id symbol from the minified CSS text.
|
|
* 2. Build the content corpus via buildCorpus() (scripts/css-content-sources.mjs).
|
|
* 3. A symbol is "candidate-unused" if its name does NOT appear (word-bounded)
|
|
* anywhere in the corpus and is not covered by the dynamic safelist/prefixes.
|
|
* 4. Symbols in the dynamic safelist/prefixes but absent from corpus are listed
|
|
* as "needs review" (kept, never flagged removable).
|
|
* 5. Reclaimable bytes = size(minified) - size(minified with `unusedSymbols`
|
|
* set to the candidate-unused set).
|
|
*
|
|
* Run: node scripts/css-unused-report.mjs [bundle ...]
|
|
* (no args => all dist/*.min.css)
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { transform } from "lightningcss";
|
|
import { buildCorpus } from "./css-content-sources.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, "..");
|
|
const distDir = resolve(root, "app/public/assets/dist");
|
|
|
|
const TARGETS = { chrome: 115 << 16, firefox: 115 << 16, safari: 16 << 16 };
|
|
|
|
/** Extract distinct `.class` and `#id` symbols from CSS text. */
|
|
function extractSymbols(css) {
|
|
const names = new Set();
|
|
const re = /[^\\]([.#])(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g;
|
|
let m = re.exec(css);
|
|
while (m) {
|
|
const prefix = m[1]; // '.' or '#'
|
|
const name = m[2];
|
|
// Skip CSS escapes/urls: names immediately followed by nothing else are fine.
|
|
// url("...png") file extensions (e.g. .otf, .svg) are not real symbols; drop
|
|
// tokens that are part of a url() or @import by checking the preceding char.
|
|
const before = css[m.index - 1];
|
|
// Exclude when preceded by `.` inside a url (e.g. `styles.otf` → ends `.otf`)
|
|
// by requiring the char before the prefix to be whitespace, `{`, `,`, `(`, `[`, `>`, `~`, `+`, `:`, or start.
|
|
if (/[\w-]/.test(before)) {
|
|
m = re.exec(css); // advance
|
|
continue;
|
|
}
|
|
names.add(prefix + name);
|
|
m = re.exec(css); // advance
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** True if `name` (a CSS identifier) appears as a whole token in corpus. */
|
|
function symbolInCorpus(name, corpus) {
|
|
// Escape regex special chars (names are already [A-Za-z0-9-], keep simple).
|
|
const re = new RegExp(`\\b${name}\\b`, "g");
|
|
return re.test(corpus);
|
|
}
|
|
|
|
/** Compress whitespace for a canonical multi-term search on dynamic prefixes. */
|
|
function analyze(bundleName) {
|
|
const file = resolve(distDir, bundleName);
|
|
let css;
|
|
try {
|
|
css = readFileSync(file, "utf8");
|
|
} catch {
|
|
return { bundle: bundleName, error: "not found" };
|
|
}
|
|
const { corpus, safelist, prefixes, vendorPrefixes } = buildCorpus();
|
|
|
|
const symbols = extractSymbols(css);
|
|
const unused = [];
|
|
const needsReview = [];
|
|
const used = [];
|
|
|
|
for (const sym of symbols) {
|
|
const name = sym.slice(1); // drop '.' or '#'
|
|
const idOrClass = sym[0];
|
|
// Id symbols are handled separately; unusedSymbols supports ids, but to keep
|
|
// the report conservative we only strip class symbols here. (ids are usually
|
|
// JS/app entry points).
|
|
const inCorpus = idOrClass === "." ? symbolInCorpus(name, corpus) : true;
|
|
const safelisted = safelist.has(name);
|
|
const prefixSafe = prefixes.some((p) => name.startsWith(p));
|
|
const vendorSafe = vendorPrefixes.some((p) => name.startsWith(p));
|
|
|
|
if (inCorpus || prefixSafe || vendorSafe) {
|
|
used.push(name);
|
|
} else if (safelisted) {
|
|
needsReview.push(name); // dynamic emitter exists, but not as literal — keep
|
|
} else {
|
|
unused.push(name);
|
|
}
|
|
}
|
|
|
|
// Reclaimable bytes: original vs stripped.
|
|
const originalSize = Buffer.byteLength(css, "utf8");
|
|
|
|
// NOTE: unusedSymbols in lightningcss expects bare names (no '.'/'#' prefix).
|
|
const stripped = transform({
|
|
filename: file,
|
|
code: Buffer.from(css, "utf8"),
|
|
minify: true,
|
|
targets: TARGETS,
|
|
unusedSymbols: unused,
|
|
});
|
|
const strippedSize = Buffer.byteLength(stripped.code, "utf8");
|
|
const reclaimable = originalSize - strippedSize;
|
|
|
|
return {
|
|
bundle: bundleName,
|
|
symbols: symbols.size,
|
|
used,
|
|
needsReview,
|
|
unused,
|
|
originalSize,
|
|
strippedSize,
|
|
reclaimable,
|
|
};
|
|
}
|
|
|
|
function printReport(results) {
|
|
let totalOriginal = 0;
|
|
let totalReclaim = 0;
|
|
console.log("\nCSS unusedSymbols report (diagnostic only — no stripping applied)\n");
|
|
for (const r of results) {
|
|
if (r.error) {
|
|
console.log(` ✗ ${r.bundle}: ${r.error}`);
|
|
continue;
|
|
}
|
|
totalOriginal += r.originalSize;
|
|
totalReclaim += r.reclaimable;
|
|
console.log(`\n══ ${r.bundle} ══`);
|
|
console.log(` symbols: ${r.symbols} original: ${r.originalSize} B reclaimable: ${r.reclaimable} B (${((r.reclaimable / r.originalSize) * 100).toFixed(1)}%)`);
|
|
if (r.unused.length) {
|
|
console.log(` ── ${r.unused.length} candidate-unused (would be stripped):`);
|
|
console.log(` ${r.unused.join(" ")}`);
|
|
}
|
|
if (r.needsReview.length) {
|
|
console.log(` ── ${r.needsReview.length} needs-review (dynamic emitter, kept):`);
|
|
console.log(` ${r.needsReview.join(" ")}`);
|
|
}
|
|
}
|
|
console.log(`\n────────────────────────────────────`);
|
|
console.log(`TOTAL original: ${totalOriginal} B reclaimable: ${totalReclaim} B (${((totalReclaim / totalOriginal) * 100).toFixed(1)}%)`);
|
|
}
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
let bundles;
|
|
if (args.length) {
|
|
bundles = args;
|
|
} else {
|
|
bundles = readdirSync(distDir).filter((f) => f.endsWith(".min.css"));
|
|
}
|
|
const results = bundles.map(analyze);
|
|
printReport(results);
|
|
}
|
|
|
|
main();
|