From 8accb8845273e80f0b1e514ad3ad02c15f2ed4a3 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Fri, 28 Aug 2026 09:27:20 +0200 Subject: [PATCH] update: CSS per-page split and lightningcss unusedSymbols report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 20 ++- app/public/assets/css/admin.css | 5 - app/public/assets/css/tfe.css | 22 +--- docs/css-split-analysis.md | 162 ++++++++++++++++++++++++ justfile | 7 ++ scripts/css-content-sources.mjs | 210 ++++++++++++++++++++++++++++++++ scripts/css-unused-report.mjs | 168 +++++++++++++++++++++++++ 7 files changed, 566 insertions(+), 28 deletions(-) create mode 100644 docs/css-split-analysis.md create mode 100644 scripts/css-content-sources.mjs create mode 100644 scripts/css-unused-report.mjs diff --git a/TODO.md b/TODO.md index c9403a3..bbbcd78 100644 --- a/TODO.md +++ b/TODO.md @@ -1,16 +1,13 @@ # TODO -> Last updated: 2026-08-26 -> Context: Recherche insensible aux accents sur toutes les barres de recherche (publique + backoffice) +> Last updated: 2026-08-28 +> Context: CSS waste strategy — full stream DEFERRED (tasks 10/u/w/x/y/z): unusedSymbols report showed only ~6.2KB/2.9% of 216KB reclaimable; pruning + per-page split parked (docs/css-split-analysis.md). #t (Interdit admin route) confirmed already implemented via /admin/media.php + media-viewer.php + recapitulatif link. No pending live tasks. ## In Progress ## Pending ## Completed -- [x] #normalize-synopsis-whitespace & Passages à la ligne inopinés dans les synopsis : jugés INDÉTECTABLES/non-corrigeables automatiquement (une coupure de ligne interne peut être une vraie frontière de mot → espace, ou une coupure du mot → à réjoindre ; indistinguables sans dictionnaire, toute tentative corrompt l'entrée p.ex. "poursuivantsur"). Migration 045 SUPPRIMÉE ; dites à l'admin qu'il n'y a pas de nettoyage fiable des données existantes. Conservation de la NORMALISATION SÛRE sur les nouvelles saisies : fin de ligne \r\n/\r → \n, pertes de lignes vides multiples → \n\n, espaces/tabulations/NBSP → espace unique, trim — retour à la ligne simple intra-paragraphe laissé intact. -- [x] #recherche-insensible-aux-accents Recherche insensible aux accents sur toutes les barres de recherche : "Théophile" et "Theophile" donnent maintenant les mêmes résultats. Implémentation via une fonction SQLite UDF `accfold()` (app/src/AccentFolding.php, UDF enregistré dans Database::registerSqliteFunctions) qui plie les caractères accentués latin (é→e, ç→c, …) vers leur base ASCII ; chaque condition de recherche compare la colonne repliée au terme replié (`accfold(column) LIKE accfold(:term)`). Appliqué à la recherche publique (searchTheses/countSearchResults sur titre/sous-titre/synopsis/auteurs/promoteurs/tags), au popover étudiant (getThesesByAuthorName/getThesesForAuthors) et à la recherche backoffice par auteur (getThesesList/getThesesListCount). NULL-safe, enregistré sur les connexions prod et de test (TestDatabase). Pas d'intl/iconv dispo → map de translitération manuelle (Western Latin-1 + Latin Extended-A). Évite aussi la dépréciation PHP 8.5 de PDO::sqliteCreateFunction() : création de la connexion via \Pdo\Sqlite (createFunction()) quand dispo, repli sur PDO (sqliteCreateFunction()) sur anciens runtimes ; tests passent de `@dataProvider` docblock à l'attribut #[DataProvider] (fin de la dépréciation PHPUnit). 294 tests verts sans dépréciation ; phpstan OK. -- [x] #untrack-build-caches Détrache .phpunit.result.cache et .php-cs-fixer.cache (déjà gitignorés) pour qu'ils ne réapparaissent plus comme modifications dans le working copy. - [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale - [x] #rewrite-development-md-to-match [!high] Rewrite development.md to match current just dev / app/ layout / PHPUnit - [x] #rewrite-deployment-md-to-match [!high] Rewrite deployment.md to match just deploy / /var/www/xamxam/ / backup @@ -38,8 +35,19 @@ - [x] #default-exemplaire-baiu Default Exemplaire BAIU + ERG to true on student (partage) submission in ThesisCreateController, replicating admin add-form default - [x] #m-style-links-on Style links on licences, charte, a propos pages (underline + accent hover) - [x] #recherche-backoffice-par-identifiant Recherche backoffice par identifiant : ajouter t.identifier a la WHERE de getThesesList/getThesesListCount -- [x] #admin-open-interdit-files Open 'Interdit' thesis files from backoffice: dedicated /admin/media.php route (AdminAuth session-gated) that bypasses the Interdit visibility gate in MediaController, keeping path jail + MIME checks; recap page links Interdit files to it. Also: file links open via /admin/media-viewer.php HTML wrapper so the tab shows the original file name instead of media.php; media.php sets a meaningful Content-Disposition filename too. Hardened: admin media route restricted to thesis-file prefixes only (defense-in-depth over the MIME/jail checks) +- [x] #add-admin-only-route-to [!low] Add admin-only route to open Interdit thesis files from backoffice --context repo +- [x] #identify-which-templates-load [!high] Identify which templates load base.min.css and the page types — DONE inventory: docs/css-split-analysis.md. Key finding: head.php loads base.min.css on EVERY full page (App::render => templates/head.php unconditional). is the per-page-type mechanism (public/tfe/repertoire/content-page/not-found/form/admin/system). Centralize inventory in docs/css-split-analysis.md. Gates both split (w) and unusedSymbols corpus (11). +- [x] #collect-content-sources-for [!high] Collect content sources for the unusedSymbols pass — Built scripts/css-content-sources.mjs: buildCorpus() returns {corpus, sources, totalBytes, safelist, prefixes}. Gathers templates/public/src PHP + first-party js/app JS (vendor excluded). Mined 22 exact dynamic classes (status-*, log-*, input-error, active, disabled, btn--*, fhb-*, admin-icon-btn--*, status-published/pending/badge) + 5 suffix prefixes (status-access--*, toc-level-*, admin-import-log__item--*, admin-body, student-body) from status-badge.php, SystemController statusClass/logLineClass, class= ternaries. Documented docs/css-split-analysis.md. Feeds task 12 report. +- [x] #write-report-script-to [!medium] Write report script to compute unused selectors + reclaimable bytes — Built scripts/css-unused-report.mjs + just css-report recipe. Extracts class/id symbols per dist bundle, matches against buildCorpus() corpus + safelists, measures reclaimable bytes via lightningcss transform unusedSymbols (no stripping to disk). FULL RESULT: 216,383B total, ~6.2KB (2.9%) reclaimable. See docs/css-split-analysis.md table. Feeds task 14 go/no-go. +- [x] #flag-or-safelist-dynamic [!medium] Flag or safelist dynamic classes before trusting the report — Vendor + dynamic safelists shipped. Dynamic: 22 exact classes (status-*, log-*, input-error, active, disabled, btn--*, fhb-*, admin-icon-btn--*, status-badge/published/pending) + 5 DB/state suffix prefixes (status-access--*, toc-level-*, admin-import-log__item--*, admin-body, student-body). VENDOR: filepond--* and htmx-* prefixes excluded (runtime vendor JS assembly) — without these the report inflated to 26% (56KB); with them honest 2.9%. Report surfaces needs-review (kept, not flagged removable). +- [x] #summarize-findings-and-decide [!low] Summarize findings and decide whether to proceed to pruning — Decision recorded in docs/css-split-analysis.md. Data: 216,383B total, ~6.2KB (2.9%) reclaimable; base.min.css only 484B. SPLIT: NO-GO (base.css already well-used; split adds complexity for ~2% of one bundle) -> defer u-w-x-y. PRUNING: CONDITIONAL-GO on hand-verifiable dead set only, from SOURCE css files, re-run just css-report after each edit, never touch needs-review/vendor-prefix classes. ## Deferred / Blocked - [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal - [ ] #csv-export-writes-cc2r-exemplaire [!low] CSV export writes CC2r/Exemplaire columns that import's $cell mapping ignores — document or wire up +- [ ] #split-base-min-css-into-per-page [!medium] Split base.min.css into per-page bundles to reduce cross-page CSS waste — NO-GO (task 14): per-page split parked. base.min.css already well-used (only 484B/2.3% reclaimable); split adds build/template complexity for ~2% of one bundle. See docs/css-split-analysis.md decision. +- [ ] #define-per-page-type-css-bundle [!medium] Define per-page-type CSS bundle boundaries and entry files — Using the template->page-type map, decide which source files (components/*, utilities.css, reset, typography, etc.) belong in each page-type bundle. Document the mapping so shared-vs-page-specific splits are explicit and reviewable. +- [ ] #implement-per-page-bundles-in [!medium] Implement per-page bundles in scripts/build-css.mjs — Extend build-css.mjs with new bundle targets following the existing bundleCss/concatBundle pattern. Emit per-page-type .min.css outputs into app/public/assets/dist/. Keep base.min.css for pages not yet split. +- [ ] #wire-templates-to-load [!medium] Wire templates to load their page-type bundle instead of base.min.css — Update each template's tags to point at the appropriate per-page bundle. Ensure no page loads CSS it doesn't need and that shared pages still get full styling. +- [ ] #measure-before-after-css-bytes [!low] Measure before/after CSS bytes shipped per page type — Moot: part of split stream which is NO-GO (task 14). Skip unless split is revived. +- [ ] #add-lightningcss-unusedsymbols-report-only [!high] Add lightningcss unusedSymbols report-only pass for base.min.css — SEQUENCING: do this (unusedSymbols report-only) BEFORE the per-page split (u). Reasoning: high priority, zero-risk diagnostic-only, produces reclaimable-byte go/no-go (14) against ~216KB baseline; if pruning is not worthwhile the split may not be either. Reuses inventory from v (docs/css-split-analysis.md). Start: 11 collect-content-sources. diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index a5d3550..808bf02 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -1453,16 +1453,11 @@ th.admin-ap-col { gap: var(--space-3xs); border-top: 1px solid var(--border-primary); padding: var(--space-xs) 0; - display: grid; - grid-template-columns: 1fr; - gap: var(--space-3xs); - border-top: 1px solid var(--border-primary); } .param-form > div:first-of-type { border-top: none; padding-top: 0; - border-top: none; } .param-form input[type="password"] { diff --git a/app/public/assets/css/tfe.css b/app/public/assets/css/tfe.css index 7a33110..2c1a611 100644 --- a/app/public/assets/css/tfe.css +++ b/app/public/assets/css/tfe.css @@ -4,17 +4,14 @@ ============================================================ */ .tfe-main { + display: flex; flex: 1; min-height: 0; overflow: hidden; - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; - /* Left padding keeps content aligned with the rest of the page; - no right padding so the right column (and its scrollbar) sits - flush against the viewport edge. */ - padding-left: var(--space-m); + /* Left padding keeps content aligned with the rest of the page; + no right padding so the right column (and its scrollbar) sits + flush against the viewport edge. */ + padding-left: var(--space-m); } /* Stacked article layout — fills main height */ @@ -60,15 +57,6 @@ padding: var(--space-xl) var(--space-m) var(--space-xl) var(--space-m); } -/* Files container inside right column — flex kept for PDF expand-to-fill behavior */ - display: flex; - flex-direction: column; - overflow-y: auto; - min-width: 0; - padding-top: var(--space-l); - padding-bottom: var(--space-xl); -} - /* Left column: stacks author, title, meta, synopsis — scrolls independently */ .tfe-left-column { gap: var(--space-m); diff --git a/docs/css-split-analysis.md b/docs/css-split-analysis.md new file mode 100644 index 0000000..b1cae7f --- /dev/null +++ b/docs/css-split-analysis.md @@ -0,0 +1,162 @@ +# CSS split / unusedSymbols — Template → Page-type inventory + +Status: analysis (gates `define-per-page-type-css-bundle` and the unusedSymbols content corpus) + +## How CSS is loaded today (head.php) + +`App::render()` (app/src/App.php) always `include`s `app/templates/head.php` for a +full page render. head.php unconditionally emits: + +```html + +``` + +`base.min.css` = the whole `style.css` `@import` chain: +reset → colors → typography → base → all `components/*` → utilities. So **every +full page ships the entire component+utility set**, even pages that barely use it +(the cross-page waste the split targets). + +On top of it, head.php renders `$extraCss` array entries (admin prepends +`admin.min.css` via `$extraCssAdmin`). Page-specific `.min.css` bundles load as +`$extraCss`. + +Fragments / HTMX partials (`app/templates/partials/*`, `app/public/**/fragments`) +are included **directly**, not via `App::render()`, so they do NOT load head.php. + +## Page-type map (entry → templates → extra css → bodyClass) + +### Public (non-admin) + +| Page type | Template(s) | extraCss (on top of base) | bodyClass | Notes | +|----------------------|---------------------------------|--------------------------------|--------------|-------| +| home | `public/home.php` | `public.min.css` | `home-body` | HomeController | +| tfe | `public/tfe.php` | `tfe.min.css` | `tfe-body` | TfeController | +| repertoire/search | `public/repertoire.php`, `public/search.php` | `repertoire.min.css` | `search-body` | SearchController | +| content-page (about / licence / charte) | `public/about.php`, `public/licence.php`, `public/charte.php` | `content-page.min.css` | `apropos-body` | AboutController, LicenceController, CharteController | +| not-found | `public/not-found.php` | `not-found.min.css` | `page-not-found` | Dispatcher | +| partage (student form / recap) | `partage/form-page.php` (templates/), `partage/recapitulatif.php`, `partage/retry-email.php`, `partage/index.php` | `form.min.css` (+ `filepond`) | — | FormBootstrap injects `extraCss = [form.min.css]`; partage/index sets `$filepondBase` | + +### Admin + +head.php prepends `admin.min.css` (`$extraCssAdmin`) whenever `$isAdmin` is set. + +| Page type | Entry (app/public/admin/*.php) | extra extraCss / extraCssAdmin | bodyClass | +|-----------------|------------------------------------|--------------------------------|-----------| +| login / reset | `login.php`, `password-reset.php`, `request-reset.php` | — | `admin-body` | +| index | `index.php` | `filepond*.css` (extraCssAdmin) | `admin-body` | +| recapitulatif | `recapitulatif.php` | — | `admin-body student-body` (student mode) | +| add / edit | `add.php`, `edit.php`, `contenus-edit.php` | form deps | `admin-body` | +| contenu mgmt | `contenus.php`, `tags.php`, `acces*.php`, `cleanup.php`, `account.php`, etc. | — | `admin-body` | +| parametres | `parametres.php` | `system.min.css` (extraCssAdmin) | `admin-body` | + +## Dist bundles present (app/public/assets/dist/) + +From `scripts/build-css.mjs`: `base.min.css`, `admin.min.css`, `form.min.css`, +`public.min.css`, `tfe.min.css`, `repertoire.min.css`, `content-page.min.css`, +`not-found.min.css`, `system.min.css`, `file-access.min.css`, `common.min.css`. + +## Shared core (potential overlap between bundles) + +`base.min.css` already contains everything: reset, colors, typography, base, +components/{links,focus,forms,tables,dialog,details,media,buttons,badges,toast, +pagination,header,search,toc}, utilities. + +The split must decide which of those move OUT of the global base into page-type +bundles; the components used on every page (reset, colors, typography, base, +header, footer, search? — see component usage below) stay in a slim base. + +## Next step + +Component-by-component usage audit: which templates actually reference each +`components/*` class / utility. That drives `define-per-page-type-css-bundle` +and feeds the unusedSymbols content corpus. + +## Content corpus (for unusedSymbols report) + +`scripts/css-content-sources.mjs` builds the content corpus scanned against the +CSS symbols: + +- **Sources**: app/templates/*.php, app/public/*.php, app/src/*.php + (controllers + icon.php helper), app/public/assets/js/app/*.js (first-party). +- **Vendor JS excluded** (htmx/filepond/pdf): their class names are internal to + their own bundled CSS, and including them would only *under*-report project + usage — the safe direction. +- **Dynamic-class handling**: `buildCorpus()` returns a safelist of 22 exact + runtime class names (status-ok/warn/err/unknown, log-*, input-error, active, + disabled, btn--*, fhb-*, admin-icon-btn--*, status-published/pending/badge) + plus 5 prefix patterns (status-access--*, toc-level-*, admin-import-log__item--*, + admin-body, student-body) whose suffix is DB/state-derived. + +Run: `node scripts/css-content-sources.mjs` (prints inventory). +The report script (scripts/css-unused-report.mjs, task 12) imports `buildCorpus()`. + +## unusedSymbols report — findings (diagnostic, task 12/14 go-no-go) + +`just css-report` rebuilds CSS then runs `scripts/css-unused-report.mjs`: +for each dist/*.min.css it extracts class/id symbols, checks them against the +content corpus + dynamic/vendor safelists, and measures bytes lightningcss would +reclaim (no stripping applied). + +**Result (216,383 B total): ~6.2 KB (2.9%) reclaimable.** Per bundle: + +| bundle | orig B | reclaim B | candidate-unused | +|---|---|---|---| +| admin.min.css | 55016 | 3060 | admin-import-results*, n-grid, n-section, param-*, admin-toggle*, admin-dialog--sheet, admin-maintenance-* | +| base.min.css | 21375 | 484 | btn--success, btn--blue, btn--yellow | +| form.min.css | 41855 | 731 | mode-toggle, licence-generalites, file-preview-list | +| partage-form.min.css | 37229 | 731 | (same) | +| form-base.min.css | 19110 | 751 | (same) | +| public.min.css | 4047 | 228 | card__media--placeholder | +| system.min.css | 7408 | 185 | sys-status-section | +| content-page.min.css | 3683 | 32 | heading-permalink | + +**Vendor classes excluded** (would over-report + reclaim): filepond--*, htmx-*. +They are assembled by vendor JS at runtime, so they never appear as literals. + +See TODO task 14 for the go/no-go evaluation of this data. + +## Decision (task 14): per-page split and CSS pruning + +Data: ~6.2 KB (2.9%) reclaimable across 216 KB total. base.min.css itself only +484 B (2.3%) reclaimable. Waste is concentrated in admin/form/partage-form +(shared FilePond + form CSS). + +**Diagnosis:** base.css is already well-used; the per-page SPLIT would shave +minimal real weight (base.min.css is ~all-referenced). The unused-symbol PRUNING +opportunity is small and concentrated in bundles that are already page-type- +specific (form/admin). + +### Split — NO-GO + +Splitting base.min.css into per-page bundles was premised on cross-page waste of +component+utility CSS. The report shows that premise is weak: nearly all of +base.min.css's classes are referenced somewhere, and shaving a redundant handful +is a pruning task, not a split. A split adds build/template complexity for ~2% +of one bundle. → Do not split. Park tasks u-w-x-y as deferred. + +### Pruning — CONDITIONAL-GO (narrow, safe subset only) + +The only pruning worth doing is hand-verifiable dead selectors, removed from +SOURCE css files (never from dist) so the next build drops them: + +- base.min.css: btn--success, btn--blue, btn--yellow (3, 484 B) +- public.min.css: card__media--placeholder (228 B) +- system.min.css: sys-status-section (185 B) +- content-page.min.css: heading-permalink (32 B) +- form*.min.css: mode-toggle, mode-toggle--back, licence-generalites, + file-preview-list (731 B, duplicated across form-base/form/partage-form) +- admin.min.css: admin-import-results*, n-grid, n-section, param-*, + admin-toggle*, admin-dialog--sheet, admin-maintenance-* (3060 B) + +Safety rules: +1. Delete from app/public/assets/css/** sources, never dist. +2. Rebuild + re-run `just css-report` after each edit; the class must vanish + from the candidate list (not just move to needs-review). +3. Never remove a `needs-review` (dynamic emitter) or vendor-prefix class + (filepond--*, htmx-*). +4. btn--success/blue/yellow are safe (no live $-built variants); confirm they are + not emitted by a helper before deleting. +5. Green path: `just build-css` + smoke admin/login/form/public pages after. + +**Verdict:** park the split (NO-GO); optionally prune the ~6.2 KB dead set as a +follow-up, lowest-risk slice first (base/public/system/content-page). diff --git a/justfile b/justfile index cd22035..f33ac87 100644 --- a/justfile +++ b/justfile @@ -70,6 +70,13 @@ build-css: build-js: @node scripts/build-js.mjs +# Diagnostic: report unused CSS symbols per bundle (rebuilds then reports). +# Does NOT strip anything. Run after changing templates/JS to refresh the corpus. +[group('build')] +css-report: + @node scripts/build-css.mjs + @node scripts/css-unused-report.mjs + [group('build')] build-install: @npm ci diff --git a/scripts/css-content-sources.mjs b/scripts/css-content-sources.mjs new file mode 100644 index 0000000..e02df5e --- /dev/null +++ b/scripts/css-content-sources.mjs @@ -0,0 +1,210 @@ +#!/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`); +} diff --git a/scripts/css-unused-report.mjs b/scripts/css-unused-report.mjs new file mode 100644 index 0000000..3ff0810 --- /dev/null +++ b/scripts/css-unused-report.mjs @@ -0,0 +1,168 @@ +#!/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();