mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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
This commit is contained in:
@@ -1,16 +1,13 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
> Last updated: 2026-08-26
|
> Last updated: 2026-08-28
|
||||||
> Context: Recherche insensible aux accents sur toutes les barres de recherche (publique + backoffice)
|
> 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
|
## In Progress
|
||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
## Completed
|
## 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] #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-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
|
- [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] #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] #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] #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
|
## 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
|
- [ ] #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
|
- [ ] #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 <link> 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.
|
||||||
|
|||||||
@@ -1453,16 +1453,11 @@ th.admin-ap-col {
|
|||||||
gap: var(--space-3xs);
|
gap: var(--space-3xs);
|
||||||
border-top: 1px solid var(--border-primary);
|
border-top: 1px solid var(--border-primary);
|
||||||
padding: var(--space-xs) 0;
|
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 {
|
.param-form > div:first-of-type {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
border-top: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-form input[type="password"] {
|
.param-form input[type="password"] {
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
.tfe-main {
|
.tfe-main {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -60,15 +57,6 @@
|
|||||||
padding: var(--space-xl) var(--space-m) var(--space-xl) var(--space-m);
|
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 */
|
/* Left column: stacks author, title, meta, synopsis — scrolls independently */
|
||||||
.tfe-left-column {
|
.tfe-left-column {
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
|
|||||||
@@ -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
|
||||||
|
<link rel="stylesheet" href="/assets/dist/base.min.css">
|
||||||
|
```
|
||||||
|
|
||||||
|
`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).
|
||||||
@@ -70,6 +70,13 @@ build-css:
|
|||||||
build-js:
|
build-js:
|
||||||
@node scripts/build-js.mjs
|
@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')]
|
[group('build')]
|
||||||
build-install:
|
build-install:
|
||||||
@npm ci
|
@npm ci
|
||||||
|
|||||||
@@ -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<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`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user