WordPress 13 min de lecture

Rendre un bloc Gutenberg dynamique avec Timber/Twig (SSR + cache)

#cache#gutenberg#ssr#timber#twig#wordpress

Créez un bloc Gutenberg dynamique sous WordPress avec Timber/Twig en SSR. Guide complet: block.json, render_callback, médias, et cache performant.

Rendre un bloc Gutenberg dynamique avec Timber/Twig (SSR + cache)

Dans ce tutoriel, vous allez créer un bloc Gutenberg dynamique rendu côté serveur avec Timber v2 et Twig. L’objectif est de livrer un bloc prêt pour l’éditeur, accessible, performant, avec un cache de sortie HTML maîtrisé. Vous partirez du block.json pour aller jusqu’au template Twig, en passant par le render_callback PHP, la résolution des médias et la gestion de l’éditeur.

Objectif

L’objectif est d’implémenter un bloc WordPress rendu côté serveur (SSR) avec Timber v2 et Twig. Vous apprendrez à produire un HTML fiable côté PHP, sans dépendre d’un rendu React, tout en restant pleinement compatible avec l’éditeur de blocs. La chaîne complète comprendra la déclaration du bloc via block.json, l’enregistrement côté PHP, le routage vers un template Twig, ainsi que la prise en charge des assets et du cache.

Objectif

Vous allez créer un bloc Gutenberg dynamique dont le rendu est assuré par Twig à travers Timber v2. Vous gérerez les attributs du bloc, la résolution des images à partir d’un ID de média, le comportement en mode éditeur (prévisualisation sans cache) et la mise en cache du HTML côté front pour des performances optimales.

Pré-requis et installation

Vous avez besoin d’un WordPress en version 6.3 ou supérieure et de PHP 8.1 minimum. Assurez-vous également que Composer est installé sur votre machine ou dans votre environnement d’intégration. Commencez par installer Timber v2 via Composer dans votre thème ou, de préférence, dans un plugin dédié afin de garder un code réutilisable.

composer require timber/timber:^2

Dans votre thème (par exemple functions.php), initialisez Timber avec un bootstrap minimal. L’instruction suivante indique à Timber où chercher vos templates Twig au sein du thème actif.

<?php
// functions.php (du thème)
use Timber\Timber;

add_action('after_setup_theme', function () {
    Timber::$dirname = ['templates', 'views']; // le template Twig du bloc se trouvera dans templates/blocks/hero.twig
});

Cette configuration permet d’écrire vos gabarits Twig dans le dossier templates du thème. Timber se chargera d’y chercher le fichier de bloc.

Structure minimale du projet

Pour organiser proprement le code, créez un plugin dédié dans wp-content/plugins/my-timber-blocks. Ce plugin enregistrera le bloc et ses assets, tandis que le template Twig résidera dans le thème actif. Une structure de départ claire ressemble à ceci : un répertoire de plugin my-timber-blocks avec un fichier d’entrée my-timber-blocks.php, ainsi qu’un sous-dossier blocks/hero contenant le block.json. Côté thème, un répertoire templates/blocks contiendra hero.twig. Cette séparation rend le bloc portable et facilite la maintenance.

Un exemple concret :

  • Plugin: wp-content/plugins/my-timber-blocks/
    • Fichier principal: my-timber-blocks.php
    • Bloc: blocks/hero/block.json
    • Assets (optionnel mais recommandé): assets/css/hero.css, assets/css/hero.editor.css, assets/js/hero.view.js
  • Thème: wp-content/themes/votre-theme/
    • Templates Twig: templates/blocks/hero.twig

Déclarer le bloc (block.json)

Créez le fichier block.json dans wp-content/plugins/my-timber-blocks/blocks/hero/block.json. Définissez le nom unique du bloc, son titre, sa catégorie et son icône. Activez l’API v2 et exposez les attributs nécessaires pour votre section héro : un titre, un sous-titre, un identifiant de média pour l’image, ainsi qu’un libellé et une URL de bouton. Activez également les supports utiles (align, anchor, className). Reliez vos assets en utilisant des handles que vous enregistrerez côté PHP. Ne déclarez pas de propriété render dans ce block.json : vous utiliserez register_block_type_from_metadata() en PHP avec un render_callback.

