- NEWSLETTER-RETOURS-2026-08-27.md : retour du commanditaire sur la newsletter de juillet (annonces à venir absentes, dates de publication affichées à la place des dates d'événement), vérification dans le code et implémentation. - NOTE-SLUG-NUMERIQUE-404-2026-08-27.md : annonces publiées répondant 404 (slug numérique incompatible avec /%category%/%postname%/), diagnostic et implémentation. - CLAUDE.md : nouveau module inc/post-slug-guard.php, piège du titre vide dans post-title-required.php, et bascule de la newsletter sur la date d'événement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w7RzAHv4r1Yahnm2NwDDG
24 KiB
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
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— simple chargeur : init Timber +require_oncedes modulesinc/*(l'ordre compte :config.phpeti18n.phpd'abord)inc/— toute la logique PHP, un module par responsabilité (voir détails plus bas)templates/+templates/partials/— Twigscss/→css/— SASS compilé manuellement par l'utilisateur (CSS commité)js/— scripts frontend ;js/admin/— scripts admin par contexte de page (voir « Customisations admin »)assets/vendor/— dépendances tierces auto-hébergées (Swiper 12.2.0, Iconoir 7.11.0) — plus aucun CDNvendor/— Composer (Timber 2.x).composer installaprès clonetests/run-tests.php— tests des fonctions pures, à exécuter viadocker exec wordpress php /var/www/html/wp-content/themes/thalim/tests/run-tests.php(idem dans chaque plugin)
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). Les catégories sont résolues par slug et les IDs Pods par nom ; toute écriture de relation Pods passe parincludes/class-pods-storage.php(dépendance dure à Pods 3.x documentée dans le fichier — ne pas écrirewp_podsrelailleurs).thalim-newsletter/— composition et export HTML des digests mensuels. Admin : Outils → Newsletter. Les constantesTHALIM_NL_CAT_*sont résolues par slug au chargement (transient 1 j, fallback IDs historiques) ; le pod/champcategorieest résolu par nom dansclass-admin-page.php. Éligibilité, tri et affichage reposent sur la date d'événement (date_de_debut>datetime>post_date), jamais sur la date de publication ; les contenus au-delà du mois sont proposés dans un bloc « À venir » (horizon 12 mois), décochés par défaut. Voir le README du plugin.
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 inc/i18n.php.
- Détection :
thalim_current_language()retourne'en'siTHALIM_ORIGINAL_URIcommence par/en/, sinon'fr'. Le préfixe/en/est strippé viado_parse_requestavant la résolution d'URL WP, etredirect_canonicalest 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 pourbiographie_en,presentation_en,recherches_en_cours_en,resume_de_la_these_en,autres_domaines_de_recherches_ensur les utilisateurs, etpresentation_en/presentation_detail_ensur le postcontenu_general - URLs préfixées :
thalim_en_url($url)ajoute/enaux URLs internes en mode EN, attaché àterm_link,post_link,page_link,post_type_link,author_link. Filtre Twig|en_urlaussi 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 metatitre_anglaisen mode EN. Aussi appliqué au title de l'onglet navigateur viadocument_title_parts - Menus : slugs
Navigation/Navigation-enetFooter/Footer-en(pasmenu-principal— c'est un legacy obsolète) - Contenu général : un seul post de type
contenu_generalfournit les blocs UMR/thalim/siècles/présentation au layout viagcdans le contexte Twig - Sélecteur de langue :
thalim_language_switcher()retourne['fr' => ['slug','url','current_lang'], 'en' => …]— remplacepll_the_languages() - Override pour AJAX : poser
$GLOBALS['thalim_lang_override'] = $langavantthalim_current_language()(utilisé par les handlersload_more_posts/load_more_agendaqui 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' => trueaux args d'unWP_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_wheredansinc/event-dates.php. LEFT JOIN surpostmetapuisCASE WHEN date_de_debut ELSE datetime ELSE post_date END DESC. Les valeurs0000-00-00...sont traitées comme NULL - Format d'affichage :
thalim_format_date($raw, $lang)dansinc/single-helpers.phprenvoiedate_i18n('j F Y', $ts). Les abréviations de mois (3-lettres) pour les vignettes agenda sont codées en dur dansinc/ajax.php(thalim_get_agenda_card_data()) et dansinc/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 dansthalim_get_agenda_card_data()— à dupliquer prudemment si besoin ailleurs
Helpers PHP (inc/)
| Fichier | Rôle |
|---|---|
config.php |
Identifiants centralisés : thalim_term_id_by_slug() (slug → term_id, cache statique), thalim_cat_id($cle) (catégories structurelles par clé logique — attention, le slug de la cat 9 est vie-du-labo-intranet), thalim_excluded_role_ids(), thalim_category_color_slug() (seule map encore indexée sur term_id). Ne jamais réintroduire d'ID numérique en dur : passer par ces fonctions |
i18n.php |
Multilingue maison complet (cf. section dédiée) |
assets.php |
Enqueue front + admin (scripts admin conditionnels par écran via get_current_screen()) |
context.php |
add_to_context() (contexte Twig global) |
event-dates.php |
Filtres posts_join/orderby/where du tri par événement, thalim_get_active_pinned_ids(), thalim_get_axes_filter_groups() |
seance-helpers.php |
thalim_get_seance_parent_id() (mémoïsée — la seule résolution séance→séminaire, utilisée partout), thalim_get_seance_link(), redirection template_redirect séance→parent |
ajax.php |
Handlers load_more_posts / load_more_agenda + helpers partagés thalim_ajax_read_filters() / thalim_ajax_build_query_args(), thalim_get_agenda_card_data() |
archive-filters.php |
Helpers partagés des 4 contrôleurs d'archives (category/taxonomy/search/page-annonces) : exclusions, lecture des filtres GET, rubrique active, posts « directs », listes de filtres (liens injectés en closure par chaque contrôleur) |
access-control.php |
Restrictions contributeurs (pre_get_posts, user_has_cap, wp_count_posts), Vie du labo, redirections login/dashboard |
admin-tweaks.php |
Synchro display_name, Pods sur user-new, colonnes taxonomies, filtre catégorie exact, rewrite /autres, admin bar, user_can_richedit |
avatars.php |
thalim_get_user_avatar_url() (voir « Avatars ») |
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 (1–3), Canal-U / YouTube embeds. Inclut aussi thalim_format_date() (partagée). Champs profil/HTML passés par wp_kses_post (autoescape Twig désactivé) |
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. Attention : le post est bien écrit avec son titre vide avant d'être annulé — c'est là que le cœur fige un slug numérique, d'où le vidage de post_name dans pods-save-error-handler.php |
post-slug-guard.php |
Suffixe les slugs purement numériques des post/page (1917 → 1917-2). Avec la structure /%category%/%postname%/, un dernier segment numérique est lu comme un numéro de page par la règle (.+?)/([^/]+)(?:/([0-9]+))?/?$ : l'article répond 404 tout en restant listé partout. Ne touche pas aux nav_menu_item ni aux attachment, dont les slugs numériques sont normaux |
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 inc/avatars.php :
- Simple Local Avatar (
simple_local_avataruser meta) — résolu viamedia_idquand dispo (pour survivre aux changements de domaine), sinon URL réécrite - Gravatar — lu uniquement depuis le transient
thalim_gravatar_{ID}(1 semaine). Aucune requête réseau dans le rendu : le HEADd=404est fait par le cron quotidienthalim_warm_gravatar_cache(+ réchauffage unitairethalim_warm_gravatar_userplanifié à la volée en cas de cache manquant) - 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 :
directeurpuisdirecteur_adjoint, lus depuis les post meta de la pagele-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 metalast_name - Cas spécial « Personnel d'accompagnement à la recherche » : les statuts contenant « Gestion et pilotage » remontent en tête
- Chaque
<tr>portedata-name,data-status,data-affiliation,data-roles(séparés par|),data-avatar,data-domaines,data-autres-domaines— consommés parmembresFilters.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-rowposée en JS après chaque filtre/tri — pasnth-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 pourparent_slug(color theme) etparent_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 enimages(taillelarge, marquéportraitsih > w) etdocuments. 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 (
seancesmeta = array de post IDs) sont redirigées viatemplate_redirectvers 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é viawp_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 viawp_nav_menu_objects - Contributeurs :
- Liste admin filtrée : posts qu'ils ont écrits OU où ils sont dans
membres/autre_membres(filtrepre_get_posts+ override dewp_count_postspour 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 : capturepost_IDdepuis$_POSTquand WP testeedit_others_postssans$args[2] - Login redirige vers
edit.php(pas le dashboard). Dashboard et menu Outils masqués pour les non-admins
- Liste admin filtrée : posts qu'ils ont écrits OU où ils sont dans
- 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/admin/* + css/admin.css. L'admin est très modifié. Les scripts sont découpés
par contexte de page et enqueués conditionnellement (inc/assets.php, via
get_current_screen()) :
admin-base.js— toutes pages : namespacewindow.ThalimAdmin(tableCONFIGdes sélecteurs/IDs Pods,safeRun()pour isoler chaque module dans un try/catch,ensureVisualMode,reinitEditor,updatePostboxVisibility,INFO_TIPS+initInfoPopovers,markReady+ fallback reveal 2 s)admin-rename.js— toutes pages : rename « Article » → « Annonce »admin-post-edit.js—post.php/post-new.php(reçoitthalimAxesGroups)admin-profile.js— profil / user-edit / user-newadmin-taxonomy-list.js—edit-tags.php/term.php(popovers FR//EN, filtre type de programme, mode visuel)admin-pods-modal.js— iframe?pods_modal(verrou catégorie séance)
Patterns transversaux
- Cacher des éléments :
css/admin.cssavecdisplay: 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 dehtml-activeàtmce-active - TinyMCE sur champs conditionnels : un éditeur initialisé dans un élément
display:nonea son iframe cassée (vide, non interactive).reinitEditor(editorId)détruit et recrée l'instance quand le champ devient visible. Utilisé pourbody_en(au premier clic onglet EN) et pourreference_bibliographique(via MutationObserver sur lestylede 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-dannonceen haut du#side-sortables,affichage-sur-laccueiletthematiqueen bas,champs-contextuelsen haut du#normal-sortables,documents-jointsjuste après,submitdiven bas - Onglets FR/EN sur le corps : barre de tabs injectée avant
#postdivrich. La metabox#pods-meta-body-enest déplacée juste après l'éditeur natif (le déplacement DOM casse son iframe TinyMCE — réparé parreinitEditor()au premier clic EN). Phase 1 = DOM avantt=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_finou « Axes antérieurs »), viagroupAxesCheckboxes(). Données passées enthalimAxesGroupsviawp_localize_script. Re-grouping déclenché si la ligne devient visible (MutationObserver) - Visibilité conditionnelle des Pods boxes :
updatePostboxVisibility()cache une.postboxPods si tous ses<tr>ontdisplay:none(saufbody-enqui 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 ligneautre_*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__contentapparaît dans le DOM - Info-popovers (
INFO_TIPSarray) : boutons d'aide attachés à des sélecteurs spécifiques. Typeinfo(cercle, neutre) outranslate(globe, vert) pour rappeler la conventionFR // EN. Champpage(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ôleenRôle sur le site - Force le mode visuel sur tous les WYSIWYG Pods (
loaddu window) do_action('show_user_profile', new WP_User(0))suruser-new.phppour que les champs Pods s'affichent à la création d'utilisateur.user_registerrejouepersonal_options_updatepour 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 metatype_de_programme). Select de filtre injecté côté JS dans la search-form, filtré côté serveur viapre_get_terms - Taxonomy
post_tag: remplace « Description » par « Exclure du nuage » (term metane_pas_afficher_dans_le_nuage)
Autres tweaks admin
- Champ
etiquettes(post_tag pick) : autocomplete déplafonnée (filtrepods_form_ui_field_pick_autocomplete_limitretourne-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 varthalim_direct_posts=1. Utilisé pour le lien depuis lespost-cardquand 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}]. Quandmembresest vide,autre_membresest 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 letype_label. Idem pourfonction_*etautre_fonction_*. Pour les posts anciens sans ces champs, fallback sur un mapping_pods_categorie ID → labelcodé en dur danssingle-helpers.php - Axes thématiques (taxonomy
axe_thematique) : term metaannee_debut,annee_fin,ordre_daffichage. Un axe est « courant » siannee_fin >= année courante. Affiché dans le menu de navigation (axes_courantsdans 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