LaJungle FSE — Guide du développeur

Bonnes pratiques pour créer un site avec le thème lajungle-fse et les plugins ljd-core + ljd-blocks. Stack native FSE, sans ACF, sans Blade.

Bedrock WordPress 7 FSE natif PHP 8.3 Docker PostCSS @wordpress/scripts
✏️
Documentation vivante — tout le monde peut contribuer Ce guide n'est pas figé. Il est amené à évoluer au fil des projets et des découvertes. Si tu repères une erreur, une bonne pratique manquante ou une info à mettre à jour, édite directement le fichier sur Bitbucket et ouvre une PR.

Stack technique

Le projet repose sur Bedrock comme base WordPress, un thème FSE natif (sans Sage, sans Blade), le plugin ljd-core pour la logique métier PHP et le plugin ljd-blocks pour les blocs Gutenberg React.

🗂 Infrastructure
  • Bedrock — structure, Composer, .env
  • WordPress 7 (roots/wordpress-full)
  • Docker — Nginx + PHP 8.3 + MySQL
  • WP-CLI via make wp
🎨 Thème lajungle-fse
  • FSE natif (parts, templates, theme.json)
  • PostCSS + cssnano pour le CSS
  • Chargement via config/features.php
  • Basé sur TwentyTwentyFive
🔧 Plugin ljd-core
  • CPT, taxonomies, features PHP
  • Abstractions AbstractFeature, AbstractCPT
🧩 Plugin ljd-blocks
  • Blocs Gutenberg custom (React)
  • block.json + edit.js + save.js
  • @wordpress/scripts (Webpack) pour les scripts JS
⚡ Blocs
  • Blocs natifs Gutenberg
  • Blocs custom React via ljd-blocks

Démarrer un projet

Prérequis

Docker
  • Docker Desktop
  • Node.js
  • npm

Installation complète

TODO

Architecture du projet

Le projet suit la structure Bedrock. Le code métier est dans web/app/, séparé du core WordPress dans web/wp/ (géré par Composer, ne pas modifier).

mon-projet/
├── config/                         ← Config Bedrock par environnement
│   ├── application.php             ← Config globale (DB, salts, WP_ENV)
│   └── environments/               ← development.php / staging.php
├── web/
│   ├── app/
│   │   ├── mu-plugins/              ← Must-use plugins (autoloadés)
│   │   ├── plugins/
│   │   │   ├── ljd-core/            ← Plugin PHP : CPT, Features, Fields
│   │   │   │   ├── src/             ← Abstracts, CPT, Features, Fields
│   │   │   │   ├── ljd-core.php
│   │   │   │   └── composer.json
│   │   │   └── ljd-blocks/          ← Plugin blocs Gutenberg React
│   │   │       ├── src/
│   │   │       │   ├── blocks/      ← Blocs custom (block.json + edit/save/render)
│   │   │       │   ├── sub-blocks/  ← Sous-blocs (parent + allowedBlocks)
│   │   │       │   ├── extensions/  ← Scripts éditeur (meta-fields, options-page…)
│   │   │       │   ├── components/  ← Composants React partagés
│   │   │       │   ├── hooks/       ← Hooks React custom
│   │   │       │   └── lib/         ← Fonctions utilitaires pures
│   │   │       ├── ljd-blocks.php
│   │   │       └── webpack.config.js   ← auto-découverte extensions + blocks
│   │   └── themes/
│   │       └── lajungle-fse/        ← Thème FSE natif
│   │           ├── parts/           ← Template parts (header, footer…)
│   │           ├── templates/       ← Templates de page FSE (.html)
│   │           ├── src/Features/    ← Features PHP du thème
│   │           ├── config/
│   │           │   ├── blocks.php   ← Whitelist des blocs autorisés
│   │           │   └── features.php ← Liste des features à charger
│   │           ├── theme.json       ← Design tokens (couleurs, typo, espacements)
│   │           ├── style.css        ← CSS global minimal (minifié → style.min.css)
│   │           └── functions.php    ← Bootstrap (lit et instancie features.php)
│   └── wp/                         ← Core WordPress (Composer — ne pas modifier)
├── .env                            ← Variables locales (gitignorée)
├── .env.example                    ← Template à copier
├── composer.json                   ← Bedrock + dépendances PHP
├── Makefile                        ← Toutes les commandes de développement
└── docker-compose.yml              ← Stack locale (Nginx + PHP + MySQL)

Plugin = bibliothèque · Thème = orchestrateur

Le plugin ljd-core ne déclare aucune feature par lui-même. C'est le thème qui orchestre via config/features.php :

// web/app/themes/lajungle-fse/config/features.php
return [
    // Features du thème
    ThemeSetup::class,   Assets::class,   BlockStyles::class,

    // CPTs déclarés dans ljd-core
    NewsCPT::class,      JobOfferCPT::class,

    // Features de ljd-core
    AllowedBlocks::class, Security::class, CleanPatterns::class,
];
Pourquoi ce pattern ? Plusieurs thèmes peuvent utiliser le même plugin en activant uniquement les features dont ils ont besoin. Un CPT absent de features.php n'est pas enregistré.

Design system — theme.json v3