{
  "apiVersion": 2,
  "name": "mytb/hero",
  "title": "Hero Timber",
  "category": "widgets",
  "icon": "cover-image",
  "description": "Section héro rendue côté serveur avec Timber/Twig.",
  "supports": {
    "align": ["wide", "full"],
    "anchor": true,
    "className": true
  },
  "attributes": {
    "title": { "type": "string", "default": "" },
    "subtitle": { "type": "string", "default": "" },
    "imageId": { "type": "number", "default": 0 },
    "ctaLabel": { "type": "string", "default": "" },
    "ctaUrl": { "type": "string", "default": "" },
    "align": { "type": "string", "enum": ["", "wide", "full"], "default": "" }
  },
  "style": "mytb-hero-style",
  "editorStyle": "mytb-hero-editor",
  "viewScript": "mytb-hero-view",
  "textdomain": "my-timber-blocks"
}

Ce fichier sert de source de vérité pour l’éditeur. Les attributs seront transmis au callback PHP lors du rendu, et les supports activeront automatiquement des fonctionnalités comme les largeurs alignwide et alignfull.

Enregistrer le bloc côté PHP

Dans le fichier my-timber-blocks.php de votre plugin, enregistrez d’abord vos styles et scripts avec wp_register_style et wp_register_script. Ensuite, appelez register_block_type_from_metadata() pour déclarer le bloc en lui associant un render_callback. Ce callback construira le contexte et rendra le template Twig. Enfin, exposez explicitement la fonction de rendu mytb_render_hero avec la signature recommandée.

<?php
/**
 * Plugin Name: My Timber Blocks
 * Description: Blocs Gutenberg dynamiques rendus avec Timber/Twig.
 * Version: 1.0.0
 * Author: Vous
 * Text Domain: my-timber-blocks
 */

use Timber\Timber;

if (!defined('ABSPATH')) {
    exit;
}

add_action('init', function () {
    $plugin_url  = plugin_dir_url(__FILE__);
    $plugin_path = plugin_dir_path(__FILE__);

    // 1) Enregistrer les assets référençés par block.json
    wp_register_style(
        'mytb-hero-style',
        $plugin_url . 'assets/css/hero.css',
        [],
        file_exists($plugin_path . 'assets/css/hero.css') ? filemtime($plugin_path . 'assets/css/hero.css') : null
    );

    wp_register_style(
        'mytb-hero-editor',
        $plugin_url . 'assets/css/hero.editor.css',
        ['wp-edit-blocks'],
        file_exists($plugin_path . 'assets/css/hero.editor.css') ? filemtime($plugin_path . 'assets/css/hero.editor.css') : null
    );

    wp_register_script(
        'mytb-hero-view',
        $plugin_url . 'assets/js/hero.view.js',
        [],
        file_exists($plugin_path . 'assets/js/hero.view.js') ? filemtime($plugin_path . 'assets/js/hero.view.js') : null,
        true
    );

    // 2) Enregistrer le bloc à partir des métadonnées
    register_block_type_from_metadata(
        __DIR__ . '/blocks/hero',
        [
            'render_callback' => 'mytb_render_hero',
        ]
    );
});

/**
 * Callback de rendu du bloc "mytb/hero".
 *
 * @param array    $attributes
 * @param string   $content
 * @param \WP_Block $block
 * @return string
 */
