mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
Full analysis of every public-facing page and partial against semantic HTML.
Currently only one semantic element exists across the entire public frontend (<nav>).
Key findings mapped to concrete replacements:
nav.php: <div class=site-nav__links> → <ul>/<li>; active class → aria-current=page
search <form> needs role=search, aria-label, hidden SVG icon
index.php: <div class=cards-container> → <ul>; card <div>s → <li>; <a> wraps directly
card__media → <figure> for image cards; pagination divs → <nav><ul>
disabled pagination links need aria-disabled + tabindex=-1, not just a class
search.php: filter label+div groups → <label> wrapping <select> (removes 2 classes per group)
.search-results-view wrapper → remove (redundant inside <main>)
results-grid <div> → <ul>; result-card__meta <span> → <small>
repertoire columns <div> → <section>; link lists → <ul>/<li>
active links → aria-current=page
tfe.php: heading hierarchy is backwards — author is h1, title is h2; should be reversed
.tfe-layout → <article>; .tfe-left → <header> inside article
.tfe-meta-list div+span soup → <dl>/<dt>/<dd> (removes ~30 wrapper divs + 5 classes)
.tfe-right → <aside>; .tfe-media-block → <figure>; caption → <figcaption>
.tfe-synopsis-text <div> → <p>; back link wrapper div → remove
apropos.php: .apropos-right <div> → <aside>; contact divs → <address>
section wrapper divs → <section>; two CSS classes → strong + a[href^=mailto:]
double-class .apropos-description.apropos-page-content → single .prose
licence.php: remove always-empty right column and two-column layout entirely
Summary table: 25+ classes that become deletable once semantic elements carry the meaning
760 lines
42 KiB
Markdown
760 lines
42 KiB
Markdown
# TODO
|
||
|
||
## Styling Redesign (matching design images)
|
||
|
||
- [x] Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
|
||
- [x] Redesign shared search bar (full-width, icon, bottom border only, white bg)
|
||
- [x] Rewrite `common.css` (nav + search bar components)
|
||
- [x] Rewrite `main.css` (home page — white bg, media card grid, label below)
|
||
- [x] Rewrite `search.css` (répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS)
|
||
- [x] Rewrite `tfe.css` (TFE page — 2-col, large author/title left, media right)
|
||
- [x] Add `apropos.css` (À Propos — 2-col, large monospace text)
|
||
- [x] Rewrite `admin.css` (dark bg, purple gradient nav, bottom-border-only form inputs)
|
||
- [x] Update `templates/nav.php` (new shared nav partial)
|
||
- [x] Update `templates/search-bar.php` (new shared search bar partial)
|
||
- [x] Rewrite `public/index.php` (home page with new layout)
|
||
- [x] Rewrite `public/search.php` (répertoire index view + search results view)
|
||
- [x] Rewrite `public/tfe.php` (individual TFE page)
|
||
- [x] Create `public/apropos.php` (À Propos page)
|
||
- [x] Rewrite `templates/admin/head.php` (admin nav)
|
||
- [x] Rewrite `templates/admin/footer.php` (clean close)
|
||
- [x] Rewrite `public/admin/add.php` (form with row layout)
|
||
- [x] Rewrite `public/admin/index.php` (dark table)
|
||
- [x] Rewrite `public/admin/edit.php` (form with row layout)
|
||
- [x] Rewrite `public/admin/login.php` (centered dark login box)
|
||
- [x] Rewrite `public/admin/thanks.php` (dark info cards)
|
||
- [x] Rewrite `public/admin/import.php` (clean dark form)
|
||
|
||
## Justfile / Ops
|
||
|
||
- [x] Simplify `serve` and `deploy` to one recipe each
|
||
- [x] Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
|
||
- [x] Extract server scripts to `scripts/` (deploy-server.sh, manage-admin-users.sh)
|
||
- [x] Guard `deploy-db` against overwriting existing remote database
|
||
- [x] Update README.md and docs/SERVER_SETUP.md to reflect current structure
|
||
|
||
---
|
||
|
||
## NEW FEATURES
|
||
|
||
### 1 — License page (public)
|
||
|
||
Create a public-facing `/licence.php` page, styled consistently with `apropos.php`.
|
||
|
||
- [x] **`public/licence.php`** — new public page; fetches content from `pages` table
|
||
(slug `'licenses'`); renders with Parsedown Markdown; uses `apropos.css` layout
|
||
- [x] **`templates/nav.php`** — add "Licence" link between "Répertoire" and "À Propos";
|
||
apply `site-nav__link--active` when `$currentNav === 'licence'`
|
||
- [x] The `pages` table row for slug `'licenses'` verified in live DB
|
||
|
||
---
|
||
|
||
### 2 — Admin: WYSIWYG/Markdown editors for static pages
|
||
|
||
Allow admins to edit the content of the "À propos" and "Licence" pages from the admin
|
||
panel, stored in the existing `pages` table.
|
||
|
||
#### 2a — `src/Database.php`
|
||
|
||
- [x] `getPage(string $slug): array|null` — `SELECT * FROM pages WHERE slug = ?`
|
||
- [x] `savePage(string $slug, string $content): void` — throws if slug not found
|
||
- [x] `getAllPages(): array` — for listing in admin
|
||
|
||
#### 2b — Admin pages editor UI
|
||
|
||
- [x] **`public/admin/pages.php`** — list all editable pages; links to edit each one
|
||
- [x] **`public/admin/pages-edit.php`** — EasyMDE WYSIWYG Markdown editor via CDN
|
||
|
||
#### 2c — `public/admin/actions/page.php`
|
||
|
||
- [x] Auth guard + CSRF check + slug validation + length validation + savePage + redirect
|
||
|
||
#### 2d — Public pages render Markdown
|
||
|
||
- [x] **`public/apropos.php`** — renders `$db->getPage('about')` via Parsedown (bundled `src/Parsedown.php`)
|
||
- [x] **`public/licence.php`** — renders `$db->getPage('licenses')` via Parsedown
|
||
- [x] Parsedown bundled as `src/Parsedown.php` (zero-dependency, MIT)
|
||
|
||
#### 2e — Nav links in admin
|
||
|
||
- [x] **`templates/admin/head.php`** — "Pages statiques" nav item added
|
||
|
||
---
|
||
|
||
### 3 — License field on TFE forms
|
||
|
||
Add a "Licence" dropdown to the Add and Edit TFE forms. The `license_types` table
|
||
already exists in the schema with an `id`, `name`, `description` structure but has
|
||
no seed data yet.
|
||
|
||
#### 3a — Schema / DB
|
||
|
||
- [x] **`storage/schema.sql`** — seed `INSERT OR IGNORE` for 8 CC licence types added
|
||
- [x] **`storage/migrations/003_seed_license_types.sql`** — migration created + applied
|
||
- [x] Verified live DB has `license_types` with 8 rows
|
||
|
||
#### 3b — `src/Database.php`
|
||
|
||
- [x] `getLicenseTypes(): array`
|
||
- [x] `getAllLicenseTypes(): array` — alias
|
||
|
||
#### 3c — Add form (`public/admin/add.php`)
|
||
|
||
- [x] Loads `$licenseTypes`; "Licence" `<select name="license_id">` added before duration
|
||
|
||
#### 3d — Add action (`public/admin/actions/formulaire.php`)
|
||
|
||
- [x] `$licenseId` parsed + included in INSERT
|
||
|
||
#### 3e — Edit form (`public/admin/edit.php`)
|
||
|
||
- [x] Loads `$licenseTypes`; raw `license_id` FK fetched directly; select pre-populated
|
||
- [x] POST handler: `license_id` included in UPDATE
|
||
|
||
#### 3f — View update
|
||
|
||
- [x] **`storage/schema.sql`** — `v_theses_full` now exposes `t.license_id` raw FK (done as part of 004/005 view rebuild)
|
||
|
||
#### 3g — TFE public page
|
||
|
||
- [x] **`public/tfe.php`** — "Licence :" meta row added, shown when non-null
|
||
|
||
---
|
||
|
||
### 4 — Jury composition section in Add/Edit forms
|
||
|
||
Replace the current flat "promoteur interne / externe" fields with a structured
|
||
**Composition du jury** section: président·e, promoteur·ice, lecteur·ices (mixed
|
||
internal/external).
|
||
|
||
#### 4a — Schema / DB
|
||
|
||
Current state: `supervisors` table + `thesis_supervisors` junction with a bare
|
||
`supervisor_order` integer. No role distinction.
|
||
|
||
- [x] **`storage/migrations/004_jury_roles.sql`**:
|
||
- `ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'`
|
||
— role values: `'president'`, `'promoteur'`, `'lecteur'`
|
||
- `ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0`
|
||
— 1 = external, 0 = internal (replaces the old "promoteur externe" free-text field)
|
||
- No data loss: existing rows get `role = 'promoteur'`, `is_external = 0`
|
||
- [x] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors`
|
||
definition; update `v_theses_full` to expose jury members grouped by role:
|
||
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='president' THEN s.name END) as jury_president`
|
||
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as jury_promoteurs`
|
||
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_lecteurs`
|
||
- Keep the existing `supervisors` column (all names) for backwards compat
|
||
- Migration SQL must DROP + CREATE the view
|
||
|
||
#### 4b — `src/Database.php`
|
||
|
||
- [x] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with
|
||
their role and is_external flag
|
||
- [x] `setThesisJury(int $thesisId, array $juryMembers): void` — delete + re-insert
|
||
|
||
#### 4c — Add form (`public/admin/add.php`)
|
||
|
||
- [x] Remove `promoteurice` and `promoteurice_externe` fields
|
||
- [x] Add **"Composition du jury"** fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove
|
||
|
||
#### 4d — Add action (`public/admin/actions/formulaire.php`)
|
||
|
||
- [x] Removed old promoteurice parsing; parse jury fields; call `$db->setThesisJury()`
|
||
|
||
#### 4e — Edit form (`public/admin/edit.php`)
|
||
|
||
- [x] Load `$jury = $db->getThesisJury($thesisId)`; jury fieldset pre-populated from DB
|
||
- [x] POST handler calls `$db->setThesisJury()`
|
||
|
||
#### 4f — TFE public page (`public/tfe.php`)
|
||
|
||
- [x] Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices
|
||
|
||
---
|
||
|
||
### 5 — Banner image upload for home page cards
|
||
|
||
Each TFE can have an optional banner image used as its home page card thumbnail.
|
||
This is distinct from the existing "couverture" file concept (which goes into
|
||
`thesis_files` as type `'cover'`) — the banner is specifically optimised for the
|
||
home grid (wider, shorter aspect ratio).
|
||
|
||
#### 5a — Schema / DB
|
||
|
||
- [x] **`storage/migrations/005_add_banner.sql`** — `ALTER TABLE theses ADD COLUMN banner_path TEXT`
|
||
- [x] **`storage/schema.sql`** — `banner_path TEXT` in `theses`; `t.banner_path` in view
|
||
|
||
#### 5b — Add form (`public/admin/add.php`)
|
||
|
||
- [x] "Image bannière" file input added after couverture row
|
||
|
||
#### 5c — Add action (`public/admin/actions/formulaire.php`)
|
||
|
||
- [x] Banner upload: MIME check, 5 MB cap, save to `banners/`, call `setBannerPath()`
|
||
|
||
#### 5d — Edit form (`public/admin/edit.php`)
|
||
|
||
- [x] Banner preview img shown; remove_banner checkbox; new banner upload input
|
||
- [x] POST handler: unlinks old file on remove; processes new upload via `setBannerPath()`
|
||
|
||
#### 5e — `src/Database.php`
|
||
|
||
- [x] `banner_path` exposed via view — verified; `getThesisById()` / `getPublishedTheses()` pick it up automatically
|
||
- [x] `setBannerPath(int $thesisId, ?string $path): void` added
|
||
|
||
---
|
||
|
||
### 6 — Home page: gradient placeholder cards & random ordering
|
||
|
||
#### 6a — Gradient placeholder for cards without a banner
|
||
|
||
- [x] **`public/index.php`** — gradient placeholder using HSL hue from thesis ID
|
||
- [x] **`public/assets/main.css`** — `.card__media--gradient`, `.card__gradient-author`,
|
||
`.card__gradient-title` styles added
|
||
|
||
#### 6b — Banner image as card thumbnail
|
||
|
||
- [x] **`public/index.php`** — checks `banner_path` first, falls through to gradient
|
||
|
||
#### 6c — Random ordering from the latest year
|
||
|
||
- [x] **`src/Database.php`** — `getLatestYearTheses(int $limit = 24)` + `getLatestPublishedYear()`
|
||
- [x] **`public/index.php`** — default home view uses random latest-year selection;
|
||
paginated view for `?year=X` and `?page=N`; info label shown
|
||
|
||
---
|
||
|
||
## Refactor: M2M tags via `tags` + `thesis_tags` junction table
|
||
|
||
The current schema stores keywords in a `keywords` table joined via `thesis_keywords`.
|
||
The field column is named `keyword` (not `name`), breaking the naming convention used by
|
||
every other lookup table (`orientations.name`, `format_types.name`, etc.).
|
||
More critically, `buildSearchConditions` and the view `v_theses_full` filter keywords
|
||
through `GROUP_CONCAT` strings with `LIKE`, bypassing the junction table entirely.
|
||
|
||
Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis_tags`,
|
||
`tags.name`), add the missing index, and rewrite all tag queries to use a proper JOIN.
|
||
|
||
### 1 — Schema migration (`storage/schema.sql` + live DB)
|
||
|
||
- [x] Rename table `keywords` → `tags`; column `keyword` → `name`
|
||
- [x] Rename junction `thesis_keywords` → `thesis_tags`; FK `keyword_id` → `tag_id`
|
||
- [x] PK on `thesis_tags(tag_id, thesis_id)`; `idx_tags_name`; updated index names
|
||
- [x] Views `v_theses_full`/`v_theses_public` use `thesis_tags`/`tags.name`
|
||
- [x] Migration `storage/migrations/001_rename_keywords_to_tags.sql` written and applied
|
||
|
||
### 2 — `src/Database.php`
|
||
|
||
- [x] `findOrCreateTag()` added; `findOrCreateKeyword()` is a backwards-compat alias
|
||
- [x] `getUsedTags()` rewritten with proper M2M JOIN; `getUsedKeywords()` alias kept
|
||
- [x] `buildSearchConditions`: keyword/query use `EXISTS` subquery on `thesis_tags`/`tags`
|
||
- [x] All conditions prefixed with `vp.` to match view alias; `vp` alias added to search queries
|
||
|
||
### 3 — Admin write paths
|
||
|
||
- [x] `public/admin/actions/formulaire.php`: uses `findOrCreateTag` + `thesis_tags`
|
||
- [x] `public/admin/edit.php`: `DELETE FROM thesis_tags` + `findOrCreateTag` + `thesis_tags`
|
||
|
||
### 4 — Public read paths
|
||
|
||
- [x] `public/search.php`: fixed `$kw['keyword']` → `$kw['name']` (tag column rename)
|
||
- [x] `getUsedKeywords()` alias delegates to `getUsedTags()` — no functional change needed
|
||
- [x] `public/tfe.php`: `$data['keywords']` still works — view column name unchanged
|
||
- [x] `templates/search-bar.php`: no keyword param refs — verified
|
||
|
||
### 5 — Admin tag management UI (`/admin/tags.php`)
|
||
|
||
#### 5a — `src/Database.php`
|
||
|
||
- [x] `getAllTagsWithCount()`, `renameTag()`, `mergeTag()`, `deleteTag()`
|
||
|
||
#### 5b — `public/admin/tags.php`
|
||
|
||
- [x] Auth guard, CSRF, table with rename/merge/delete per row, inline forms
|
||
|
||
#### 5c — `public/admin/actions/tag.php`
|
||
|
||
- [x] Routes on `$_POST['action']`: rename, merge, delete
|
||
|
||
#### 5d — Nav & routing
|
||
|
||
- [x] `templates/admin/head.php`: "Mots-clés" nav link added
|
||
|
||
#### 5e — Propagation safety
|
||
|
||
- [x] mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK
|
||
|
||
### 6 — Tests
|
||
|
||
- [x] `tests/Unit/DatabaseTest.php`: tests 5–7 cover findOrCreateTag, getUsedTags, alias
|
||
- [x] `tests/Integration/SearchTest.php`: tests 4–6 cover tag-filter subquery, full-text query, count consistency
|
||
|
||
### 6 — Fixtures / seed data
|
||
|
||
- [x] `storage/fixtures/CreateTestDatabase.php`: updated to `tags`/`thesis_tags`/`findOrCreateTag()`
|
||
|
||
---
|
||
|
||
## Feature: Mode Maintenance
|
||
|
||
- [x] Storage flag file `storage/maintenance.flag` (created on demand)
|
||
- [x] Public gate in `config/bootstrap.php` — blocks non-admin routes when flag exists
|
||
- [x] `public/maintenance.php` (503 page, minimal dark UI)
|
||
- [x] `public/admin/actions/maintenance.php` (POST: enable/disable)
|
||
- [x] Admin UI toggle in `public/admin/index.php` (bar with status + action button)
|
||
|
||
---
|
||
|
||
## Feature: TFE Visibility States (publique / interne / interdit)
|
||
|
||
- [x] DB migration `002_add_visibility.sql` — seeds access_types rows (already existed)
|
||
- [x] `src/Database.php` — `setVisibility()`, `bulkSetVisibility()`, `getAccessTypes()`
|
||
- [x] `public/media.php` — blocks thesis files when access_type_id = 3 (Interdit)
|
||
- [x] `public/tfe.php` — shows access type, context_note, hides files for Interdit
|
||
- [x] `public/admin/edit.php` — access_type_id select + context_note textarea
|
||
- [x] `public/admin/index.php` — three-state access badge per row
|
||
- [x] `public/admin/actions/visibility.php` — single + bulk visibility update
|
||
|
||
---
|
||
|
||
## Fixes
|
||
|
||
- [x] Fix `tests/Security/SecurityTest.php`: update SQL injection test to call `searchTheses(['query' => $string])` instead of bare string — `searchTheses()` signature was updated to `array $params` but the test was never updated, causing a fatal `TypeError` that prevented the security suite from running at all
|
||
|
||
## Pending
|
||
|
||
- [x] Add flake.nix for Nix-based PHP dev environment
|
||
- [x] Add favicon (`<link rel="icon">` → admin_favicon.svg) to all pages; nginx 204 for /favicon.ico
|
||
- [x] Remove 100-item cap from répertoire student index: `getAllPublishedTheses()` fetches all published theses; search results remain paginated at 30/page
|
||
- [x] Cover image fallback for home grid cards: batch-load `thesis_files` covers for theses without `banner_path`; resolution order: banner → cover → gradient
|
||
|
||
## Admin / Server
|
||
|
||
- [x] Create `scripts/setup-server.sh` (one-time server setup: group, ownership, setgid 2775 on dirs)
|
||
- [x] Add `just setup-server` recipe (rsync + run setup-server.sh on remote)
|
||
- [x] Exclude `.claude` and `.pi` from rsync deploy
|
||
- [x] Update `docs/SERVER_SETUP.md` with correct permissions rationale and troubleshooting
|
||
- [x] Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
|
||
- [x] Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
|
||
- [x] Add nginx config viewer to admin panel: "nginx — config" tab in `system.php` reads `/etc/nginx/sites-available/posterg` (live, with badge) or falls back to local `nginx/posterg.conf`; line-numbered, syntax-coloured, copy-to-clipboard
|
||
- [x] Add admin user management UI — password change/set for PHP auth layer (`public/admin/account.php` + `actions/account.php`; "Compte" nav link; account CSS)
|
||
- [x] Merge `status.php` and `logs.php` into a single `system.php` page; remove "Statut" and "Journaux" nav links, add single "Système" link; preserve all existing content in their respective sections
|
||
- [x] Rework logs UI: replace the select-then-click-Afficher flow with instant tabs (nginx access, nginx error, php-fpm); switching tabs loads the selected log immediately without a form submit; add a copy-to-clipboard button per log view
|
||
|
||
---
|
||
|
||
## Refactor & Maintenance (backend audit 2026-03-26)
|
||
|
||
### A — SQLite / Query performance
|
||
|
||
- [ ] **WAL mode** — set `PRAGMA journal_mode = WAL` and `PRAGMA synchronous = NORMAL` in `Database::__construct()` after `foreign_keys = ON`.
|
||
Eliminates full-database read-locks on every write; makes concurrent PHP-FPM workers safe.
|
||
Also add `PRAGMA cache_size = -8000` (≈8 MB page cache) while there.
|
||
|
||
- [ ] **Composite index `(is_published, year DESC)`** on `theses` — every public query filters on both.
|
||
Currently `idx_theses_published` and `idx_theses_year` are separate; the query planner picks one
|
||
and sorts the other with a temp B-tree. A single covering index eliminates the sort:
|
||
`CREATE INDEX IF NOT EXISTS idx_theses_pub_year ON theses(is_published, year DESC);`
|
||
Add to `schema.sql` + a migration.
|
||
|
||
- [ ] **`v_theses_full` is always fully materialised** — every query against `v_theses_public` forces
|
||
SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before
|
||
applying the `WHERE`/`LIMIT`. The view cannot be used with an index. Fix: **replace `v_theses_public`
|
||
with a plain table query in `searchTheses` and `getPublishedTheses`** — build the minimal JOIN set
|
||
per query and let indexes filter `theses` first. Keep the views for reporting/admin use only.
|
||
|
||
- [ ] **`getAllPublishedTheses()` in `search.php`** — fetches every published thesis (all columns,
|
||
all JOINs) just to build the student-name index on the Répertoire page. This is a full table
|
||
scan through the fat view. Replace with a dedicated lean query:
|
||
`SELECT id, authors FROM v_theses_public ORDER BY authors ASC` — or add
|
||
`Database::getPublishedAuthors(): array` that queries `thesis_authors JOIN authors` directly,
|
||
avoiding the view entirely.
|
||
|
||
- [ ] **`migration 005` view is stale in the file** — `005_add_banner.sql` recreates the view still
|
||
referencing `thesis_keywords` / `keywords.keyword` (the old pre-migration-001 names).
|
||
The file is already applied to the live DB (correctly, since 001 ran first), but the migration
|
||
file itself is wrong and misleading. Fix: rewrite it to reference `thesis_tags` / `tags.name`,
|
||
or drop the view recreation from it (schema.sql is the canonical source).
|
||
|
||
### B — PHP / Database.php
|
||
|
||
- [ ] **Dead CRUD helpers** — `getOrientationId()`, `getAPProgramId()`, `getFinalityId()`,
|
||
`getLanguageId()`, `getFormatId()` are defined in `Database.php` but **never called** anywhere
|
||
(forms now pass IDs directly from selects). Remove them to reduce surface area.
|
||
|
||
- [ ] **Alias proliferation** — `getOrientations()`/`getAllOrientations()`, `getApPrograms()`/`getAllAPPrograms()`,
|
||
`getFinalityTypes()`/`getAllFinalityTypes()`, `getLanguages()`/`getAllLanguages()`,
|
||
`getFormatTypes()`/`getAllFormatTypes()`, `getLicenseTypes()`/`getAllLicenseTypes()`,
|
||
`findOrCreateKeyword()`, `getUsedKeywords()` — **13 alias methods** pointing at 6 real ones.
|
||
Pick the canonical name for each pair, update all call-sites (there are few), and delete aliases.
|
||
Reduces Database.php from ~945 lines significantly.
|
||
|
||
- [ ] **`getPDO()` / `getConnection()` leaking to callers** — `edit.php`, `formulaire.php`,
|
||
`thanks.php`, `import.php`, `tfe.php`, `index.php`, `media.php`, `system.php` all call
|
||
`$db->getPDO()` or `$db->getConnection()` to run raw queries that belong in `Database.php`.
|
||
Each is a missed encapsulation:
|
||
- `tfe.php`: raw `SELECT access_type_id FROM theses WHERE id = ?` → add `getThesisAccessTypeId(int $id): ?int`
|
||
- `index.php`: raw `SELECT thesis_id, file_path FROM thesis_files WHERE … IN (…)` → add `getCoverPathsForTheses(array $ids): array`
|
||
- `media.php`: raw visibility join → move into `Database::getFileVisibility(string $path): ?int`
|
||
- `edit.php` (line 155): unparameterised `"… WHERE id = $thesisId"` **SQL injection risk** — fix immediately; also move to a DB method
|
||
- `edit.php`: raw `SELECT license_id, access_type_id, context_note FROM theses WHERE id = ?` → expose these via `getThesis()` (already returns `v_theses_full` which has `license_id`)
|
||
- `formulaire.php`: raw identifier-generation query + all junction-table INSERTs → encapsulate in `Database::createThesis(array $data): int`
|
||
|
||
- [ ] **`sanitize_string()` in `formulaire.php` applies `htmlspecialchars` at write time** —
|
||
HTML-escaping belongs at render time (in the template), not at storage time. Storing
|
||
`&` or `<` in the DB means search, export, and any non-HTML consumer sees corrupt data.
|
||
Remove `htmlspecialchars` from `sanitize_string()`; keep only `trim()`. The templates already
|
||
call `htmlspecialchars()` on output.
|
||
|
||
- [ ] **Dead variable `$problematique`** — `formulaire.php` line 84 reads `$_POST["problématique"]`
|
||
into `$problematique` but the value is **never used** (no matching column, no INSERT reference).
|
||
Delete it.
|
||
|
||
- [ ] **`setThesisJury()` not wrapped in a transaction** — the method does a DELETE then multiple
|
||
INSERTs with no transaction guard of its own. If called from outside a transaction (e.g. a
|
||
future API endpoint) a partial failure leaves orphaned rows. Wrap the body in
|
||
`BEGIN … COMMIT / ROLLBACK` (check `$this->pdo->inTransaction()` to avoid nesting).
|
||
|
||
- [ ] **DB config auto-detection is fragile** — `src/config.php` switches to `test.db` whenever the
|
||
file exists locally, which means a developer who ran tests and forgot to delete `test.db` will
|
||
silently hit test data on a local production-mirror. Make the default `prod`; require explicit
|
||
`DB_ENV=test` to use the test database.
|
||
|
||
### C — Code organisation / maintainability
|
||
|
||
- [ ] **`edit.php` does too much** — 530 lines combining form display, POST handling, file upload,
|
||
and all reference-data loading in one file. Extract the POST handler to
|
||
`public/admin/actions/edit.php` (matching the pattern already used by `formulaire.php`,
|
||
`tag.php`, `page.php`, etc.).
|
||
|
||
- [ ] **`formulaire.php` duplicates banner-upload logic verbatim from `edit.php`** — the MIME check,
|
||
size cap, `random_bytes` name, `chmod`, and `setBannerPath()` call are copy-pasted.
|
||
Extract a private `Database`-level helper or a standalone `uploadBanner(array $file): ?string`
|
||
utility function (returns the relative path or null) shared by both action files.
|
||
|
||
- [ ] **Junction-table INSERTs are open-coded in every action** — `formulaire.php` and `edit.php`
|
||
both manually loop `INSERT INTO thesis_languages`, `thesis_formats`, `thesis_tags`.
|
||
Add `Database::setThesisLanguages(int $id, array $ids)`, `setThesisFormats(int $id, array $ids)`,
|
||
`setThesisTags(int $id, array $names)` — following the same delete-then-reinsert pattern
|
||
already used by `setThesisJury()`.
|
||
|
||
- [ ] **`RateLimit` uses per-file JSON on disk** — reads, writes, and `glob()`s the filesystem on
|
||
every public request. For a low-traffic art-school site this is fine, but it creates a
|
||
write-on-every-hit pattern. Consider switching to APCu (if available) or SQLite (single INSERT)
|
||
to avoid filesystem churn. At minimum, move the cache dir to `/tmp` or a dedicated
|
||
`storage/cache/` path that is excluded from deploy rsync.
|
||
|
||
- [ ] **`__wakeup()` singleton guard throws from a public method** — PHP 8.x deprecates
|
||
throwing exceptions from `__wakeup`. Change to `trigger_error(…, E_USER_ERROR)` or implement
|
||
`__serialize()`/`__unserialize()` that always throw.
|
||
|
||
---
|
||
|
||
## Refactor & Maintenance — Templates & Frontend (audit 2026-03-26)
|
||
|
||
### D — Template structure / boilerplate duplication
|
||
|
||
- [ ] **Every public page duplicates its own `<head>`** — `index.php`, `search.php`, `tfe.php`,
|
||
`apropos.php`, `licence.php` each contain an identical block: `<!DOCTYPE html>`,
|
||
`<html lang="fr">`, `<meta charset>`, `<meta viewport>`, `<link rel="icon">`,
|
||
`<link modern-normalize>`, `<link common.css>`, live-reload script. Only `<title>` and one
|
||
extra CSS `<link>` differ. Extract a `templates/public/head.php` partial accepting
|
||
`$pageTitle` and `$extraCss` — mirrors the pattern `templates/admin/head.php` already uses.
|
||
|
||
- [ ] **Live-reload snippet copy-pasted into 6 files** — `index.php`, `search.php`, `tfe.php`,
|
||
`apropos.php`, `licence.php`, `templates/admin/head.php` all contain the same 6-line
|
||
`(function poll(){…})()` block. Consolidate into the shared head partials.
|
||
|
||
- [ ] **`templates/header.php` and `templates/head.php` are dead files** — neither is `include`d
|
||
anywhere in the codebase. Both contain outdated markup from a previous design iteration
|
||
(`lang="en"`, empty author meta, a broken nav with double-quoted `href` attributes inside
|
||
`href`). Delete both to remove confusion.
|
||
|
||
- [ ] **`public/assets/icons.svg` is dead** — it is the full TrumboWYG editor icon sprite (40+
|
||
symbols) referenced **nowhere** in the codebase. The only WYSIWYG editor in use (EasyMDE
|
||
in `pages-edit.php`) loads from CDN. Delete `icons.svg` (~15 KB of noise).
|
||
|
||
- [ ] **`admin_favicon.svg` used as the public-facing favicon** — every public page links
|
||
`/assets/admin_favicon.svg`. Rename or create a distinct `favicon.svg` so admin and public
|
||
can diverge without naming confusion.
|
||
|
||
### E — CSS architecture
|
||
|
||
- [ ] **`html, body { margin:0; padding:0; height:100% }` repeated in 4 page stylesheets** —
|
||
`main.css`, `search.css`, `tfe.css`, `apropos.css` all open with this identical block.
|
||
Move it to `common.css` once; delete from the four files. Same for the body-level flex-column
|
||
shell (`display:flex; flex-direction:column; background:var(--white)`) which only differs in
|
||
the BEM class name applied to `<body>`.
|
||
|
||
- [ ] **No `font-display` on the `Combinedd.otf` custom font** — `common.css` declares `@font-face`
|
||
with no `font-display` property; the browser blocks text rendering until the font loads (FOIT).
|
||
Add `font-display: swap`. Also add a `<link rel="preload">` for the font file in the shared
|
||
head partial once it exists.
|
||
|
||
- [ ] **Search results pagination is fully inline-styled** — `search.php` lines 159–164 apply
|
||
`style="padding:.25rem .7rem;border:1px solid #ddd;…"` and hardcoded `#ddd`/`#666`. The home
|
||
page (`index.php`) already has `.pagination-btn` / `.pagination-info` in `main.css`. Reuse
|
||
those classes in `search.php` and remove the inline styles.
|
||
|
||
- [ ] **Scattered inline styles in templates** — notable instances that should become named classes:
|
||
- `tfe.php` line 146: `style="align-items:start;"` → `.tfe-meta-item--top` in `tfe.css`
|
||
- `tfe.php` lines 148, 170–172, 193: `font-style:italic`, `margin-top:1.5rem`,
|
||
`font-size:.88rem;color:#666`, `color:#999;font-style:italic` → `.tfe-note-value`,
|
||
`.tfe-back-link`, `.tfe-restricted` in `tfe.css`
|
||
- `admin/edit.php`: multiple `style=` on `.admin-form-row` and banner preview → modifier
|
||
classes in `admin.css`
|
||
- `index.php` line 146: `style="padding:2rem;color:#666;"` → `.cards-empty` in `main.css`
|
||
|
||
- [ ] **`.site-nav__right` is a duplicate of `.site-nav__link`** — `common.css` defines both with
|
||
identical declarations (font-size, letter-spacing, text-transform, color, opacity, transition).
|
||
The only difference is DOM position. Merge `.site-nav__right` into `.site-nav__link`; let the
|
||
flex layout position it via `margin-left:auto` or DOM order.
|
||
|
||
- [ ] **`.site-nav__link--active` is applied in `nav.php` but never defined in CSS** — the class
|
||
is set conditionally but has no corresponding rule in `common.css`, so the active state is
|
||
invisible. Add a visible style (e.g. `opacity:1; border-bottom:1px solid rgba(255,255,255,.6)`)
|
||
or remove the conditional.
|
||
|
||
### F — Template logic / PHP in templates
|
||
|
||
- [ ] **Rate-limit 429 response in `search.php` emits unstyled bare HTML** — the early-exit block
|
||
outputs `<!DOCTYPE html><html><body><h1>Trop de requêtes</h1>…` with no stylesheet, no lang,
|
||
no viewport meta. Style it inline-minimally or redirect to a consistent `429.php` page (like
|
||
`maintenance.php`).
|
||
|
||
- [ ] **`apropos.php` contacts and credits are hardcoded in the template** — names, roles, emails
|
||
(Laurent Leprince, Xavier Gorgol, Brigitte Ledune) and credits text live in PHP/HTML and
|
||
require a code deploy to change. Either move them into the `about` page Markdown (admin-
|
||
editable) or extract to a config array so they are in one place.
|
||
|
||
- [ ] **`licence.php` wastes half the viewport with an always-empty right column** — the page
|
||
reuses the two-column `.apropos-layout` but `<div class="apropos-right"></div>` is always
|
||
empty. Add a `.apropos-layout--single` variant (or just `grid-template-columns:1fr` when
|
||
the right child is empty) to use the full width for content.
|
||
|
||
### G — Accessibility & semantics
|
||
|
||
- [ ] **`<nav>` in `nav.php` has no `aria-label`** — pages have multiple landmark regions (main
|
||
nav, search `<form>`, pagination). Add `aria-label="Navigation principale"` to the `<nav>`
|
||
and `aria-label="Pagination"` to pagination wrappers so screen readers can distinguish them.
|
||
|
||
- [ ] **Search bar `<form>` has no accessible name** — `search-bar.php` has no `aria-label` on the
|
||
`<form>` and no `<label>` for the input (only a placeholder). Add `aria-label="Recherche"` to
|
||
the `<form>` element.
|
||
|
||
- [ ] **No `<meta name="description">` on any public page** — all public pages omit the description
|
||
meta tag (the dead `templates/head.php` had `content=""`). Add per-page descriptions:
|
||
site blurb for `index.php`, synopsis excerpt for `tfe.php`, page content intro for
|
||
`apropos.php`/`licence.php`. Necessary for search indexing and link preview cards.
|
||
|
||
- [ ] **No Open Graph tags** — `tfe.php` is the ideal candidate for `og:title`, `og:description`
|
||
(synopsis), `og:image` (banner or cover path through `/media.php`), `og:type=article`.
|
||
Without them, sharing a thesis link on social media or messaging apps shows a blank preview.
|
||
|
||
### H — Minor / low-hanging fruit
|
||
|
||
- [ ] **`admin/thanks.php` duplicates `getThesisFiles()` with a raw PDO query** — lines 34–40
|
||
manually prepare `SELECT … FROM thesis_files WHERE thesis_id = ?` instead of calling
|
||
`$db->getThesisFiles($thesisId)` which already exists. Replace with the DB method.
|
||
|
||
- [ ] **`admin/index.php` stats computed via PHP `array_filter` on full result set** — "total",
|
||
"publiés", "en attente" counts are derived by filtering the already-fetched `$theses` array
|
||
in PHP. When a filter is active the stats reflect only filtered rows, which is misleading.
|
||
Add `Database::getThesesStats(): array` returning three counts from SQL
|
||
(`COUNT(*)`, `SUM(is_published)`, `SUM(1-is_published)`) so they always reflect the full DB.
|
||
|
||
---
|
||
|
||
## Semantic HTML audit (2026-03-26)
|
||
|
||
Goal: replace presentational wrappers with the element that already carries the correct meaning,
|
||
removing classes where the element name itself is sufficient as a CSS selector.
|
||
The design does **not** need to change — only the vocabulary of the markup.
|
||
|
||
### I — `templates/nav.php` & `templates/search-bar.php`
|
||
|
||
- [ ] **`<div class="site-nav__links">`** wraps the centre nav links purely for flex grouping.
|
||
Replace with `<ul>` + `<li>` children (links inside a nav belong in a list — standard
|
||
pattern). The `<a>` elements stay; CSS targets `nav ul` / `nav li` / `nav a` directly,
|
||
removing `.site-nav__links`, `.site-nav__link`, `.site-nav__right` classes entirely.
|
||
`nav a[aria-current="page"]` replaces the missing `.site-nav__link--active` rule and is
|
||
self-documenting.
|
||
|
||
- [ ] **`<form class="site-search">`** is already a `<form>` — good. Add
|
||
`role="search"` and `aria-label="Recherche"`. The SVG icon should get
|
||
`aria-hidden="true"` (it's decorative). The `<input>` should have an associated
|
||
`<label>` (visually hidden via `.sr-only` is fine, or `aria-label` on the input).
|
||
|
||
### II — `public/index.php`
|
||
|
||
- [ ] **`<div class="filter-info">`** is a status/notice banner. Use `<p role="status">` or
|
||
`<output>` — both carry live-region semantics for screen readers without extra ARIA.
|
||
|
||
- [ ] **`<div class="cards-container">`** is a list of navigable items. Replace with `<ul>` —
|
||
removing the wrapper div and making each card an `<li>`. `.cards-container` → target `main > ul`
|
||
or a single class on `<ul>`.
|
||
|
||
- [ ] **`<a class="card-link"><div class="card">…</div></a>`** — the outer `<a>` wrapping a `<div>`
|
||
makes the div redundant. The `<a>` is already a block element (set `display:block`). The
|
||
`.card` div can be removed; CSS targets `ul li a` directly. The `<li>` inside the `<ul>`
|
||
becomes the card container.
|
||
|
||
- [ ] **`<div class="card__media">`** — this is the image/media wrapper inside each card.
|
||
When it contains an `<img>`, use `<figure>` (a self-contained media unit). When it shows
|
||
the gradient placeholder (no real image), a plain `<div>` is fine since it's presentational.
|
||
|
||
- [ ] **`<div class="card__info"><p class="authors">…</p></div>`** — the `.card__info` wrapper
|
||
exists only to add padding. Move the padding to the `<p>` or `<li>` directly; remove the
|
||
div. The `<p>` stays. `.authors` class → either keep it or target `li > p`.
|
||
|
||
- [ ] **`<div class="pagination-wrap">`** with `<a class="pagination-btn">` and
|
||
`<span class="pagination-info">` — replace with `<nav aria-label="Pagination"><ul>…</ul></nav>`.
|
||
Each button becomes an `<li>`. The disabled state uses `aria-disabled="true"` +
|
||
`tabindex="-1"` instead of a `.disabled` class alone (which has no keyboard semantics).
|
||
`<span class="pagination-info">` → `<li aria-current="page">1 / 5</li>`.
|
||
|
||
### III — `public/search.php`
|
||
|
||
- [ ] **`<div class="search-filter-group">`** wraps each label+select pair. Replace with
|
||
`<label>` directly wrapping `<select>` — one element instead of two, and the label/control
|
||
association is implicit. Remove `.search-filter-group` and `.search-filter-label` (the
|
||
`<label>` element is the label). CSS targets `form label` and `form select`.
|
||
|
||
- [ ] **`<span class="search-filter-label">`** inside the filter group — deleted once the `<label>`
|
||
approach is taken (see above).
|
||
|
||
- [ ] **`<div class="search-results-view">`** is unnecessary nesting inside `<main>`. `<main>` is
|
||
already the landmark. Remove the wrapper; apply padding directly to `<main>` or its direct
|
||
children.
|
||
|
||
- [ ] **`<div class="results-grid">`** is a list of search results. Replace with `<ul class="results-grid">`.
|
||
Each `<a class="result-card">` becomes a `<li><a>` — the link text is made up of child `<span>`s
|
||
which is correct. However `.result-card__authors` and `.result-card__title` `<span>`s would be
|
||
better as `<strong>` (author, emphasis) and the title as plain text or `<span>`. The year/meta
|
||
`<span class="result-card__meta">` → `<small>` (ancillary metadata).
|
||
|
||
- [ ] **Répertoire index: `<div class="repertoire-index">`** — replace with `<div>` kept but its
|
||
four children are semantic candidates: each `.repertoire-col` is an independent index with a
|
||
heading. Replace `<div class="repertoire-col">` with `<section>`. The heading
|
||
(`<h2 class="repertoire-col__header">`) is already correct — `<h2>` is right. Remove
|
||
`.repertoire-col__header`; CSS targets `section > h2` scoped inside `.repertoire-index`.
|
||
|
||
- [ ] **`.year-index-item`, `.cat-index-item`, `.student-index-item`, `.keyword-index-item`** — all
|
||
four are sequences of `<a>` links with `display:block`. They are lists. Wrap each group in
|
||
`<ul>`; each link becomes `<li><a>`. The four custom classes collapse to a single `ul a`
|
||
selector per column (or no class at all, scoped via `section`). The `.active` class on links
|
||
→ `aria-current="page"` on the `<a>`.
|
||
|
||
- [ ] **`<p class="search-results-header">`** count line — remove `.search-results-header`; this is
|
||
a plain `<p>` styled with `.search-main p:first-child` or just keep a lightweight class. Or use
|
||
`<output>` since it is a computed result count.
|
||
|
||
### IV — `public/tfe.php`
|
||
|
||
- [ ] **`<div class="tfe-layout">`** — the two-column grid container. Replace with `<article>` —
|
||
a single thesis is genuinely a self-contained piece of content. Remove `.tfe-layout`;
|
||
CSS targets `main > article` for the grid. Remove `.tfe-main`; CSS targets `main` directly.
|
||
|
||
- [ ] **`<div class="tfe-left">`** — the info/metadata column. Replace with `<header>` of the
|
||
article (it contains the author name, title, and all metadata — the article header). Or
|
||
simply remove and target `article > :first-child` if that is too strong. Actually
|
||
`<header>` is semantically correct here: it is the identifying header of the article.
|
||
|
||
- [ ] **`<div class="tfe-meta-list">`** with `<div class="tfe-meta-item"><span class="label">…</span><span class="value">…</span></div>` — this is
|
||
a description list by definition. Replace with `<dl>` / `<dt>` / `<dd>`:
|
||
- `<dl class="tfe-meta-list">` → just `<dl>` (class optional)
|
||
- `<div class="tfe-meta-item">` → remove; `<dt>`+`<dd>` are direct children of `<dl>`
|
||
(or grouped in `<div>` inside `<dl>` which is valid HTML — the spec allows it for styling)
|
||
- `<span class="label">` → `<dt>`
|
||
- `<span class="value">` → `<dd>`
|
||
- CSS: `.tfe-meta-list` → `dl`; `.label` → `dl dt`; `.value` → `dl dd`
|
||
This removes ~5 classes and ~30 wrapper divs from the metadata section.
|
||
|
||
- [ ] **`<div class="tfe-synopsis-text">`** — the synopsis paragraph(s). Replace with `<p>` (or
|
||
keep as `<section class="synopsis">` if multi-paragraph, but a single `<p>` suffices for
|
||
most cases). Remove the wrapper div.
|
||
|
||
- [ ] **`<div style="margin-top:1.5rem;"><a href="index.php" style="…">← Retour</a></div>`** —
|
||
remove the wrapper div; move margin to the `<a>` itself as a class. The back link is
|
||
better as `<a rel="up" href="index.php" class="back-link">← Retour</a>` (no wrapper needed).
|
||
|
||
- [ ] **`<div class="tfe-right">`** — the media column. Replace with `<aside>` — it contains
|
||
supplementary files (media, PDFs) that are related but secondary to the descriptive content.
|
||
Remove `.tfe-right`; CSS targets `article > aside`.
|
||
|
||
- [ ] **`<div class="tfe-media-block">`** — each file display unit. Replace with `<figure>`.
|
||
Image and video files become `<figure><img></figure>` and `<figure><video></video></figure>`.
|
||
The existing `<p class="tfe-file-caption">` → `<figcaption>`. PDF `<embed>` stays in a
|
||
`<figure>` (valid). Remove `.tfe-media-block`; CSS targets `aside figure`.
|
||
|
||
- [ ] **`<h1 class="tfe-author">` and `<h2 class="tfe-title">`** — the heading hierarchy makes
|
||
the author the primary heading and the title secondary, which is backwards semantically.
|
||
The *title* of the work is the `<h1>`; the *author* is metadata (could be a `<p>` or a `<dt>`
|
||
in the `<dl>` above). Swap: `<h1>` = title, author moves into the `<dl>`. Keeps the visual
|
||
design (CSS controls size) but fixes the document outline.
|
||
|
||
### V — `public/apropos.php`
|
||
|
||
- [ ] **`<div class="apropos-layout">`** — two-column grid. Replace with `<div>` kept but the
|
||
children are semantic: left is the main content, right is supplementary.
|
||
Left `<div class="apropos-left">` → remove (redundant wrapper around already-styled content).
|
||
Right `<div class="apropos-right">` → `<aside>` (contacts, credits = supplementary info).
|
||
|
||
- [ ] **`<div class="apropos-description apropos-page-content">`** inside the left col —
|
||
the Parsedown output already generates `<p>`, `<h1>`–`<h3>`, `<ul>` etc.
|
||
The wrapping `<div>` is only needed for the `.apropos-page-content` scoped CSS rules.
|
||
Keep it but as a single class — `<div class="prose">` — and scope all Markdown content
|
||
styles under `.prose`. This is the standard prose-container pattern.
|
||
|
||
- [ ] **`<div class="apropos-contact">`** — each contact entry. Replace with `<address>`:
|
||
the HTML spec defines `<address>` for contact information related to the document or section.
|
||
Each contact is literally an address entry. `<span class="apropos-contact-name">` →
|
||
`<strong>`, `<span class="apropos-contact-role">` → plain text or `<span>`,
|
||
`<span class="apropos-contact-email">` → `<a href="mailto:…">`. Three classes removed.
|
||
|
||
- [ ] **Outer `<div>` wrappers around each section in `.apropos-right`** (`<div><h2>…</h2></div>`,
|
||
`<div><h2>Contacts</h2>…</div>`, `<div><h2>Crédits</h2>…</div>`) — replace each with
|
||
`<section>`. Remove the anonymous `<div>` wrappers; CSS targets `aside section > h2`.
|
||
|
||
### VI — `public/licence.php`
|
||
|
||
- [ ] **`<div class="apropos-right"></div>`** — always-empty right column. Remove entirely; the
|
||
`licence.php` page is full-width content. Update `licence.php` to not use `.apropos-layout`
|
||
at all — just `<main class="apropos-main"><div class="prose">…</div></main>`.
|
||
No class changes needed to `apropos.css`; the layout simply is not applied.
|
||
|
||
### VII — Summary of class deletions enabled by semantic changes
|
||
|
||
Once the above is applied, the following classes become deletable (element name carries the meaning):
|
||
|
||
| Class removed | Replaced by |
|
||
|---|---|
|
||
| `.site-nav__links` | `nav ul` |
|
||
| `.site-nav__link` | `nav li a` |
|
||
| `.site-nav__right` | `nav li:last-child a` (or `[aria-label]` target) |
|
||
| `.site-nav__link--active` | `[aria-current="page"]` |
|
||
| `.card-link` | `ul li a` (block `<a>` inside `<li>`) |
|
||
| `.card` | `ul li` |
|
||
| `.tfe-layout` | `main > article` |
|
||
| `.tfe-left` | `article > header` |
|
||
| `.tfe-right` | `article > aside` |
|
||
| `.tfe-meta-list` | `dl` |
|
||
| `.tfe-meta-item` | `div` inside `dl` (or removed) |
|
||
| `.label` / `.value` | `dt` / `dd` |
|
||
| `.tfe-media-block` | `figure` |
|
||
| `.tfe-file-caption` | `figcaption` |
|
||
| `.tfe-synopsis-text` | `p` (direct child of `article > header`) |
|
||
| `.search-filter-label` | `label` |
|
||
| `.search-filter-group` | `label` (wrapping approach) |
|
||
| `.repertoire-col` | `section` |
|
||
| `.repertoire-col__header` | `section > h2` |
|
||
| `.year-index-item` etc. | `ul a` (scoped per section) |
|
||
| `.result-card__meta` | `small` |
|
||
| `.results-grid` | `ul.results-grid` (only class needed) |
|
||
| `.apropos-left` | removed (direct child of grid) |
|
||
| `.apropos-right` | `aside` |
|
||
| `.apropos-contact` | `address` |
|
||
| `.apropos-contact-name` | `strong` inside `address` |
|
||
| `.apropos-contact-email` | `a[href^="mailto:"]` inside `address` |
|
||
| `.apropos-description apropos-page-content` | `.prose` (single class) |
|