Tout le design system est centralisé dans theme.json (schéma v3, WP 6.6+). Pas de SCSS ni de variables CSS manuelles dans le thème — WordPress génère automatiquement les custom properties CSS à partir de theme.json. (Le plugin ljd-blocks peut utiliser PostCSS/SCSS pour ses styles d'éditeur — voir Architecture ljd-blocks.)

theme.json// theme.json — structure racine (à inclure dans tout projet)
{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": { /* couleurs, typo, espacements… */ },
  "styles":   { /* styles par défaut */ },
  "templateParts":   [],
  "customTemplates": []
}
Toujours déclarer $schema et "version": 3 Le $schema active la validation et l'autocomplétion dans VS Code / PhpStorm. "version": 3 est requis pour les fonctionnalités WP 6.6+ (presets border-radius, styles formulaires, pseudo-classes bouton). Référence : theme.json living reference.

Palette de couleurs

Désactiver les palettes natives pour forcer l'utilisation de la palette projet :

"color": {
  "defaultPalette": false,
  "defaultGradients": false,
  "palette": [
    { "slug": "base",     "color": "#FFFFFF",  "name": "Base" },
    { "slug": "contrast", "color": "#111111",  "name": "Contrast" },
    { "slug": "accent-1", "color": "#FFEE58",  "name": "Accent 1" },
    { "slug": "accent-3", "color": "#503AA8",  "name": "Accent 3" }
  ]
}

En CSS : var(--wp--preset--color--accent-1) · Dans theme.json : var:preset|color|accent-1

Typographie fluide

Les tailles fluides calculent automatiquement un clamp() selon la largeur du viewport :

"fontSizes": [
  { "slug": "small",    "size": "0.875rem", "fluid": false },
  { "slug": "medium",   "size": "1rem",
    "fluid": { "min": "1rem",    "max": "1.125rem" } },
  { "slug": "xx-large", "size": "2.15rem",
    "fluid": { "min": "2.15rem", "max": "3rem"    } }
]

Échelle d'espacements

SlugNomValeurCSS
20Tiny10pxvar(--wp--preset--spacing--20)
40Small30pxvar(--wp--preset--spacing--40)
50Regularclamp(30px, 5vw, 50px)var(--wp--preset--spacing--50)
60Largeclamp(30px, 7vw, 70px)var(--wp--preset--spacing--60)
80XX-Largeclamp(70px, 10vw, 140px)var(--wp--preset--spacing--80)

Polices auto-hébergées

Déclarer les polices dans theme.json, pas en CSS. Les fichiers woff2 sont dans assets/fonts/ :

"fontFamilies": [{
  "slug": "manrope",
  "fontFamily": "Manrope, sans-serif",
  "fontFace": [{
    "src": ["file:./assets/fonts/manrope/Manrope-VariableFont_wght.woff2"],
    "fontWeight": "200 800"
  }]
}]

Workflow Figma → theme.json

  1. Export des tokens Figma

    Exporter couleurs, typographies et espacements (plugin Tokens Studio ou export manuel).

  2. Traduction en slugs cohérents

    Nommer base, contrast, accent-1… pour réutilisation entre projets.

  3. Mise à jour de theme.json

    Modifier la section settings pour les tokens, et styles.blocks pour appliquer aux blocs natifs.

  4. Validation dans le Site Editor

    Ouvrir Apparence → Éditeur et vérifier que couleurs et typographies apparaissent dans la sidebar.

Syntaxe theme.json dans styles.blocks Utiliser var:preset|color|accent-1 (syntaxe interne), pas var(--wp--preset--color--accent-1) — WordPress gère le rendu selon le contexte (front / éditeur).

Styles par bloc — styles.blocks

Appliquer des styles par défaut à n'importe quel bloc dans tout le site, sans CSS inline. Indispensable pour harmoniser les résultats de Query Loop :

"styles": {
  "blocks": {
    "core/post-title": {
      "typography": { "fontSize": "var:preset|font-size|x-large" }
    },
    "core/post-excerpt": {
      "color": { "text": "var:preset|color|muted" }
    },
    "core/buttons": {
      "spacing": { "margin": { "top": "var:preset|spacing|40" } }
    }
  }
}

Nouveautés WP 6.9

🟣 Border radius presets

Déclarer des presets dans settings.border.radiusSizes pour un sélecteur visuel dans l'éditeur. Les utilisateurs peuvent toujours saisir une valeur libre.

🔵 Formulaires stylables

Styler <input> et <select> via styles.elements.input — border, color, shadow, spacing. Focus state non disponible en 6.9.

🟠 Bouton hover/focus

Styler :hover et :focus du bloc Button directement dans theme.json, sans CSS additionnel.

Polices — Font Library & theme.json WP 6.5+

WordPress propose deux mécanismes pour gérer les polices. Les confondre ou les mélanger génère des doublons, des conflits et des surprises en production. Choisir l'un ou l'autre selon le contexte ; ne pas utiliser les deux sur le même projet.

📁 theme.json — approche développeur
  • Polices déclarées dans le dépôt git
  • Fichiers .woff2 versionnés dans assets/fonts/
  • Reproductible, déployable, auditeable
  • À utiliser sur tous les projets LaJungle
🖥 Font Library UI — approche éditeur
  • Upload via Apparence → Polices dans l'admin
  • Stockage dans wp-content/fonts/ (hors git)
  • Fonctionnel pour prototypage ou site client sans dev
  • À éviter sur les projets avec thème custom
Ne pas mélanger les deux approches Si theme.json déclare une police et que la Font Library en upload une du même nom, WordPress génère deux @font-face avec des chemins différents. Résultat : comportement imprévisible selon l'ordre de chargement des styles.

Bonnes pratiques 2026 — À faire / À éviter

✅ À faire❌ À éviter
Variable font WOFF2 — un seul fichier pour toutes les graisses Charger 4–5 fichiers statiques quand une variable font existe
Auto-héberger les polices dans assets/fonts/ (versionnées git) Google Fonts via wp_enqueue_style ou @import — requête tierce, RGPD
Déclarer dans theme.json avec "fontDisplay": "swap" Définir les @font-face manuellement en CSS — doublon avec theme.json
Subsetté latin + latin-ext — retire les glyphes inutiles (−60 à −80 %) Embarquer la police complète (2 000+ glyphes) pour un site en français
Précharger uniquement la police body (1–2 max) via Feature PHP Précharger toutes les polices — contention réseau, ralentit le rendu
defaultFontFamilies: false pour masquer les polices natives WP Laisser les polices WP par défaut polluer le sélecteur éditeur
Tout versionner dans git — reproductible entre tous les envs Uploader via Apparence → Polices sur un thème custom (hors git = dérive)

Déclaration dans theme.json — fontFamilies + fontFace

Déclarer chaque famille avec ses variantes directement dans theme.json. WordPress génère automatiquement les règles @font-face et les variables CSS --wp--preset--font-family--{slug}.

Le préfixe file: résout le chemin depuis la racine du thème :

// theme.json — settings.typography.fontFamilies
"fontFamilies": [
  {
    "slug":       "manrope",
    "name":       "Manrope",
    "fontFamily": "Manrope, sans-serif",
    "fontFace": [
      {
        "fontFamily":  "Manrope",
        "fontWeight":  "200 800",      // plage variable font
        "fontStyle":   "normal",
        "fontDisplay": "swap",         // évite le FOIT
        "src": ["file:./assets/fonts/manrope/Manrope-VariableFont_wght.woff2"]
      }
    ]
  },
  {
    "slug":       "playfair",
    "name":       "Playfair Display",
    "fontFamily": "'Playfair Display', Georgia, serif",
    "fontFace": [
      {
        "fontFamily":  "Playfair Display",
        "fontWeight":  "400",
        "fontStyle":   "normal",
        "fontDisplay": "swap",
        "src": ["file:./assets/fonts/playfair/PlayfairDisplay-Regular.woff2"]
      },
      {
        "fontFamily":  "Playfair Display",
        "fontWeight":  "700",
        "fontStyle":   "normal",
        "fontDisplay": "swap",
        "src": ["file:./assets/fonts/playfair/PlayfairDisplay-Bold.woff2"]
      }
    ]
  }
]
Variable fonts — une seule déclaration pour toutes les graisses Quand la police est une variable font (ex. Manrope, Inter, Raleway…), une seule entrée fontFace suffit avec une plage "fontWeight": "200 800". Le fichier .woff2 pèse souvent moins lourd que la somme de 4 ou 5 fichiers statiques. Préférer systématiquement les variable fonts en 2026.

Appliquer une police par défaut — styles.typography

Définir les polices de base du site dans la section styles de theme.json :

"styles": {
  "typography": {
    "fontFamily": "var:preset|font-family|manrope"  // police du body
  },
  "blocks": {
    "core/heading": {
      "typography": {
        "fontFamily": "var:preset|font-family|playfair"
      }
    }
  }
}

Désactiver les polices et tailles par défaut de WordPress

WordPress 6.5+ inclut un jeu de polices par défaut (Inter, Cardo…) qui apparaissent dans le sélecteur de l'éditeur si on ne les désactive pas explicitement. Forcer uniquement les polices du projet :

"settings": {
  "typography": {
    "defaultFontSizes":    false,   // masque les tailles WP (small, medium…)
    "defaultFontFamilies": false,   // masque les polices WP par défaut
    "fontFamilies": [ /* vos polices uniquement */ ],
    "fontSizes":   [ /* vos tailles uniquement */ ]
  }
}

Structure des fichiers de police

Stocker les polices dans assets/fonts/, organisées par famille. N'embarquer que les variantes réellement utilisées dans le design :

lajungle-fse/assets/fonts/
├── manrope/
│   └── Manrope-VariableFont_wght.woff2        ← toutes graisses (200–800)
└── playfair/
    ├── PlayfairDisplay-Regular.woff2           ← 400 normal
    ├── PlayfairDisplay-Italic.woff2            ← 400 italic (si utilisé)
    └── PlayfairDisplay-Bold.woff2              ← 700 normal
WOFF2 uniquement en 2026 Tous les navigateurs supportés supportent WOFF2. Supprimer les formats .ttf, .eot et .woff — ils augmentent la taille du dépôt et ne servent plus à rien. Un seul fichier par variante suffit.

Subsetting — réduire le poids des fichiers

Une police complète peut contenir des milliers de glyphes (cyrillique, grec, symboles…) inutiles pour un site en français. Le subsetting conserve uniquement les caractères nécessaires et réduit la taille de 60 à 80 %.

  • Google Fonts Helper (gwfh.mranftl.com) — télécharger directement les subset latin + latin-ext en WOFF2
  • Glyphhanger — outil CLI Node.js pour subsetté à partir du HTML réel du projet
  • FontForge / pyftsubset — subsetting précis par liste de caractères Unicode

Pour un site français standard : subset latin + latin-ext (accents, ligatures) est suffisant.

Préchargement des polices critiques

WordPress ne génère pas automatiquement de <link rel="preload"> pour les polices déclarées dans theme.json. Ajouter le preload manuellement dans une Feature du thème pour uniquement la police du body (au-dessus de la ligne de flottaison) :

// src/Features/FontPreload.php
class FontPreload extends AbstractFeature
{
    public function register(): void
    {
        add_action('wp_head', [$this, 'preloadFonts'], 1);
    }

    public function preloadFonts(): void
    {
        $fonts = [
            'manrope/Manrope-VariableFont_wght.woff2',
            // ne pas précharger plus de 2 polices — contention réseau
        ];

        foreach ($fonts as $font) {
            $url = get_theme_file_uri("assets/fonts/{$font}");
            echo "<link rel=\"preload\" as=\"font\" type=\"font/woff2\" "
               . "href=\"{$url}\" crossorigin>\n";
        }
    }
}

Puis ajouter FontPreload::class dans config/features.php.

Précharger avec discernement Précharger toutes les polices est contre-productif — cela retarde les ressources critiques (CSS, images above-the-fold). Règle : 1 à 2 polices maximum, uniquement celles visibles sans défilement. Ne jamais précharger une police de titre rarement affichée.

Variables CSS générées automatiquement

Pour chaque famille déclarée dans theme.json, WordPress génère une custom property utilisable partout :

SlugVariable CSSSyntaxe theme.json interne
manropevar(--wp--preset--font-family--manrope)var:preset|font-family|manrope
playfairvar(--wp--preset--font-family--playfair)var:preset|font-family|playfair

En CSS custom (fichier style.css ou style de bloc) : utiliser la syntaxe var(--wp--preset--...). Dans theme.json (styles.blocks, styles.elements) : utiliser la syntaxe var:preset|font-family|slug.

Font Library UI — quand l'utiliser

L'interface Apparence → Polices (/wp-admin/font-library.php) est utile dans deux cas précis :

  • Prototypage rapide — tester une police Google Fonts avant de l'intégrer proprement dans theme.json
  • Site client sans développeur — le client gère lui-même ses polices sans accès au code

Les polices uploadées via cette interface sont stockées dans wp-content/fonts/ et référencées dans la base de données — elles ne sont pas versionnées et peuvent disparaître lors d'une migration.

Font Library UI sur un thème custom LaJungle Ne pas utiliser l'UI Font Library si le thème déclare déjà ses polices dans theme.json. Désactiver l'accès si nécessaire via le filtre block_editor_settings_all ou en retirant la capacité edit_theme_options aux rôles non-admin.

Récapitulatif — checklist polices 2026

  1. Variable font WOFF2 uniquement

    Télécharger via Google Fonts Helper, subset latin + latin-ext. Un seul fichier par famille quand c'est une variable font.

  2. Versionner dans assets/fonts/

    Les fichiers .woff2 font partie du dépôt git — reproductibilité garantie entre environnements.

  3. Déclarer dans theme.json avec fontDisplay: swap

    Toujours ajouter "fontDisplay": "swap" sur chaque fontFace pour éviter le texte invisible pendant le chargement.

  4. Désactiver les polices et tailles WP par défaut

    defaultFontFamilies: false + defaultFontSizes: false dans settings.typography.

  5. Précharger uniquement la police body

    Feature FontPreload avec add_action('wp_head', ..., 1) — maximum 2 polices.

  6. Ne jamais uploader via l'UI Font Library sur un thème custom

    Les fonts hors git = dérive entre environnements. Tout passe par theme.json.

Layout FSE — Parts & Templates

Template Parts (parts/)

Les parts sont des fragments réutilisables (header, footer, sidebar). Les déclarer dans theme.json :

Parts — pas de sous-dossiers Les fichiers de template parts doivent se trouver directement dans parts/ — pas dans des sous-dossiers. WordPress ne scanne pas les répertoires imbriqués pour les template parts.
"templateParts": [
  { "area": "header",        "name": "header",          "title": "Header" },
  { "area": "header",        "name": "header-large-title", "title": "Header large" },
  { "area": "header",        "name": "vertical-header",   "title": "Vertical header" },
  { "area": "footer",        "name": "footer",          "title": "Footer" },
  { "area": "uncategorized", "name": "sidebar",         "title": "Sidebar" }
]

Inclusion dans un template :

<!-- wp:template-part {"slug":"header"} /-->

Templates (templates/)

FichierQuand utilisé
front-page.htmlPage d'accueil — priorité absolue sur home.html et page.html
index.htmlFallback universel (requis)
home.htmlPage de blog (liste des articles)
page.htmlPage statique standard
page-no-title.htmlTemplate personnalisé — page sans titre
single.htmlArticle de blog
archive.htmlArchives (catégories, dates, CPT)
search.htmlRésultats de recherche
404.htmlPage non trouvée

Anatomie d'un template page

<!-- templates/page.html -->
<!-- wp:template-part {"slug":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
  <!-- wp:group {"align":"full","layout":{"type":"constrained"}} -->
  <div class="wp-block-group alignfull">
    <!-- wp:post-featured-image /-->
    <!-- wp:post-title {"level":1} /-->
    <!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->
  </div>
  <!-- /wp:group -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer"} /-->

Template pour un CPT

Créer les fichiers dans templates/ — WordPress les détecte automatiquement selon sa hiérarchie :

  • single-posttype.html — vue d'un item unique
  • archive-posttype.html — liste des items
  • taxonomy-slug.html — items d'une taxonomie donnée
Responsivité Les templates définissent la structure uniquement (layout, espacements). Le responsive est géré nativement par les blocs via theme.json et les clamp(). Ne pas ajouter de media queries dans les templates HTML.

Templates personnalisés — customTemplates

Pour qu'un template apparaisse dans la liste déroulante "Template" de l'éditeur de page, le déclarer dans theme.json :

"customTemplates": [
  { "name": "page-no-title", "title": "Page sans titre", "postTypes": ["page"] },
  { "name": "landing",       "title": "Landing Page",    "postTypes": ["page"] }
]

Le fichier correspondant doit exister dans templates/ — ex. templates/landing.html. Le champ name doit correspondre exactement au nom du fichier (sans .html).

Overrides utilisateur — priorité sur theme.json Toute modification enregistrée dans le Site Editor est stockée en base et prend la priorité sur theme.json et les fichiers .html. Si un changement de thème ne s'applique pas :

Styles : Apparence → Éditeur → Styles → ⋮ → Réinitialiser les styles.
Templates : Éditeur → Templates → sélectionner → ⋮ → Effacer les modifications.

Query Loop — core/query

Le bloc core/query est le seul mécanisme FSE pour afficher des listes de posts ou de CPT dans les templates HTML. Il remplace WP_Query dans les templates et prend en charge la pagination native, les filtres et le rendu server-side.

Structure complète

<!-- templates/archive-news.html — liste paginée de News -->
<!-- wp:query {
  "query": { "postType": "news", "perPage": 12, "order": "desc", "orderBy": "date" },
  "namespace": "ljd/news-list"
} -->
<div class="wp-block-query">

  <!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
    <!-- wp:post-featured-image {"isLink":true} /-->
    <!-- wp:post-terms {"term":"news_category"} /-->
    <!-- wp:post-title {"isLink":true,"level":3} /-->
    <!-- wp:post-excerpt {"moreText":"Lire la suite"} /-->
    <!-- wp:post-date /-->
  <!-- /wp:post-template -->

  <!-- wp:query-pagination {"layout":{"type":"flex","justifyContent":"center"}} -->
    <!-- wp:query-pagination-previous /-->
    <!-- wp:query-pagination-numbers /-->
    <!-- wp:query-pagination-next /-->
  <!-- /wp:query-pagination -->

  <!-- wp:query-no-results -->
    <!-- wp:paragraph --><p>Aucun résultat.</p><!-- /wp:paragraph -->
  <!-- /wp:query-no-results -->

</div>
<!-- /wp:query -->

Paramètres query clés

ParamètreTypeUsage
postTypestringSlug du CPT ciblé ("news", "job_offer"…)
perPagenumberNombre d'items par page
order / orderBystring"desc"/"date", "asc"/"title", "asc"/"menu_order"
taxQueryobjectFiltrer par terme de taxonomie — {"news_category":[42]}
inheritbooleantrue = hérite de la requête de la page courante (archive, recherche)
stickystring"exclude" pour ignorer les posts épinglés
offsetnumberDécaler les résultats (utile pour les "featured" séparés)

Mode hérité — templates d'archive

Dans un template archive-news.html ou taxonomy-news_category.html, utiliser "inherit":true pour que WordPress injecte automatiquement les paramètres de l'archive courante (type, terme, pagination) :

<!-- wp:query {"query":{"inherit":true},"namespace":"ljd/news-archive"} -->
  <!-- ... -->
<!-- /wp:query -->
Règle : inherit true dans les templates d'archive Ne jamais forcer postType ou perPage dans un template d'archive si inherit:true est actif — WordPress gère la requête. Réserver les paramètres manuels aux pages statiques qui embarquent une Query Loop.

Blocs enfants disponibles dans post-template

Contenu du post
  • core/post-title
  • core/post-excerpt
  • core/post-content
  • core/post-featured-image
Métadonnées
  • core/post-date
  • core/post-author
  • core/post-terms (taxo)
  • core/read-more
Navigation
  • core/query-pagination
  • core/query-pagination-previous
  • core/query-pagination-numbers
  • core/query-pagination-next
  • core/query-no-results

namespace — QueryLoopExcludeCurrentPost

L'attribut namespace identifie une Query Loop spécifique dans les filtres PHP. La feature QueryLoopExcludeCurrentPost de ljd-core l'utilise pour exclure automatiquement le post courant des listes "articles liés" sur les pages single :

<!-- Query Loop sur un single-news.html — exclut le post courant -->
<!-- wp:query {
  "query": { "postType": "news", "perPage": 3 },
  "namespace": "ljd/news-related"
} -->
Toujours définir un namespace sur les Query Loops custom Cela permet de cibler précisément chaque loop dans les filtres PHP (pre_get_posts, query_loop_block_query_vars) sans affecter les autres loops de la page.

Patterns

Les patterns sont des compositions de blocs réutilisables exposées dans l'inserteur. Préférer les patterns filesystem (dans patterns/) aux patterns enregistrés en PHP dans une Feature — ils sont automatiquement découverts par WordPress.

Pattern filesystem (patterns/*.php)

Chaque fichier dans patterns/ est un pattern. L'en-tête PHP (commentaire) déclare ses métadonnées :

📁 lajungle-fse/patterns/news-grid.php
<?php
/**
 * Title: Grille News
 * Slug: ljd/news-grid
 * Categories: lajungle
 * Block Types: core/query
 * Inserter: true
 */
?>
<!-- wp:query {"query":{"postType":"news","perPage":6}} -->
<div class="wp-block-query">
  <!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
    <!-- wp:post-title {"isLink":true,"level":3} /-->
    <!-- wp:post-excerpt /-->
  <!-- /wp:post-template -->
</div>
<!-- /wp:query -->

En-têtes disponibles

En-têteObligatoireDescription
TitleOuiNom affiché dans l'inserteur
SlugOuiIdentifiant unique namespace/nom
CategoriesNonCatégorie dans l'inserteur (enregistrée via register_block_pattern_category)
Block TypesNonPropose automatiquement le pattern quand ce bloc est inséré
InserterNonfalse = pattern interne, non visible dans l'inserteur
Post TypesNonLimite la visibilité à certains types de posts
Pattern vs Template — règle de décision Utiliser un template pour les structures de page entières (archive, single, page…) gérées par la hiérarchie WordPress. Utiliser un pattern pour les compositions réutilisables qu'un éditeur peut insérer dans du contenu — hero sections, grilles, cards, CTA. Ne jamais dupliquer un template en pattern.
Stabilité du markup Renommer un bloc ou changer la structure HTML d'un pattern peut corrompre silencieusement les contenus existants qui ont utilisé ce pattern. Traiter le markup des patterns comme une API stable.

Gestion des blocs

Whitelist des blocs (config/blocks.php)

Seuls les blocs listés sont accessibles dans l'éditeur. Le fichier retourne un tableau PHP de slugs, ou true pour tout autoriser. C'est AllowedBlocks::class (listé dans config/features.php) qui lit ce fichier et hook allowed_block_types_all.

// config/blocks.php
return [

    // ── Layout ───────────────────────────────────────────────────────────────
    'core/group',
    'core/columns', 'core/column',
    'core/row',     'core/stack',  'core/grid',

    // ── Contenu ──────────────────────────────────────────────────────────────
    'core/paragraph', 'core/heading',
    'core/list', 'core/list-item',
    'core/quote', 'core/pullquote',
    'core/table', 'core/details',
    'core/code', 'core/preformatted', 'core/verse',

    // ── Media ────────────────────────────────────────────────────────────────
    'core/image', 'core/gallery',
    'core/video', 'core/audio', 'core/file',
    'core/cover', 'core/media-text',

    // ── Design ───────────────────────────────────────────────────────────────
    'core/buttons', 'core/button',
    'core/separator', 'core/spacer', 'core/html',

    // ── Navigation & thème ───────────────────────────────────────────────────
    'core/navigation', 'core/navigation-link', 'core/navigation-submenu',
    'core/site-logo', 'core/site-title', 'core/site-tagline',
    'core/template-part', 'core/pattern',

    // ── Query Loop ───────────────────────────────────────────────────────────
    'core/query', 'core/post-template',
    'core/post-title', 'core/post-excerpt', 'core/post-featured-image',
    'core/post-date', 'core/post-terms', 'core/post-author', 'core/post-content',
    'core/query-no-results',
    'core/query-pagination', 'core/query-pagination-next',
    'core/query-pagination-previous', 'core/query-pagination-numbers'
];
Escape hatch Retourner true au lieu du tableau désactive le filtre et autorise tous les blocs. Pratique en dev pour déboguer sans contrainte. Si config/blocks.php est absent, AllowedBlocks retourne aussi true par défaut.

Pour ajouter un bloc ponctuellement sans modifier le fichier (ex. depuis un autre plugin) :

add_filter('lajungle_core/allowed_blocks', function(array $blocks): array {
    $blocks[] = 'mon-plugin/mon-bloc';
    return $blocks;
});

Bloc custom React

📁 ljd-blocks/src/blocks/mon-bloc/
mon-bloc/
├── block.json   ← Déclaration, attributs, supports
├── edit.js     ← Interface éditeur (React)
├── save.js     ← HTML statique sauvegardé en base
└── style.css   ← Style front (optionnel)
// block.json — structure minimale
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "ljd/mon-bloc",
  "title": "Mon Bloc",
  "category": "lajungle",
  "supports": { "html": false, "color": { "background": true } },
  "attributes": {
    "titre": { "type": "string", "default": "" }
  },
  "editorScript": "file:./index.js"
}
Ne pas utiliser "source": "meta" dans les attributs La source meta est dépréciée et génère des comportements imprévisibles à long terme. Pour lier un attribut à un meta field, utiliser les Block Bindings (core/post-meta, WP 6.5+) ou passer la valeur via render.php avec get_post_meta().
apiVersion: 3 est obligatoire pour tous les nouveaux blocs. WP 6.9 émet des warnings console pour apiVersion 2 quand SCRIPT_DEBUG est activé. WP 6.3–6.9 : l'éditeur passe en iframe si tous les blocs du contenu sont apiVersion 3+ — un seul bloc apiVersion 2 désactive l'iframe pour ce contenu. WP 7.0 : l'iframe est activée systématiquement, indépendamment de l'apiVersion. Bénéfices : isolation des styles admin, unités vw/vh et media queries correctes. Vérifier que tous les handles CSS sont déclarés dans block.json — les styles non référencés ne se chargent pas dans l'iframe.

Champs d'assets dans block.json

ChampContexteUsage typique
editorScriptÉditeurJS React (edit / save / register)
editorStyleÉditeurCSS spécifique à l'interface d'édition
styleÉditeur + frontCSS partagé (styles visuels du bloc)
viewScriptFront uniquementJS interactif côté visiteur
viewScriptModuleFront uniquementInteractivity API (data-wp-*, ESM)
renderServeurFichier PHP pour les blocs dynamiques

Enregistrement PHP

Préférer register_block_type_from_metadata()block.json fait autorité pour les assets et la configuration. Voir plus bas pour l'API en lot WP 6.8+ plus performante.

// ljd-blocks.php — fallback WP < 6.8
add_action('init', function (): void {
    $blocks_dir = __DIR__ . '/build/blocks/';
    foreach (glob($blocks_dir . '*', GLOB_ONLYDIR) as $block_dir) {
        register_block_type_from_metadata($block_dir);
    }
});

Wrapper — useBlockProps() obligatoire

Le wrapper du bloc doit utiliser useBlockProps() en édition et useBlockProps.save() en sauvegarde — c'est la seule façon pour les supports (couleur, espacement, bordures…) de générer les classes et styles corrects :

// edit.js
import { useBlockProps } from '@wordpress/block-editor';

export default function Edit({ attributes }) {
    const blockProps = useBlockProps();
    return <div { ...blockProps }>{ attributes.titre }</div>;
}
// save.js
import { useBlockProps } from '@wordpress/block-editor';

export default function save({ attributes }) {
    return <div { ...useBlockProps.save() }>{ attributes.titre }</div>;
}

Bloc dynamique — render.php

Pour un bloc dont le rendu dépend de données WP (meta fields, posts…), déclarer render dans block.json. WordPress injecte automatiquement $attributes, $content et $block dans le fichier :

// block.json — version dynamique
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name":         "ljd/news-card",
  "render":        "file:./render.php",
  "editorScript": "file:./index.js"
}
// render.php — $attributes, $content, $block injectés par WordPress
<div <?php echo get_block_wrapper_attributes(); ?>>
    <?php echo esc_html( $attributes['titre'] ?? '' ); ?>
</div>
// save.js — bloc dynamique : retourner null (nouveau bloc uniquement)
export default function save() {
    return null; // rendu entièrement délégué à render.php
}
Statique ou dynamique ? Bloc statique (save.js) si le rendu est 100 % éditeur et ne dépend d'aucune donnée WP. Bloc dynamique (render.php) si le contenu provient de meta fields, de posts liés — dans ce cas save() retourne null.

Interactivity API — @wordpress/interactivity WP 6.5+

Pour tout bloc nécessitant un état côté front (accordéon, onglets, filtres, lightbox…), préférer l'Interactivity API au JS vanilla. Elle expose un state réactif déclaratif via des directives data-wp-*, compatible avec le SSR et le cache full-page.

// block.json — déclarer le bloc comme interactif
{
  "supports":         { "interactivity": true },
  "viewScriptModule": "file:./view.js"
}
<!-- render.php — markup avec directives -->
<div
  data-wp-interactive="ljd/accordion"
  data-wp-context='{"isOpen": false}'
  <?php echo get_block_wrapper_attributes(); ?>>
  <button data-wp-on--click="actions.toggle">Ouvrir</button>
  <div data-wp-bind--hidden="!context.isOpen">...</div>
</div>
// view.js — store déclaré avec le namespace du bloc
import { store, getContext } from '@wordpress/interactivity';

store('ljd/accordion', {
    actions: {
        toggle() {
            const context = getContext();
            context.isOpen = !context.isOpen;
        },
    },
});
Interactivity API vs JS vanilla Préférer l'Interactivity API à tout JS vanilla pour les blocs avec état côté front. Le module @wordpress/interactivity est partagé entre tous les blocs — une seule instance chargée par page. Les directives data-wp-* sont SSR-compatibles : le markup initial est rendu côté serveur et hydraté côté client sans layout shift.

InnerBlocks — blocs conteneurs

Pour un bloc qui imbrique d'autres blocs, combiner useInnerBlocksProps() avec useBlockProps() afin que les supports (espacement, couleur…) s'appliquent correctement au wrapper :

// edit.js — conteneur avec template verrouillé
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';

const TEMPLATE = [
    ['core/heading',   { level: 2, placeholder: 'Titre', metadata: { name: 'Titre' } }],
    ['core/paragraph', { placeholder: 'Description', metadata: { name: 'Description' } }],
];

export default function Edit() {
    const blockProps     = useBlockProps();
    const innerBlocksProps = useInnerBlocksProps(blockProps, {
        template:     TEMPLATE,
        templateLock: 'all', // 'all' | 'insert' | false
        allowedBlocks: ['core/heading', 'core/paragraph'],
    });
    return <div { ...innerBlocksProps } />;
}
// save.js — les inner blocks sont sérialisés via InnerBlocks.Content
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';

export default function Save() {
    return <div { ...useBlockProps.save() }><InnerBlocks.Content /></div>;
}
metadata.name sur les blocs internes Ajouter metadata: { name: "Titre" } sur les blocs du template pour les labelliser dans l'arbre de blocs — améliore l'UX éditeur sans impact sur le rendu.

Migrations et dépréciations — éviter « Invalid block »

Toute modification du HTML sauvegardé (save.js) ou des attributs d'un bloc déjà en production doit être accompagnée d'une entrée dans le tableau deprecated. WordPress parcourt ce tableau du plus récent au plus ancien jusqu'à trouver une version correspondant au HTML sauvegardé.

// index.js — tableau deprecated (plus récent → plus ancien)
registerBlockType('ljd/mon-bloc', {
    attributes: {
        titre:   { type: 'string', default: '' },
        couleur: { type: 'string', default: 'base' }, // nouvel attribut v2
    },
    edit, save,
    deprecated: [
        {
            // v1 : avant l'ajout de "couleur"
            attributes: { titre: { type: 'string', default: '' } },
            save({ attributes }) {
                return <div { ...useBlockProps.save() }>{ attributes.titre }</div>;
            },
            migrate({ titre }) {
                return { titre, couleur: 'base' }; // normalise vers le schéma actuel
            },
        },
    ],
});
Les dépréciations ne se chaînent pas WordPress compare chaque entrée deprecated directement contre le HTML original sauvegardé — elles ne s'appliquent pas en cascade. Conserver un fichier de fixtures pour chaque version : tests/fixtures/mon-bloc--v1.html. Référence : Block deprecation.
Convertir un bloc statique existant en dynamique Passer save() de retourner du JSX à retourner null sur un bloc déjà en production invalide tout le contenu existant. Ajouter une entrée deprecated avec l'ancien save(), ou fournir un script de migration WP-CLI pour chaque post affecté.

Blocs composés — sub-blocks/ + parent + allowedBlocks

Quand un bloc est constitué d'un parent et d'enfants (ex. kpisbiskpisbis-itemskpisbis-item), organiser les sous-blocs dans src/sub-blocks/ pour les séparer des blocs exposés :

// sub-blocks/kpisbis-item/block.json
{
  "parent":   ["ljd/kpisbis-items"], // visible uniquement dans le parent
  "supports": {
    "reusable": false        // empêche la sauvegarde comme bloc réutilisable
  }
}
// blocks/kpisbis/block.json — restreindre l'inserteur dans le parent
{
  "allowedBlocks": ["ljd/kpisbis-items", "core/heading", "core/paragraph"]
}

Contexte parent → enfant — providesContext / usesContext

Transmettre une valeur d'un bloc parent vers tous ses descendants sans prop-drilling :

// blocks/kpisbis/block.json — expose theme_variant comme contexte
{
  "providesContext": { "ljd/themeVariant": "theme_variant" }
}

// sub-blocks/kpisbis-item/block.json — consomme le contexte
{
  "usesContext": ["ljd/themeVariant"]
}
// sub-blocks/kpisbis-item/edit.js
import { useBlockProps } from '@wordpress/block-editor';

export default function Edit({ context }) {
    const { 'ljd/themeVariant': themeVariant } = context;
    const blockProps = useBlockProps({ className: `theme-${themeVariant}` });
    return <div { ...blockProps } />;
}

Enregistrement en lot — wp_register_block_types_from_metadata_collection()

WP 6.8+ expose une API de registration en lot avec un fichier blocks-manifest.php généré par @wordpress/scripts. Plus rapide qu'une boucle glob() car le manifest est pré-indexé :

// ljd-blocks.php — enregistrement optimisé WP 6.8+
add_action('init', function (): void {
    $manifest = __DIR__ . '/build/blocks-manifest.php';

    if (
        function_exists('wp_register_block_types_from_metadata_collection') &&
        file_exists($manifest)
    ) {
        foreach ([__DIR__ . '/build/blocks', __DIR__ . '/build/sub-blocks'] as $dir) {
            if (is_dir($dir)) {
                wp_register_block_types_from_metadata_collection(
                    $dir,
                    $manifest
                );
            }
        }
        return;
    }
    // fallback WP < 6.8 ou manifest absent
    foreach (glob(__DIR__ . '/build/blocks/*', GLOB_ONLYDIR) as $dir) {
        register_block_type_from_metadata($dir);
    }
});
// Le manifest est généré automatiquement dans build/ par --blocks-manifest
package.json// package.json — activer la génération du manifest (@wordpress/scripts ≥ 28)
{
  "scripts": {
    "build":  "wp-scripts build --blocks-manifest",
    "start":  "wp-scripts start --blocks-manifest"
  }
}
build/blocks-manifest.php — gitignored Ce fichier est généré à chaque build et ne doit pas être versionné. Le flag --blocks-manifest est disponible à partir de @wordpress/scripts v28.

Filtres PHP utiles sur les blocs

// Restreindre les supports selon le rôle utilisateur
add_filter('block_type_metadata', function ($args) {
    if ($args['name'] === 'ljd/textbis' && !current_user_can('administrator')) {
        unset($args['supports']['spacing']);
        unset($args['supports']['color']);
    }
    return $args;
});

// Injecter un attribut par défaut au runtime (ex. theme actif)
add_filter('register_block_type_args', function ($args, $name) {
    if ($name === 'ljd/kpisbis') {
        $args['attributes']['theme_variant']['default'] = get_stylesheet();
    }
    return $args;
}, 10, 2);

__experimentalDefaultControls et __experimentalSelector

APIs expérimentales — stabilité non garantie Ces clés sont préfixées __experimental : leur signature peut changer ou disparaître sans dépréciation préalable entre versions mineures de Gutenberg / WordPress. À utiliser en connaissance de cause — tester après chaque mise à jour majeure.

Contrôler quels contrôles de support sont visibles par défaut dans l'inspecteur, et restreindre l'application CSS du support à des éléments spécifiques :

"supports": {
  "color": {
    "background": true,
    "text": true,
    "__experimentalDefaultControls": {
      "background": true,   // visible par défaut dans l'inspecteur
      "text": false         // masqué par défaut
    }
  },
  "spacing": {
    "padding": true, "margin": true, "blockGap": true,
    "__experimentalDefaultControls": { "padding": true, "margin": true, "blockGap": false }
  },
  // Restreindre le sélecteur CSS généré par les supports
  "__experimentalSelector": ".wp-block-ljd-kpisbis h2, .wp-block-ljd-kpisbis p"
}
__experimentalSelector Par défaut, les supports de typographie et de couleur génèrent des CSS sur le wrapper du bloc. Avec __experimentalSelector, on cible uniquement les éléments voulus — utile pour éviter des conflits CSS sur les blocs composés.

Block Styles (variations CSS)

Ajouter une variation visuelle à un bloc natif sans React via register_block_style() dans une Feature du thème :

// src/Features/BlockStyles.php
register_block_style('core/button', [
    'name'  => 'accent',
    'label' => 'Accent',
]);
// → classe .is-style-accent disponible dans l'éditeur

Style Variations — styles/*.json

Les style variations proposent des variantes visuelles globales du site (palette sombre, contraste élevé…) sélectionnables via Apparence → Éditeur → Styles. Ce sont des fichiers JSON dans styles/ du thème pouvant surcharger n'importe quelle section de theme.json.

📁 lajungle-fse/styles/dark.json
{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "title": "Sombre",
  "styles": {
    "color": {
      "background": "var:preset|color|contrast",
      "text":       "var:preset|color|base"
    }
  }
}
Variations stockées en base — pas seulement dans le fichier Une fois qu'un utilisateur sélectionne une variation dans le Site Editor, son choix est stocké en base de données et prend la priorité sur le fichier JSON. Modifier le fichier ne met pas à jour ce que l'utilisateur a déjà choisi. Pour tester : Apparence → Éditeur → Styles → ⋮ → Réinitialiser les styles.

Extensions de blocs natifs — @wordpress/hooks

Pour ajouter des fonctionnalités à un bloc existant (ex. une icône sur core/button) sans le forker, WordPress expose trois filtres JS à chaîner :

FiltreRôle
blocks.registerBlockTypeAjouter des attributs au bloc cible
editor.BlockEditEnvelopper le composant éditeur (HOC) pour ajouter des contrôles dans la sidebar
blocks.getSaveElementModifier le HTML sérialisé en base
import { addFilter } from '@wordpress/hooks';
import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl } from '@wordpress/components';
import { createHigherOrderComponent } from '@wordpress/compose';
import { cloneElement } from '@wordpress/element';

// 1. Nouveaux attributs sur core/button
addFilter('blocks.registerBlockType', 'mon-plugin/button-attrs', (settings, name) => {
    if (name !== 'core/button') return settings;
    return {
        ...settings,
        attributes: {
            ...settings.attributes,
            iconName:     { type: 'string', default: '' },
            iconPosition: { type: 'string', default: 'right' },
            iconSize:     { type: 'number', default: 16 },
        },
    };
});

// 2. Panneau sidebar dans l'éditeur (Higher Order Component)
const withIconControl = createHigherOrderComponent((BlockEdit) => {
    return (props) => {
        if (props.name !== 'core/button') return <BlockEdit {...props} />;
        const { attributes, setAttributes } = props;
        return (
            <>
                <BlockEdit {...props} />
                <InspectorControls>
                    <PanelBody title="Icône" initialOpen={false}>
                        <SelectControl
                            label="Position"
                            value={attributes.iconPosition}
                            options={[{ label: 'Gauche', value: 'left' }, { label: 'Droite', value: 'right' }]}
                            onChange={(v) => setAttributes({ iconPosition: v })}
                        />
                    </PanelBody>
                </InspectorControls>
            </>
        );
    };
}, 'withIconControl');

addFilter('editor.BlockEdit', 'mon-plugin/button-edit', withIconControl);

// 3. HTML sauvegardé : ajouter classes et CSS custom properties inline
addFilter('blocks.getSaveElement', 'mon-plugin/button-save', (element, blockType, attributes) => {
    if (blockType.name !== 'core/button' || !attributes.iconName) return element;
    const inner = element.props.children;
    return cloneElement(element, {},
        cloneElement(inner, {
            className: `${inner?.props?.className ?? ''} has-icon icon-pos-${attributes.iconPosition}`.trim(),
            style: { ...inner?.props?.style, '--icon-size': `${attributes.iconSize}px` },
        })
    );
});
blocks.getSaveElement sur les blocs core — risque de validation Ce filtre modifie le HTML sérialisé. Si WordPress met à jour la structure de save() du bloc core ciblé (ex. core/button), tous les contenus existants seront marqués "Invalid block" à l'ouverture dans l'éditeur. Utiliser uniquement si le bloc ciblé est stable (blocs custom) ou prévoir un deprecated() en amont. GitHub issue #28509 documente ce risque sur core/button.
Cas concret — icône IcoMoon sur core/button (ve-brands) L'extension injecte les icônes via wp_localize_script côté PHP (window.ljdIcomoonData alimenté depuis le selection.json du thème actif). Le SVG est rendu en CSS via mask-image: url("data:image/svg+xml,…") et background-color: currentColor — une seule couleur, héritée du texte du bouton. Les classes has-button-icon, btn-icon-pos-left/right et les custom properties --btn-icon / --btn-icon-size sont posées à la fois en éditeur (via DOM direct + RAF) et dans le HTML sauvegardé (via getSaveElement).
CSS dans l'iframe éditeur (WP 7.0) WP 7.0 active l'iframe systématiquement (indépendamment de l'apiVersion — voir callout apiVersion 3 ci-dessus) — les styles du document parent ne s'y propagent pas. Injecter le CSS manuellement via useEffect : wrapperRef.current?.ownerDocument donne accès au bon document (iframe ou principal), puis créer un <style> dans son <head>.

Plugin — ljd-blocks — Architecture

Structure recommandée

ljd-blocks/
├── ljd-blocks.php   ← point d'entrée PHP, registration auto des blocs
├── build/            ← assets compilés (gitignored)
├── assets/           ← fichiers statiques non traités par le build
├── docs/
└── src/
    ├── blocks/           ← source de vérité de tous les blocs custom
    │   ├── timelinebis/
    │   ├── kpisbis/
    │   └── ...
    ├── shared/           ← structures internes réutilisables (non exposées WP)
    │   └── section-header.js
    ├── components/       ← vrais composants React partagés entre blocs
    ├── hooks/            ← hooks React custom réutilisés dans plusieurs blocs
    ├── extensions/       ← enrichissements de l'éditeur et des blocs core
    ├── styles/           ← SCSS mutualisé (outils, variables editor)
    └── lib/              ← fonctions techniques pures sans dépendance React

Rôle de chaque dossier

DossierContenuRègle
src/blocks/Un dossier par bloc — block.json, edit.js, save.js / render.php, stylesTout ce qui est spécifique à un bloc reste dedans
src/shared/Structures InnerBlocks, layouts réutilisés par plusieurs blocsNon exposé comme objet WordPress autonome — ne pas appeler templates/ ni patterns/
src/components/Composants React partagés : inspector controls réutilisables, previews complexesPas de structure InnerBlocks simple ici
src/hooks/Hooks React custom : usePostSearch, useTimelinePostsUniquement React — pas de fonctions utilitaires
src/extensions/Extensions editor globales, variations, filtres blocs coreDéjà en place — conserver tel quel
src/styles/SCSS mutualisé : outils, variables editor, styles multi-blocsLes styles propres à un bloc restent dans src/blocks/<bloc>/
src/lib/Helpers techniques purs : normalisation, mapping, petites fonctions sans ReactPréférer lib/ à utils/utils/ devient toujours un fourre-tout

Vocabulaire — termes à respecter

Pour éviter la confusion avec l'UI WordPress :

TermeStatutSignification dans le plugin
blocOKBloc Gutenberg exposé dans l'inserteur — timelinebis est un bloc
extensionOKEnrichissement d'un bloc existant ou de l'éditeur — button-icon est une extension
sharedOKStructure interne réutilisable — section-header.js est un shared
templateRéservé WPTerme FSE natif — ne pas l'utiliser pour des sous-dossiers internes du plugin
patternRéservé WPComposition exposée dans les patterns WordPress — ne pas renommer des composants internes "pattern"
Règle de décision — bloc ou pattern ? Utiliser un bloc si l'élément est exposé comme composant métier autonome dans l'inserteur, avec son propre block.json, ses propres attributs et sa propre logique — même s'il arrive déjà assemblé et pré-rempli à l'insertion. Utiliser un pattern si l'objectif est uniquement de proposer une composition éditoriale de départ, sans composant métier autonome à gouverner.

Exemples : timelinebis = bloc même s'il injecte un section-header. Une composition "Hero + CTA + image" sans block.json dédié = pattern.
Migration vers src/blocks/ Ordre recommandé : créer src/blocks/ → déplacer les blocs → stabiliser src/shared/ → reclasser components/, hooks/ → vider utils/ vers src/lib/. Chaque déplacement doit être coordonné avec les chemins webpack et les imports PHP des render.php.

Plugin ljd-core

Le plugin fournit une architecture orientée objet pour organiser les fonctionnalités WordPress. Chaque fonctionnalité est une classe qui étend AbstractFeature.

AbstractFeature

Interface minimale — implémenter uniquement register() pour enregistrer les hooks :

abstract class AbstractFeature
{
    abstract public function register(): void;
}

Exemple d'une Feature du thème :

class PatternCategories extends AbstractFeature
{
    public function register(): void
    {
        add_action('init', [$this, 'registerCategories']);
    }

    public function registerCategories(): void
    {
        register_block_pattern_category('ljd/heros', ['label' => 'Héros']);
        register_block_pattern_category('ljd/cards', ['label' => 'Cartes']);
    }
}

Bootstrap — chargement des features

// functions.php
$feature_classes = require __DIR__ . '/config/features.php';

foreach ($feature_classes as $class) {
    if (class_exists($class)) {   // protège si le plugin est désactivé
        (new $class())->register();
    }
}

Meta Fields (sans ACF)

Les champs personnalisés sont déclarés en PHP et apparaissent dans la sidebar de l'éditeur :

class JobOfferFields extends AbstractPostTypeFields
{
    protected function fields(): array
    {
        return [
            new TextField('contrat',  'Type de contrat'),
            new TextField('lieu',      'Lieu'),
            new ImageField('logo',      'Logo entreprise'),
            new NumberField('order',    'Ordre d\'affichage'),
        ];
    }
}

Types disponibles : TextField · NumberField · ImageField · Tab

Block Bindings (WP 6.5+)

Lier un meta field à un bloc natif sans PHP supplémentaire de rendu :

<!-- wp:paragraph {
  "metadata": {
    "bindings": {
      "content": {
        "source": "core/post-meta",
        "args": { "key": "contrat" }
      }
    }
  }
} -->
<p></p>
<!-- /wp:paragraph -->
Block Bindings — prérequis et limitations

CPT & Taxonomies

Chaque Custom Post Type est une classe qui étend AbstractCPT. La classe parent gère l'enregistrement WordPress ; la sous-classe déclare uniquement les données métier.

Créer un CPT

// src/CPT/EventCPT.php
class EventCPT extends AbstractCPT
{
    public function getPostType(): string      { return 'event'; }
    public function getSingularLabel(): string { return 'Événement'; }
    public function getPluralLabel(): string   { return 'Événements'; }
    public function getMenuIcon(): string      { return 'dashicons-calendar-alt'; }

    // Taxonomie attachée
    public function getTaxonomySlug(): string          { return 'event_type'; }
    public function getTaxonomySingularLabel(): string { return 'Type d\'événement'; }
    public function getTaxonomyPluralLabel(): string   { return 'Types d\'événement'; }
}
// Puis l'ajouter dans config/features.php

Options AbstractCPT

MéthodeObligatoireDéfautDescription
getPostType()OuiSlug du CPT
getSingularLabel()OuiLabel singulier
getPluralLabel()OuiLabel pluriel
getMenuIcon()Nondashicons-admin-postIcône menu WP
getSupports()Nontitle, editor, thumbnail…Supports du CPT
hasArchive()NontrueActive la page archive
showInRest()NontrueExposé dans l'API REST
getRewriteSlug()NongetPostType()Slug URL personnalisé
getTaxonomySlug()NonnullActive une taxonomie si non-null
Flush des permaliens obligatoire Après toute création ou modification de CPT : make wp cmd="rewrite flush" ou Réglages → Permaliens.