function mytb_render_hero(array $attributes, string $content, $block): string
{
    if (!class_exists(Timber::class)) {
        return '';
    }

    $context = Timber::context();

    // Fusion des attributs avec des valeurs par défaut
    $attrs = wp_parse_args($attributes, [
        'title'    => '',
        'subtitle' => '',
        'imageId'  => 0,
        'ctaLabel' => '',
        'ctaUrl'   => '',
        'align'    => ''
    ]);

    // Détection d’un contexte éditeur/REST pour ajuster le cache et les placeholders
    $is_edit_context = is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST);

    // Placeholder léger en éditeur si vide (ne fuit pas en production)
    if ($is_edit_context && $attrs['title'] === '') {
        $attrs['title'] = 'Titre du héros';
    }

    // Résolution de l’image (Timber Attachment si présent)
    $image = null;
    if (!empty($attrs['imageId'])) {
        $image = Timber::get_attachment((int) $attrs['imageId']);
    }

    // Attributs de wrapper (ajoute les classes align, l’ancre, etc.)
    $wrapper = get_block_wrapper_attributes([
        'class' => 'hero-block',
    ]);

    // Contexte enrichi pour Twig
    $context['attrs']   = $attrs;
    $context['image']   = $image; // objet Timber\Image ou null
    $context['content'] = $content; // utile si vous utilisez InnerBlocks
    $context['wrapper'] = $wrapper;
    $context['post_id'] = $block->context['postId'] ?? 0;
    $context['name']    = $block->name;

    // Mise en cache côté front (désactivée en contexte éditeur)
    if ($is_edit_context) {
        return Timber::compile('blocks/hero.twig', $context);
    }

    $post_modified_gmt = '';
    if ($context['post_id']) {
        $p = get_post((int) $context['post_id']);
        $post_modified_gmt = $p ? (string) strtotime((string) $p->post_modified_gmt) : '';
    }

    $cache_key = implode(':', [
        'bloc',
        'hero',
        (string) $context['post_id'],
        (string) $context['name'],
        md5(wp_json_encode($attrs)),
        $post_modified_gmt,
    ]);

    $cached = wp_cache_get($cache_key, 'mytb');
    if ($cached !== false) {
        return (string) $cached;
    }

    $html = Timber::compile('blocks/hero.twig', $context);
    wp_cache_set($cache_key, $html, 'mytb', 600); // TTL 10 minutes

    return $html;
}

Cette approche enregistre les assets avant l’appel à register_block_type_from_metadata. Le render_callback assemble ensuite le contexte pour Twig, gère la résolution d’image via Timber et sécurise le cache uniquement en front.

Render callback avec Timber

Le render callback commence par préparer le contexte provenant de Timber::context(), puis fusionne les attributs du bloc avec des valeurs par défaut pour éviter les index manquants. Il résout ensuite l’image à partir de l’ID de media grâce à Timber::get_attachment, ce qui vous offre dans Twig des propriétés pratiques comme src et alt. L’appel à get_block_wrapper_attributes fournit une chaîne d’attributs HTML qui inclut notamment les classes de largeur alignwide ou alignfull si l’utilisateur les a choisies. Le callback renseigne également l’ID du post courant et le nom du bloc si vous souhaitez tracer ou composer une clé de cache. Enfin, il retourne le HTML compilé par Timber::compile à partir du template Twig spécifié.

Voici un extrait ciblé montrant la résolution de l’image et la gestion du wrapper :

$image = null;
if (!empty($attrs['imageId'])) {
    $image = Timber::get_attachment((int) $attrs['imageId']);
}

$wrapper = get_block_wrapper_attributes([
    'class' => 'hero-block',
]);

$context['image']   = $image;
$context['wrapper'] = $wrapper;

return Timber::compile('blocks/hero.twig', $context);

Ce code laisse à Twig la responsabilité de l’affichage conditionnel, ce qui garde le callback PHP concis et lisible.

Template Twig du bloc

Créez le template dans votre thème, par exemple wp-content/themes/votre-theme/templates/blocks/hero.twig. Utilisez le wrapper natif pour laisser WordPress gérer les classes d’alignement et l’ancre. Affichez les attributs en profitant de l’échappement automatique de Twig pour le HTML, et appliquez un échappement d’URL sur les liens. Le bouton ne doit s’afficher que si le libellé et l’URL sont fournis. Selon votre choix, l’image peut être rendue comme balise ou injectée en background.

