Drupal 11 : une brique de paragraphes réutilisable avec leurs composants SDC

This article is also available in English

Tous les sites Drupal que je maintiens dans mon uzinasit finissent par avoir besoin des mêmes blocs de contenu : un texte riche, un texte avec une image, une galerie, une vidéo, une carte, un bouton, une citation. Jusqu'ici, chaque site les réécrivait à sa façon. J'ai finis par packager un ensemble config de paragraphe et composants SDC dans un module que je déploie sur l'ensemble des sites petits à petit.

Une brique en trois partie

La brique tient en trois éléments :

  • une recette Drupal (recipes/uzinasit_paragraphs) porte la config : les types de paragraphe, leurs champs, leurs form et view displays, les types de média nécessaires (image, remote_video) ;
  • un module porte le code : une classe de bundle par type, et le métier de chaque type ;
  • des composants SDC, dans ce même module, portent le rendu : un HTML neutre et une CSS minimale, fonctionnels sans thème.

Entre le contenu et le rendu, un seul point de contact : le template de liaison paragraph--<bundle>.html.twig.

Paragraphe (contenu)  →  champs  →  formateurs  →  template de liaison  →  composant SDC (rendu)
  bundle (recette)                  data.*         paragraph--<bundle>     uzinasit_paragraphs:<nom>
  + classe de bundle                content.*      (module)                (remplaçable par le thème)

Pourquoi une recette

Un profil d'installation ne sert qu'à la création d'un site. Une recette, elle, s'applique aussi à un site existant :

drush @monsite recipe ../recipes/uzinasit_paragraphs_page

Il y en a deux. La première crée les paragraphes sans rien supposer du site. La seconde, uzinasit_paragraphs_page, applique la première puis pose le champ field_paragraphs sur le type « page », en le créant s'il n'existe pas. Une fois la recette appliquée, c'est la config du site (drush cex) qui redevient la source de vérité : une recette ne se « désinstalle » pas, et on ne la réapplique pas par-dessus pour faire évoluer un type.

Tous les noms sont préfixés uzp_ (bundles) et field_uzp_ (champs) : un site qui a déjà son propre rich_text ou son field_text n'entre pas en collision avec la brique.

Dix types de paragraphes

  • texte riche, texte riche et image (à gauche ou à droite)
  • galerie d'images, avec visionneuse, légende par image et format grille ou bande
  • vidéo (YouTube)
  • carte OpenStreetMap
  • CTA (call to action)
  • citation
  • Bloc
  • ...

Chacun peut porter un titre éditorial commun (texte + niveau h2 à h4) qui est lui même un composant SDC. Un titre vide ne rend rien, pas même un conteneur.

Le template

Les valeurs brutes arrivent dans data grâce à raw_fields_formatters (des formateurs qui exposent la valeur d'un champ au lieu de la rendre), et passent au composant en props. Les champs déjà rendus (une vidéo oEmbed, par exemple) passent en slots. Exemple avec la citation :

{% set title %}
  {%- if data.title -%}
    {{ include('uzinasit_paragraphs:heading', {
      text: data.title,
      level: data.title_level|default(2),
    }, with_context: false) }}
  {%- endif -%}
{% endset %}
{% if data.quote %}
  {{ include('uzinasit_paragraphs:quote', {
    attributes: attributes.addClass(classes),
    quote: data.quote,
    author: data.author|default(''),
    title: title|trim ? title : '',
  }, with_context: false) }}
{% endif %}

Et le composant, lui, ne sait rien de Drupal : il ne lit jamais d'entité.

<figure{{ attributes.addClass('uzp-quote') }}>
  {{ title }}
  <blockquote class="uzp-quote__text">
    <p>{{ quote|nl2br }}</p>
  </blockquote>
  {% if author %}
    <figcaption class="uzp-quote__author">{{ author }}</figcaption>
  {% endif %}
</figure>

Chaque thème de site stylise ces composants, ou en remplace un complètement grâce à la clé replaces de SDC, sans toucher ni au module ni au contenu.

Les classes de bundle : le métier

Plus propre que l'utilisation de hook preprocess paragraph, cela permet de stocker ce qui est spécifique aux paragraphes.

#[Bundle(entityType: 'paragraph', bundle: 'uzp_cta')]
final class CtaParagraph extends UzpParagraphBase {

  public const FIELD_LINK = 'field_uzp_link';
  public const FIELD_STYLE = 'field_uzp_style';

  /**
   * Les styles permis, ceux de l'énumération du composant.
   */
  public const STYLES = ['primary', 'secondary'];

  public function getStyle(): string {
    $style = $this->hasField(self::FIELD_STYLE)
      ? (string) ($this->get(self::FIELD_STYLE)->value ?? '')
      : '';
    return in_array($style, self::STYLES, TRUE) ? $style : self::STYLES[0];
  }

  public function getComponentData(): array {
    return ['style' => $this->getStyle()] + parent::getComponentData();
  }

}

Chaque type a sa classe, toujours, même sans métier : elle porte au minimum les constantes de ses champs, et c'est elle qui décide si le titre éditorial commun s'applique (seuls les bundles dont la classe étend UzpParagraphBase le reçoivent).

Et ensuite

Je commence à réfléchir à packager cette brique, afin de la rendre utilisable sur d'autres sites. J'attends de fignoler tout ça, la peinture est encore fraîches.

Écrit par Kevin Gautreau — développeur & formateur PHP/Drupal freelance en Auvergne. me contacter

Contenus en rapport

article · 29 Mai 2026 Uzinasit : retour d'expérience sur mon usine à site Drupal 11 J'ai profité d'avoir un peu de temps durant l'été 2025 pour commencer à rationaliser l'ensemble de mes sites histoire de m'en simplifier la gestion Pourquoi… article · 13 Oct 2026 Drupal : raw_fields_formatters, des données brutes pour les composants SDC et fronts découplés Je travaille sur des drupal découplés depuis quelques années (back drupal pour la contrib, et front angular ou application mobile par exemple) dans ces cas là…

Commentaires (0)