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à on peut utiliser la json-api ou des recettes custom. Depuis que les Single-Directory Components (SDC) sont dans le core, j'écris mes composants comme n'importe quel composant front : des props typées (un titre, une URL d'image, une largeur, une liste…) et des slots pour le HTML riche. Le problème, c'est que Drupal, lui, ne sait donner que du HTML. Un champ image arrive dans le template sous forme de render array, prêt à produire une balise <img>, pas sous forme d'URL. Et on finit par écrire des preprocess pour aller récupérer l'URL du fichier, appliquer le style d'image, vérifier les droits sur le média, remettre les bons tags de cache…

Le même problème se pose, en plus aigu, quand Drupal ne fait pas le rendu du tout : un front découplé (Angular, React, une app mobile) attend du JSON propre, pas des render arrays ni des noms machine de champs. Et on le sait, Drupal est verbeux sur la structure du contenu... Difficile d'avoir un contrat d'API simple et limpide.

Pour simplifier tout ça, j'ai fini par faire un module : Raw Fields Formatters. Il fournit des formateurs de champ qui ne produisent aucun balisage, seulement des valeurs typées, rassemblées dans un tableau data. Ce tableau sert aussi bien de props à un composant SDC que de réponse JSON à un front découplé.

Le principe

On choisit, dans « Gérer l'affichage », un formateur raw_* pour chaque champ qu'on veut exposer. Le template de l'entité reçoit alors une variable data :

{{ data.title }}
{{ data.image.url }}

Rien d'autre à écrire. Une valeur unique est déballée, plusieurs valeurs donnent une liste et un champ vide vaut NULL.

Les valeurs sont typées : un booléen est un vrai true, un entier un vrai int. Les champs plus riches sortent sous forme de tableau :

// Raw Image URL : les dimensions suivent le style d'image choisi.
['url' => 'https://…/image.webp', 'alt' => 'Un chat', 'title' => '', 'width' => 600, 'height' => 400]

// Raw URL
['url' => '/blog/un-titre', 'uri' => 'entity:node/12', 'title' => 'Lire la suite', 'external' => FALSE]

// Raw Formatted Text : le format de texte est appliqué, "plain" est le texte sans balises.
['value' => '<p>Du texte</p>', 'plain' => 'Du texte', 'format' => 'basic_html']

Les formateurs disponibles couvrent les types de champs courants : texte simple, texte formaté, booléen, entier, décimal, liste, lien, image, fichier, référence d'entité, paragraphes, et deux types contrib (icône UI Icons, et Geolocation). Les références d'entité et les paragraphes imbriquent les données de l'entité cible, construites à partir du mode d'affichage choisi dans le formateur.

data.title, et plus data.field_title

C'est pour moi le vrai intérêt du module. Chaque formateur a un réglage Override the default key with qui renomme la clé dans data. On ne manipule donc plus les noms machine de Drupal (field_titre, field_uzp_gallery, field_media_image…) mais les noms du composant ou du contrat d'API : title, images, image.

ChampCléDans data
field_titretitledata.title
field_visuelimagedata.image.url
field_uzp_galleryimagesdata.images

Ce que ça change :

  • Le template ou le front ne connaît plus le modèle Drupal. Un composant card attend title, image, link, quel que soit le type de contenu qui l'alimente : un article avec field_titre et une page avec field_heading donnent la même structure, et le composant sert aux deux ;
  • Le contrat survit aux refontes : renommer ou remplacer un champ côté Drupal ne casse ni les templates ni l'application front, il suffit de reporter la clé sur le nouveau champ dans « Gérer l'affichage » ;
  • Le contrat se lit dans le back-office : le résumé de chaque formateur affiche la clé exposée. C'est le mode d'affichage qui décide de ce qui sort et sous quel nom.

Le second réglage commun, Force as array, stabilise la forme : il renvoie toujours une liste, même avec une seule valeur. Sans lui, un nœud avec un seul tag donne une chaîne, et le {% for tag in tags %} du template (ou le @for d'Angular) boucle sur les caractères.

Installation

composer require drupal/raw_fields_formatters
drush en raw_fields_formatters

Le module ne dépend que du core : Drupal 11 (PHP 8.3) ou Drupal 12 (PHP 8.5). Chaque formateur n'apparaît que si le module qui fournit son type de champ est présent.

Sur Drupal 12, le type de champ « texte avec résumé » (text_with_summary) a quitté le core : il vient désormais du module contrib Text With Summary, à installer pour garder Raw Formatted Text sur ces champs.

Premier usage : alimenter des composants SDC

Sur mon infrastructure uzinasit, toutes les briques de contenu sont des paragraphes rendus par des composants SDC. Prenons la galerie : un titre, un niveau de titre et une liste de médias image.

Dans « Gérer l'affichage » du paragraphe, chaque champ reçoit son formateur brut et une clé qui correspond au nom de la prop. Le champ des médias utilise Raw Entity, avec Force as array (une galerie d'une seule image reste une liste) et un mode d'affichage dédié du média, « Données brutes (paragraphes) » :

Écran « Gérer l'affichage » du paragraphe galerie : le titre en Raw String, le niveau de titre en Raw List Value et les médias en Raw Entity, chacun avec sa clé

Côté média, ce mode d'affichage n'expose qu'un champ : l'image, avec Raw Image URL. Le formateur sait exposer plusieurs styles d'image à la fois : un style principal, et des styles supplémentaires cochés chacun sous sa propre clé.

Écran « Gérer l'affichage » du média image, mode d'affichage Données brutes : seul le champ image est affiché, avec le formateur Raw Image URL
Réglages du formateur Raw Image URL : un style d'image principal et des styles supplémentaires cochés, chacun avec la clé sous laquelle il est exposé

Avec un style principal « Large (1090) » et trois styles supplémentaires (Large 480×480, Large ×2 960×960 et Moyen 220×220), chaque image de la galerie arrive dans data.images sous cette forme :

[
  'url' => 'https://…/styles/large_1090/…',
  'alt' => '…',
  'width' => …,
  'height' => …,
  'large' => ['url' => '…', 'width' => …, 'height' => …],
  'large_x2' => ['url' => '…', 'width' => …, 'height' => …],
  'medium' => ['url' => '…', 'width' => …, 'height' => …],
]

Les largeurs et hauteurs sont celles que le style d'image calcule pour chaque fichier. Chaque style supplémentaire est un tableau url / width / height, jamais une URL seule : les dimensions doivent voyager avec l'URL à laquelle elles appartiennent.

Le template du paragraphe : du mapping, pas du rendu

Le template du paragraphe ne rend aucun champ. Il se contente de faire correspondre data aux props du composant :

{# paragraph--uzp-gallery.html.twig #}
{% set title %}
  {%- if data.title -%}
    {{ include('uzinasit_paragraphs:heading', {
      text: data.title,
      level: data.title_level|default(2),
    }, with_context: false) }}
  {%- endif -%}
{% endset %}
{{ include('uzinasit_paragraphs:gallery', {
  attributes: attributes.addClass(classes),
  images: data.images|default([]),
  title: title|trim ? title : '',
}, with_context: false) }}

Le composant : un srcset sans preprocess

Le composant, lui, ne sait rien de Drupal. Il reçoit une liste d'images et prend le style large pour la vignette, large_x2 comme second candidat du srcset, et l'image principale pour le lien :

{# components/gallery/gallery.twig (extrait) #}
{% for image in images %}
  {% set thumbnail = image.large.url is defined ? image.large : image %}
  <li class="uzp-gallery__item">
    <a class="uzp-gallery__link" href="{{ image.url }}">
      <img
        src="{{ thumbnail.url }}"
        {%- if image.large_x2.url is defined and image.large_x2.width > thumbnail.width %}
          srcset="{{ thumbnail.url }} {{ thumbnail.width }}w, {{ image.large_x2.url }} {{ image.large_x2.width }}w"
          sizes="(min-width: 60em) 18rem, (min-width: 30em) 33vw, 50vw"
        {%- endif %}
        alt="{{ image.alt|default('') }}"
        width="{{ thumbnail.width }}" height="{{ thumbnail.height }}"
        loading="lazy" decoding="async">
    </a>
  </li>
{% endfor %}

Les largeurs sont celles calculées par le style d'image : le srcset est juste, sans une ligne de PHP. Et le jour où un site a besoin d'une autre taille, on coche un style dans le back-office.

Rendu de la galerie sur le site : une bande d'images avec une légende sous chacune

Enrichir data dans un preprocess

Le module ne remplace pas les preprocess, il les allège. Sur la galerie, chaque image affiche la légende de son média, dans la langue du paragraphe : ce n'est pas un champ de l'affichage, c'est une donnée calculée. Le preprocess complète donc data.images :

#[Hook('preprocess_paragraph')]
public function preprocessParagraph(array &$variables): void {
  // ...
  if (!isset($variables['data']) || !is_array($variables['data'])) {
    $this->rawRenderer->renderData($variables);
  }
  if ($paragraph instanceof GalleryParagraph && is_array($variables['data']['images'] ?? NULL)) {
    $variables['data']['images'] = $this->withCaptions($paragraph, $variables['data']['images']);
  }
}

private function withCaptions(GalleryParagraph $paragraph, array $images): array {
  $items = $paragraph->get(GalleryParagraph::FIELD_GALLERY);
  $langcode = $paragraph->language()->getId();
  foreach ($images as $delta => $image) {
    $media = $items->get($delta)?->get('entity')->getValue();
    if (!$media instanceof ContentEntityInterface) {
      continue;
    }
    $translation = $this->entityRepository->getTranslationFromContext($media, $langcode);
    $caption = GalleryParagraph::caption($translation);
    if ($caption !== '') {
      $images[$delta]['caption'] = $caption;
    }
  }
  return $images;
}

Tester la présence d'une valeur

Avec des valeurs brutes, on teste ce qui est réellement là, plutôt que de se demander si un rendu est vide. La brique « Bloc et image » retombe sur une simple grille de blocs quand son image manque (média supprimé, accès refusé) :

{% if data.image.url is defined and data.image.url %}
  {{ include('uzinasit_paragraphs:block-image', {
    image: data.image,
    block_position: data.block_position|default('left'),
    title: title|trim ? title : '',
    block: blocks|default([]),
  }, with_context: false) }}
{% elseif data.blocks_count|default(0) > 0 %}
  {{ include('uzinasit_paragraphs:blocks', { ... }, with_context: false) }}
{% endif %}

Props et slots côte à côte

Un affichage n'a pas besoin d'être entièrement brut. Un champ sur un formateur raw_* devient une prop dans data ; un champ laissé sur un formateur classique reste disponible dans content, prêt à être passé en slot. C'est le modèle que je recommande : les valeurs structurées en props, le HTML riche (un champ WYSIWYG, un bloc) en slots.

{{ include('mon_theme:cta-banner', {
  title: data.title,
  link: data.link.url is defined ? { label: data.link.title, url: data.link.url } : {},
  text: content.field_wysiwyg,
}, with_context = false) }}

Second usage : en découplé, avec un front Angular

Rien n'attache le module au theming. Le service raw_fields_formatters.renderer renvoie un simple tableau PHP, qui se sérialise tel quel en JSON. Un mode d'affichage api devient alors le contrat d'API : on y place les champs à exposer, chacun avec son formateur brut et sa clé, et le front n'a plus qu'à consommer.

Côté Drupal : un contrôleur

# mon_module.routing.yml
mon_module.article_json:
  path: '/api/article/{node}'
  defaults:
    _controller: '\Drupal\mon_module\Controller\ArticleController::json'
  requirements:
    _entity_access: 'node.view'
  options:
    parameters:
      node:
        type: entity:node
final class ArticleController extends ControllerBase {

  public function __construct(
    private readonly RawRendererInterface $rawRenderer,
    private readonly EntityDisplayRepositoryInterface $displayRepository,
  ) {}

  public static function create(ContainerInterface $container): self {
    return new self(
      $container->get('raw_fields_formatters.renderer'),
      $container->get('entity_display.repository'),
    );
  }

  public function json(NodeInterface $node): CacheableJsonResponse {
    $elements = $this->displayRepository
      ->getViewDisplay('node', $node->bundle(), 'api')
      ->build($node);

    $response = new CacheableJsonResponse($this->rawRenderer->getData($elements));
    $response->addCacheableDependency($this->rawRenderer->getCacheableMetadata($elements));

    return $response;
  }

}

La ligne addCacheableDependency() n'est pas optionnelle. Les champs ne sont jamais rendus, les données sont lues directement dans le render array : rien ne remonte tout seul. Dans un template d'entité, le module s'en charge (tags de cache des entités référencées, résultat des contrôles d'accès, formats de texte, styles d'image). Dans un contrôleur, c'est à vous de reporter ces métadonnées sur la réponse, sinon dépublier un média référencé laisserait l'ancienne version en cache.

Avec les clés renommées dans le mode d'affichage api, la réponse ressemble à ceci :

{
  "title": "Un titre",
  "summary": { "value": "<p>Du texte</p>", "plain": "Du texte", "format": "basic_html" },
  "image": { "url": "https://…/card.webp", "alt": "Un chat", "title": "", "width": 600, "height": 400 },
  "tags": ["Drupal", "Angular"],
  "featured": true
}

Pas de field_, pas de [0]['value'], pas de target_id à résoudre dans une seconde requête : les références sont déjà imbriquées, les URLs d'images déjà calculées, et les types sont les bons (featured est un vrai booléen, pas la chaîne "1").

Côté Angular : une interface et un service

Le contrat se traduit directement en interface TypeScript :

// article.model.ts
export interface RawImage {
  url: string;
  alt: string;
  title: string;
  width: number;
  height: number;
}

export interface Article {
  title: string;
  summary: { value: string; plain: string; format: string } | null;
  image: RawImage | null;   // un champ vide vaut null
  tags: string[];           // Force as array : toujours une liste
  featured: boolean;
}
// article.service.ts
@Injectable({ providedIn: 'root' })
export class ArticleService {
  private readonly http = inject(HttpClient);

  get(nid: number): Observable<Article> {
    return this.http.get<Article>(`/api/article/${nid}`);
  }
}
<!-- article.component.html -->
@if (article(); as article) {
  <h1>{{ article.title }}</h1>
  @if (article.image) {
    <img [src]="article.image.url" [alt]="article.image.alt"
         [width]="article.image.width" [height]="article.image.height">
  }
  <ul>
    @for (tag of article.tags; track tag) {
      <li>{{ tag }}</li>
    }
  </ul>
}

C'est là que les deux réglages communs sont utiles : la clé renommée donne une interface lisible et indépendante du modèle Drupal, et Force as array garantit que tags est toujours un tableau, qu'il y ait un tag ou dix. Un champ vide vaut null (et non une liste vide), ce qui se type proprement avec | null.

 

Le contrôle d'accès est vérifié : une entité, un paragraphe, une image ou un fichier que le visiteur ne peut pas voir est simplement absent de data.

Où en est le module

Le module est en alpha (1.0.0-alpha10 au moment où j'écris), compatible Drupal 11 et Drupal 12, et c'est lui qui alimente les composants de mon infrastructure uzinasit. Le README sur la page du projet détaille chaque formateur et sa sortie. Les retours et les propositions sont les bienvenus dans la file d'issues.

É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…

Commentaires (0)