{# templates/blocks/hero.twig #}
<section {{ wrapper|raw }}>
  <div class="hero-block__inner">
    {% if attrs.title %}
      <h2 class="hero-block__title">{{ attrs.title }}</h2>
    {% endif %}

    {% if attrs.subtitle %}
      <p class="hero-block__subtitle">{{ attrs.subtitle }}</p>
    {% endif %}

    {% if image %}
      <figure class="hero-block__media">
        <img
          src="{{ image.src }}"
          alt="{{ image.alt|default('') }}"
          loading="lazy"
          decoding="async"
        >
      </figure>
    {% endif %}

    {% if attrs.ctaLabel and attrs.ctaUrl %}
      <p class="hero-block__actions">
        <a
          class="hero-block__button"
          href="{{ attrs.ctaUrl|e('url') }}"
          rel="noopener"
        >{{ attrs.ctaLabel }}</a>
      </p>
    {% endif %}

    {# Optionnel: contenus enfants si vous utilisez InnerBlocks #}
    {% if content %}
      <div class="hero-block__content">
        {{ content|raw }}
      </div>
    {% endif %}
  </div>
</section>

Ce template isole la mise en forme via des classes BEM et évite les styles inline. Si vous souhaitez appliquer une image de fond, vous pouvez soit produire l’attribut style côté Twig avec attention à l’échappement, soit mieux, ajouter une classe et gérer le background depuis une feuille CSS.

Gestion éditeur vs front (prévisualisation)

En contexte éditeur, React affiche la prévisualisation du bloc via REST. Il est recommandé de désactiver la mise en cache pour présenter un retour immédiat à l’utilisateur. Le callback détecte l’éditeur via is_admin, wp_doing_ajax ou REST_REQUEST, et compile le template sans toucher au cache. Vous pouvez également fournir un texte par défaut non intrusif pour le titre en prévisualisation lorsque celui-ci est vide. En front, l’attribut align est déjà géré par le wrapper natif, ce qui applique automatiquement les classes alignwide ou alignfull. Si vous souhaitez vous y référer dans le CSS, utilisez simplement le sélecteur du wrapper et ciblez ces classes.

Mettre en cache le rendu HTML

Le cache de sortie HTML accélère les pages en évitant de recompiler Twig à chaque requête. La clé de cache doit être stable et suffisamment précise pour représenter l’état du bloc : concaténez un préfixe, l’ID de post, le nom technique du bloc, un hash JSON des attributs et une référence temporelle du post. L’ajout de post_modified_gmt dans la clé assure une invalidation automatique à chaque mise à jour. Utilisez l’object cache via wp_cache_get et wp_cache_set pour une compatibilité maximale avec un cache persistant comme Redis.

$post_modified_gmt = '';
if ($context['post_id']) {
    $p = get_post((int) $context['post_id']);
    $post_modified_gmt = $p ? (string) strtotime((string) $p->post_modified_gmt) : '';
}

$cache_key = implode(':', [
    'bloc', 'hero',
    (string) $context['post_id'],
    (string) $context['name'],
    md5(wp_json_encode($attrs)),
    $post_modified_gmt,
]);

if (!($is_edit_context)) {
    $cached = wp_cache_get($cache_key, 'mytb');
    if ($cached !== false) {
        return (string) $cached;
    }
}

En environnement d’édition, le code court-circuite le cache pour montrer immédiatement les modifications. Côté production, le TTL de 600 secondes suffit pour amortir la compilation tout en laissant une actualisation fréquente. Si vous préférez un contrôle encore plus strict, vous pouvez rajouter un nettoyage ciblé au hook clean_post_cache pour supprimer des entrées spécifiques de votre groupe de cache.

Assets du bloc

Les assets déclarés dans block.json sont référencés par des handles. Il suffit de les enregistrer avec wp_register_style et wp_register_script, comme montré plus haut, pour que WordPress les enfile automatiquement selon le contexte (éditeur ou front). Assurez-vous d’encapsuler vos styles sous le wrapper du bloc afin d’éviter les effets de bord sur d’autres composants. Un JavaScript de type viewScript peut apporter une amélioration progressive, par exemple une animation non bloquante ou une préférence utilisateur. Voici un exemple minimal pour le fichier assets/js/hero.view.js :

// assets/js/hero.view.js
(function () {
  const blocks = document.querySelectorAll('.hero-block');
  blocks.forEach((el) => {
    el.classList.add('is-ready');
  });
})();

Ce script se contente d’ajouter une classe d’état à l’affichage, illustrant ainsi une amélioration progressive qui ne remet pas en cause le SSR.

Tests rapides

Ouvrez une page dans l’éditeur, insérez le bloc Hero Timber et remplissez les champs. La prévisualisation doit refléter immédiatement vos modifications, sans latence notable. Visionnez ensuite la page côté front pour vérifier que la mise en page et les classes d’alignement sont conformes, y compris dans les cas où aucun média n’est fourni. Testez différents scénarios : absence d’image, image à grande résolution, alignements wide et full, ainsi que des chaînes avec caractères spéciaux pour confirmer que l’échappement est correct. Pour un contrôle des performances, mesurez le TTFB avec et sans cache :

# Mesure simple du TTFB
curl -s -o /dev/null -w "%{time_starttransfer}\n" "https://exemple.test/page-avec-bloc/"

Mettez ensuite à jour le post et rafraîchissez la même page pour confirmer que le cache s’invalide bien avec la nouvelle clé intégrant post_modified_gmt.

Aller plus loin

Pour accueillir des blocs enfants, utilisez InnerBlocks côté éditeur et passez le paramètre $content au contexte Twig, que vous rendrez avec le filtre |raw afin de ne pas double-échappper le HTML. Cette approche permet de construire des sections héro plus riches, composées d’éléments éditoriaux. Pour proposer des variations de style, ajoutez la propriété styles dans le block.json, puis conditionnez des classes supplémentaires dans Twig en fonction de la variation choisie. Si votre projet utilise ACF, vous pouvez court-circuiter les attributes JSON et alimenter attrs depuis get_field dans le render_callback, tout en conservant le SSR via Twig. Côté internationalisation, traduisez les libellés éditoriaux via __ en PHP et passez-les au template, et pour les scripts front, utilisez wp_set_script_translations afin d’assurer une localisation propre. Enfin, pour valider le SSR en CI, générez des instantanés HTML et comparez-les automatiquement. Par exemple, vous pouvez exécuter en ligne de commande un rendu de contenu via do_blocks :

# Exemple (adaptez l'ID du post et le contexte WordPress chargé)
wp eval 'echo do_blocks( get_post(123)->post_content );'

Ce test rapide peut être intégré dans une pipeline pour détecter les écarts inattendus de rendu.

Checklist

Avant de publier, relisez le block.json et le code PHP afin de vérifier la cohérence des handles, des attributs et des supports. Exécutez l’installation de Timber et testez les snippets fournis dans un environnement de développement, en validant que le template Twig est bien trouvé via Timber::$dirname. Lancez une série de tests manuels en éditeur et en front avec des jeux de données variés, y compris sans image, avec des caractères spéciaux et des URLs délibérément incorrectes afin de confirmer la robustesse de l’échappement. Contrôlez l’efficacité du cache en mesurant le TTFB, puis modifiez le post pour valider l’invalidation, et seulement ensuite publiez votre bloc en production.

Conclusion

Vous disposez maintenant d’un bloc Gutenberg dynamique, rendu côté serveur via Timber/Twig et optimisé avec un cache de sortie HTML. Cette architecture combine une excellente compatibilité éditeur, une sécurité solide grâce à l’échappement systématique, et des performances maîtrisées en production. La même méthode se décline facilement sur d’autres blocs, que vous pouvez enrichir avec InnerBlocks, des variations de styles, une intégration ACF ou des scripts d’amélioration progressive.

Ressources