Cet article est aussi disponible en français
Every Drupal site I maintain in my uzinasit ends up needing the same content blocks: rich text, text with an image, a gallery, a video, a map, a button, a quote. Until now, each site rewrote them its own way. I eventually packaged a set of paragraph config and SDC components into a module that I am rolling out to all the sites, little by little.
A package in three parts
The package is made of three pieces:
- a Drupal recipe (
recipes/uzinasit_paragraphs) carries the config: the paragraph types, their fields, their form and view displays, and the media types they need (image,remote_video); - a module carries the code: one bundle class per type, and the business logic of each type;
- SDC components, in that same module, carry the rendering: neutral HTML and minimal CSS, working without any theme.
Between content and rendering, there is a single point of contact: the binding template paragraph--<bundle>.html.twig.
Paragraph (content) → fields → formatters → binding template → SDC component (rendering)
bundle (recipe) data.* paragraph--<bundle> uzinasit_paragraphs:<name>
+ bundle class content.* (module) (replaceable by the theme)Why a recipe
An installation profile is only used when a site is created. A recipe, on the other hand, can also be applied to an existing site:
drush @mysite recipe ../recipes/uzinasit_paragraphs_pageThere are two of them. The first one creates the paragraphs without assuming anything about the site. The second one, uzinasit_paragraphs_page, applies the first one and then adds the field_paragraphs field to the "page" content type, creating it if it does not exist. Once the recipe is applied, the site's config (drush cex) becomes the source of truth again: a recipe cannot be "uninstalled", and you do not re-apply it on top to evolve a type.
All names are prefixed with uzp_ (bundles) and field_uzp_ (fields): a site that already has its own rich_text or field_text does not collide with the package.
Ten paragraph types
- rich text, rich text with an image (on the left or on the right)
- image gallery, with a lightbox, a caption per image and a grid or strip layout
- video (YouTube)
- OpenStreetMap map
- CTA (call to action)
- quote
- Block
- ...
Each one can carry a shared editorial title (text + level h2 to h4), which is itself an SDC component. An empty title renders nothing, not even a wrapper.
The template
Raw values arrive in data thanks to raw_fields_formatters (formatters that expose a field's value instead of rendering it), and are passed to the component as props. Fields that are already rendered (an oEmbed video, for instance) are passed as slots. Here is the quote as an example:
{% 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 %}The component itself knows nothing about Drupal: it never reads an entity.
<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>Each site's theme styles these components, or replaces one entirely thanks to the SDC replaces key, without touching either the module or the content.
Bundle classes: the business logic
Cleaner than using a paragraph preprocess hook, they hold whatever is specific to each paragraph type.
#[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';
/**
* The allowed styles, those of the component's enum.
*/
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();
}
}Each type has its class, always, even without any business logic: at the very least it holds the constants of its fields, and it is the class that decides whether the shared editorial title applies (only bundles whose class extends UzpParagraphBase get it).
What's next
I am starting to think about packaging this, so that it can be used on other sites. I want to polish it all first: the paint is still wet.
Comments (0)