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.
| Champ | Clé | Dans data |
|---|---|---|
field_titre | title | data.title |
field_visuel | image | data.image.url |
field_uzp_gallery | images | data.images |
Ce que ça change :
- Le template ou le front ne connaît plus le modèle Drupal. Un composant
cardattendtitle,image,link, quel que soit le type de contenu qui l'alimente : un article avecfield_titreet une page avecfield_headingdonnent 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_formattersLe 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) » :
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é.
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.
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:nodefinal 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.
Commentaires (0)