Files
thalim-stack/CLAUDE.md
2026-05-12 23:32:48 +02:00

210 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
Guidance pour Claude Code sur ce dépôt.
## Project overview
Site WordPress du laboratoire THALIM (Théorie et Histoire des Arts et des Littératures de la Modernité). Stack Docker + thème Timber/Twig customisé + deux plugins maison (HAL importer, Newsletter). Le site est bilingue FR/EN via un système maison (Polylang a été retiré).
## Dev environment
```bash
docker-compose up -d # WordPress, MySQL, phpMyAdmin
docker-compose down
```
- WordPress : http://localhost:8020
- phpMyAdmin : http://localhost:8021
- MySQL : `localhost:3307``db:3306`
- Identifiants dans `.env`
- WordPress monté depuis `./wp-data/`
## Architecture
### Thème : `wp-data/wp-content/themes/thalim/`
Timber/Twig. Chaque `*.php` charge un Twig de `templates/`. `base.twig` est le layout, les autres l'étendent.
- `functions.php` — gros (≈1400 lignes) : setup, i18n, contexte Twig, AJAX, filtres de requête, customisations admin
- `inc/` — helpers PHP par contexte (voir détails plus bas)
- `templates/` + `templates/partials/` — Twig
- `scss/``css/` — SASS compilé **manuellement** par l'utilisateur (CSS commité)
- `js/` — scripts frontend et `adminDashboardMods.js` (admin)
- `vendor/` — Composer (Timber 2.x). `composer install` après clone
### Plugins maison
- **`thalim-hal-importer/`** — import publications HAL (structure 254015). Admin : Outils → HAL Import. Voir le README du plugin pour le mapping doc types → catégories (10 types : ART, COUV, OUV, COMM, ISSUE, PROCEEDINGS, THESE, HDR, SON, VIDEO).
- **`thalim-newsletter/`** — composition et export HTML des digests mensuels. Admin : Outils → Newsletter. Voir le README du plugin pour les constantes de catégories.
### Plugins WP requis
- **Pods** — types de contenu + champs personnalisés (tout est en Pods)
- **Members** — gestion fine des rôles/capacités
- **Simple Local Avatars** — avatars uploadés (vs Gravatar)
- **Relevanssi** — recherche
## Multilingue (système maison)
Polylang a été **remplacé** par un système custom. Toute la logique vit dans `functions.php`.
- **Détection** : `thalim_current_language()` retourne `'en'` si `THALIM_ORIGINAL_URI` commence par `/en/`, sinon `'fr'`. Le préfixe `/en/` est strippé via `do_parse_request` avant la résolution d'URL WP, et `redirect_canonical` est désactivé sur les URLs `/en/` pour éviter que WP ne supprime le préfixe
- **Champs texte bilingues** : convention `"FR // EN"` dans un même champ. `thalim_bilingual($value, $lang)` (exposé comme filtre Twig `|bilingual`) renvoie la bonne moitié. Utilisé partout : titres de posts, noms de termes de taxonomie, sous-titres, lieux, fonctions, types, etc.
- **Champs longs séparés** : pour le corps des posts, c'est un champ Pods `body_en` à part. Idem pour `biographie_en`, `presentation_en`, `recherches_en_cours_en`, `resume_de_la_these_en`, `autres_domaines_de_recherches_en` sur les utilisateurs, et `presentation_en` / `presentation_detail_en` sur le post `contenu_general`
- **URLs préfixées** : `thalim_en_url($url)` ajoute `/en` aux URLs internes en mode EN, attaché à `term_link`, `post_link`, `page_link`, `post_type_link`, `author_link`. Filtre Twig `|en_url` aussi disponible. No-op en admin (URI commence par `/wp-admin/`)
- **Noms de catégories** : `thalim_cat_name($cat)` (filtre Twig `|cat_name`) lit le term meta `titre_anglais` en mode EN. Aussi appliqué au title de l'onglet navigateur via `document_title_parts`
- **Menus** : slugs `Navigation` / `Navigation-en` et `Footer` / `Footer-en` (pas `menu-principal` — c'est un legacy obsolète)
- **Contenu général** : un seul post de type `contenu_general` fournit les blocs UMR/thalim/siècles/présentation au layout via `gc` dans le contexte Twig
- **Sélecteur de langue** : `thalim_language_switcher()` retourne `['fr' => ['slug','url','current_lang'], 'en' => …]` — remplace `pll_the_languages()`
- **Override pour AJAX** : poser `$GLOBALS['thalim_lang_override'] = $lang` avant `thalim_current_language()` (utilisé par les handlers `load_more_posts` / `load_more_agenda` qui reçoivent la langue en POST)
## Dates et tri par événement
Les annonces ont une `date_de_debut` / `date_de_fin` (champ date Pods) ou un `datetime` (communications). Les listes et l'agenda doivent **prioriser ces dates sur `post_date`**.
- **Activation** : ajouter `'thalim_event_date_order' => true` aux args d'un `WP_Query` (ou Timber)
- **Filtre de plage** : `'thalim_event_date_filter' => ['from' => 'YYYY-MM-DD', 'to' => 'YYYY-MM-DD']`
- **Implémentation** : filtres `posts_join` + `posts_orderby` + `posts_where` dans `functions.php`. LEFT JOIN sur `postmeta` puis `CASE WHEN date_de_debut ELSE datetime ELSE post_date END DESC`. Les valeurs `0000-00-00...` sont traitées comme NULL
- **Format d'affichage** : `thalim_format_date($raw, $lang)` dans `inc/single-helpers.php` renvoie `date_i18n('j F Y', $ts)`. Les abréviations de mois (3-lettres) pour les vignettes agenda sont codées en dur dans `functions.php` (`thalim_get_agenda_card_data()`) et dans `inc/single-helpers.php` (séances)
- **Construction du `date_label`** : la logique « Le X de H1 à H2 / Du X au Y / Jusqu'au X / X à H » vit dans `thalim_get_agenda_card_data()` — à dupliquer prudemment si besoin ailleurs
## Helpers PHP (`inc/`)
| Fichier | Rôle |
|---|---|
| `single-helpers.php` | `thalim_get_single_data($post_id)` résout tous les champs Pods d'un post en tableau prêt pour Twig : dates formatées, images vs documents (split par mime-type), membres résolus en `{name, url}`, taxonomies (axes, étiquettes, programmes), séances triées en `seances_a_venir` / `seances_passees`, hiérarchie de catégorie, `type_label` et `fonction_label` dérivés des champs `type_*` / `fonction_*` ou (legacy) du `_pods_categorie`, liens externes (13), Canal-U / YouTube embeds. Inclut aussi `thalim_format_date()` (partagée). |
| `author-helpers.php` | `thalim_get_author_data($user_id)` (profil membre) et `thalim_get_author_posts_by_category($user_id)` (posts liés au membre, groupés par catégorie primaire, plus un groupe spécial « séances de séminaire » via cat 12). Tri inter-groupes par `ordre_profil` (term meta) puis par nombre de posts |
| `membres-helpers.php` | Page `/membres` : `thalim_get_membres_groups()` regroupe par slugs de rôle taxonomy (mapping codé en dur dans `$group_definitions` — voir « Page membres » plus bas) |
| `post-card-helpers.php` | `thalim_get_card_data($post_id)` / `thalim_get_cards_data($posts)` : données pour `partials/post-card.twig`. Inclut la résolution catégorie parente pour le code couleur (`parent_slug`), la première image (medium), la `card_event_date`, et la redirection `#seance-{ID}` pour les séances de séminaire |
| `pods-conditional-required.php` | Patch : Pods n'évalue pas sa propre logique conditionnelle côté serveur lors de la validation des champs *required*. On la rejoue dans `pods_api_pre_save_pod_item_post` et on désactive `required` sur les champs masqués |
| `pods-save-error-handler.php` | Quand Pods déclenche un `wp_die()` à la sauvegarde admin, intercepter (`pods_error_die`), stocker tous les `pods_meta_*` dans un transient, rediriger vers `post.php?action=edit`, annuler le statut si le post passait à `publish`, et restaurer les champs côté React via `get_post_metadata` + JS dans `admin_footer`. **Mécanisme partagé** avec : |
| `post-title-required.php` | Force un titre non-vide à la sauvegarde, réutilise le même mécanisme transient/restore |
| `admin-users-filter.php` | Dropdown « Statut » sur `users.php`, filtre via meta `role_1`/`role_2`/`role_3` |
### Avatars (chaîne de fallback)
`thalim_get_user_avatar_url($user_id)` dans `functions.php` :
1. Simple Local Avatar (`simple_local_avatar` user meta) — résolu via `media_id` quand dispo (pour survivre aux changements de domaine), sinon URL réécrite
2. Gravatar (HEAD request avec `d=404`) — résultat (positif ou négatif) caché 1 semaine en transient `thalim_gravatar_{ID}`
3. Chaîne vide → templates fallback initiales
## Pages spécifiques
### Page Membres (`page-membres.php`)
Pilotée par `inc/membres-helpers.php`.
- Groupes basés sur les **slugs** de la taxonomy `role` (pas les term IDs — survit aux migrations)
- Groupe « Direction » en tête, ordre fixe : `directeur` puis `directeur_adjoint`, lus depuis les post meta de la page `le-laboratoire`
- Tri intra-groupe alphabétique sur le nom de famille via `Collator('fr_FR')` à `PRIMARY` (insensible accents/casse). Pour les noms composés (« Duclaux de l'Estoile »), `thalim_get_sort_key()` prend le premier mot du meta `last_name`
- Cas spécial « Personnel d'accompagnement à la recherche » : les statuts contenant « Gestion et pilotage » remontent en tête
- Chaque `<tr>` porte `data-name`, `data-status`, `data-affiliation`, `data-roles` (séparés par `|`), `data-avatar`, `data-domaines`, `data-autres-domaines` — consommés par `membresFilters.js` (filtre rôle + recherche texte sur name/status/affiliation, tri colonne), `membresPopover.js` (popover au hover), `seanceToggle.js` (collapse de groupe)
- **Zébrage** : classe `.is-even-row` posée en JS après chaque filtre/tri — pas `nth-of-type`, qui compte les lignes masquées et casse l'alternance
### Single post
`single-helpers.php` fait quasi tout le travail. À noter :
- Hiérarchie de catégorie : `wp_get_post_categories()` → exclut cat 12 (séance) et 31, prend les ancêtres pour `parent_slug` (color theme) et `parent_name`
- Posts en catégorie racine sans sous-catégorie → leur lien vers la liste devient `/category/{slug}/autres/` (rewrite rule dédiée)
- `documents_joints` (Pods relationship file) est splitté par mime-type en `images` (taille `large`, marqué `portrait` si `h > w`) et `documents`. Les captions et titres passent par `|bilingual`
- Toggle « afficher le titre des images en légende » : meta `afficher_le_titre_des_images_en_legende`
- Séances de séminaire (`seances` meta = array de post IDs) sont **redirigées via `template_redirect`** vers le parent séminaire avec ancre `#seance-{ID}`. Les helpers post-card et agenda-card font la même substitution pour les liens
### Front page
`is_front_page()` charge : logo animé (`animatedLogo.js`, aussi sur 404), hero avec mots colorés (`coloredWordsHero.js`), Swiper d'annonces (`annoncesSwiper.js`), `messageLabo.js`, nuage de mots-clés (`keywordCloud.js`, exclut les tags marqués `ne_pas_afficher_dans_le_nuage`), `quickLinks.js`. Tags localisés dans `thalimTags` (window) via `wp_localize_script`.
### Catégorie / archives / agenda
`is_category()` charge Swiper + `agendaView.js` (slider d'événements à venir, scroll horizontal centré sur aujourd'hui via `today_offset` renvoyé par l'AJAX).
Toutes les pages d'archives (catégorie, taxonomie, tag, `/annonces`, search) chargent :
- `infiniteScroll.js` — AJAX paginé via `wp_ajax_load_more_posts`. Params POST : `page`, `category`, `axe`, `date_from`/`date_to`, `taxonomy`/`term`, `filter_cat`/`filter_autres`, `exclude_cats`, `search`, `lang`. Recherche passe par Relevanssi (`'relevanssi' => true`, `orderby => 'relevance'`)
- `categoryFilters.js` — UI filtres
Sur les pages de catégorie, les **posts épinglés** sont sortis du flux principal (`epingler_dans_la_categorie` = `1` ET (`date_de_fin_depinglage` vide OU ≥ aujourd'hui)) — affichés en tête, exclus de la pagination AJAX via `post__not_in`.
Sur les pages de taxonomie/tag, les **séances de séminaire (cat 12)** sont exclues : on liste les posts taggés, pas leurs séances.
## Restrictions / sécurité contenus
- **Vie du labo (cat 9)** : exclue des requêtes pour les non-connectés via `pre_get_posts`, redirige vers `/` si on visite la page de catégorie, retirée du menu via `wp_nav_menu_objects`
- **Contributeurs** :
- Liste admin filtrée : posts qu'ils ont écrits **OU** où ils sont dans `membres`/`autre_membres` (filtre `pre_get_posts` + override de `wp_count_posts` pour des compteurs cohérents)
- Peuvent **éditer** les posts où ils figurent comme membre (filtre `user_has_cap` — accorde les caps primitives le temps du check, sans modifier la table des rôles). Gère le flow save : capture `post_ID` depuis `$_POST` quand WP teste `edit_others_posts` sans `$args[2]`
- Login redirige vers `edit.php` (pas le dashboard). Dashboard et menu Outils masqués pour les non-admins
- **Filtre catégorie admin** : par défaut WP inclut les sous-catégories, on force `include_children => false` (`thalim_exact_category_filter`)
- **Axes filtres** : pour les contributeurs, restreints au premier groupe (le plus récent) via `thalim_get_axes_filter_groups()` slice
## Customisations admin
`js/adminDashboardMods.js` (≈900 lignes) + `css/admin.css`. L'admin est très modifié.
### Patterns transversaux
- **Cacher des éléments** : `css/admin.css` avec `display: none !important`. Pour les boutons TinyMCE, **sélecteurs stables uniquement** (`aria-label`, `.mce-i-bold`, `.mce-i-italic`) — jamais `#mceu_*` qui dépendent du nombre d'éditeurs sur la page
- **Force du mode visuel** : filtre PHP `user_can_richedit``true` (sinon les users avec « désactiver l'éditeur visuel » dans leur profil sont bloqués en mode code, car nos CSS masquent les onglets Visual/Code). `ensureVisualMode(editorId)` (JS) retry jusqu'à 15× pour passer de `html-active` à `tmce-active`
- **TinyMCE sur champs conditionnels** : un éditeur initialisé dans un élément `display:none` a son iframe cassée (vide, non interactive). `reinitEditor(editorId)` détruit et recrée l'instance quand le champ devient visible. Utilisé pour `body_en` (au premier clic onglet EN) et pour `reference_bibliographique` (via MutationObserver sur le `style` de la ligne Pods)
- **Rename « Article » → « Annonce »** : `renameArticlesToAnnonces()` parcourt les nœuds texte des éléments connus (menu, h1, notices, submitbox, screen options, search) plus l'admin bar (côté PHP). Réplique de plusieurs casses
### Page d'édition de post
`initPostEditPage()` orchestre :
- Désactivation d'options de catégorie (`1`, `12`, `5`, `20`) dans le select Pods
- Réordonnancement des metaboxes : `type-dannonce` en haut du `#side-sortables`, `affichage-sur-laccueil` et `thematique` en bas, `champs-contextuels` en haut du `#normal-sortables`, `documents-joints` juste après, `submitdiv` en bas
- **Onglets FR/EN sur le corps** : barre de tabs injectée avant `#postdivrich`. La metabox `#pods-meta-body-en` est déplacée juste après l'éditeur natif (le déplacement DOM casse son iframe TinyMCE — réparé par `reinitEditor()` au premier clic EN). Phase 1 = DOM avant `t=100ms`, Phase 2 = wiring après
- **Groupement des axes thématiques** : la liste des cases à cocher est regroupée par période (label = `annee_debut annee_fin` ou « Axes antérieurs »), via `groupAxesCheckboxes()`. Données passées en `thalimAxesGroups` via `wp_localize_script`. Re-grouping déclenché si la ligne devient visible (MutationObserver)
- **Visibilité conditionnelle des Pods boxes** : `updatePostboxVisibility()` cache une `.postbox` Pods si tous ses `<tr>` ont `display:none` (sauf `body-en` qui est piloté par les onglets). Re-déclenché au changement de catégorie
- **Séparateur dans la grille Membres** : `<tr class="membres-grid-separator">` injecté en fin de tbody, affiché uniquement si une ligne `autre_*` est visible
- **Popover date picker Gutenberg** : le composant Popover de Gutenberg se ferme sur outside-click via `focusout`, mais si le focus n'y entre jamais, il reste ouvert. `initDatePickerPopoverFix()` force le focus dès que `.components-popover__content` apparaît dans le DOM
- **Info-popovers** (`INFO_TIPS` array) : boutons d'aide attachés à des sélecteurs spécifiques. Type `info` (cercle, neutre) ou `translate` (globe, vert) pour rappeler la convention `FR // EN`. Champ `page` (`post` | `user` | `taxonomy`) limite le scope
### Page de profil utilisateur
`initProfileEditors()` :
- Réordonne les sections natives WP en lignes deux-colonnes (`PROFILE_ORDER`)
- Masque le `<h2>` « À propos du compte »
- Renomme le label `Rôle` en `Rôle sur le site`
- Force le mode visuel sur tous les WYSIWYG Pods (`load` du window)
- `do_action('show_user_profile', new WP_User(0))` sur `user-new.php` pour que les champs Pods s'affichent à la création d'utilisateur. `user_register` rejoue `personal_options_update` pour sauver ces champs avec le nouvel ID
- Auto-sync `display_name``first_name + last_name` à chaque save (`profile_update`, `user_register`, et hook Pods)
### Pods modal (création de séance via popup)
Quand l'URL contient `pods_modal`, la catégorie est verrouillée sur `12` (séance de séminaire) et le select est désactivé pour les autres valeurs.
### Colonnes admin custom
- Taxonomy `programme_de_recherche` : remplace « Description » par « Type de programme » (term meta `type_de_programme`). Select de filtre injecté côté JS dans la search-form, filtré côté serveur via `pre_get_terms`
- Taxonomy `post_tag` : remplace « Description » par « Exclure du nuage » (term meta `ne_pas_afficher_dans_le_nuage`)
### Autres tweaks admin
- Champ `etiquettes` (post_tag pick) : autocomplete déplafonnée (filtre `pods_form_ui_field_pick_autocomplete_limit` retourne `-1`)
- Admin bar : retire `wp-logo`, `customize`, `wpforms-menu`, et rename « article » → « annonce » dans tous les nodes
## Search panel (`templates/partials/search-panel.twig`)
Réutilisée dans `search.twig`. L'icône loupe `iconoir-search` est un `<button type="submit" class="search-panel__icon-btn">` (positionné en absolute dans `.search-panel__input-wrap` qui est relative — styles dans `scss/_header.scss`).
## Rewrite rules
- `/category/{slug}/autres` → posts directement dans la catégorie racine (sans sous-catégorie). Query var `thalim_direct_posts=1`. Utilisé pour le lien depuis les `post-card` quand la catégorie est racine
## Conventions Pods
- **Membres / autre_membres** : champs `pick` (user) multi-valeur sur les posts. `membres` = membres Thalim, `autre_membres` = externes. Les helpers résolvent en `[{name, url}]`. Quand `membres` est vide, `autre_membres` est utilisé en fallback pour les cards
- **Liens externes** : convention `lien_externe_1/2/3` + `titre_du_lien_externe_1/2/3` (bilingual). Fallback titre : nom de domaine
- **Champs `type_*`** (`type_colloque_journee_d_etude`, `type_soutenance`, etc.) : un seul est rempli par post, le premier non-vide donne le `type_label`. Idem pour `fonction_*` et `autre_fonction_*`. Pour les posts anciens sans ces champs, fallback sur un mapping `_pods_categorie ID → label` codé en dur dans `single-helpers.php`
- **Axes thématiques** (taxonomy `axe_thematique`) : term meta `annee_debut`, `annee_fin`, `ordre_daffichage`. Un axe est « courant » si `annee_fin >= année courante`. Affiché dans le menu de navigation (`axes_courants` dans le contexte)
## Workflow
- **NE PAS compiler le SCSS/CSS.** L'utilisateur le fait manuellement. Éditer uniquement les `.scss`
- **CSS commité** dans le dépôt (`css/*.css`) pour que le site fonctionne après clone
- **SQL direct OK** pour les tweaks ponctuels — phpMyAdmin sur http://localhost:8021