Crear un tema personalizado en Drupal con Twig
Cómo crear un tema personalizado en Drupal con Twig sin morir en el intento
Hace un par de años heredamos un proyecto que daba un poco de miedo. El cliente había comprado un tema de pago en un marketplace, de esos que prometen "compatibilidad total" y traen cuarenta regiones, tres frameworks de CSS peleándose entre sí y un panel de opciones con doscientos sliders. Funcionaba, sí, pero cada cambio de color obligaba a bucear en seis hojas de estilo distintas, y el peso de la home rondaba los cuatro megas antes siquiera de cargar una imagen. Lo que tardamos en limpiar aquello fue casi lo mismo que habríamos tardado en construir un tema desde cero. Esa fue la lección: a veces pelearte con un tema de terceros sale más caro que aprender cómo crear un tema personalizado en Drupal con Twig y hacerlo a tu medida.
Este artículo va de eso. De montar un tema propio en Drupal 10 u 11, entender la jerarquía de plantillas Twig, adjuntar tus librerías sin romper nada y dejar el código en un estado del que no te avergüences dentro de seis meses. No es magia, pero hay detalles que conviene tener claros antes de teclear la primera línea.
¿Tema de terceros o tema propio?
La pregunta del millón. Un tema de terceros te da velocidad el primer día y deudas el resto del año. Si el proyecto es un blog rápido o una landing que no vas a tocar, adelante. Pero en cuanto el diseño tiene personalidad, o el cliente pide cambios cada dos por tres, ese tema "todo incluido" se convierte en una jaula.
Cuando decides construir lo tuyo, tienes dos caminos:
- Subtema de un tema base (Olivero o Stark). Heredas plantillas, librerías y comportamientos del tema padre y solo sobrescribes lo que necesitas. Es lo más sensato para la mayoría de proyectos: arrancas con una base sólida y accesible.
- Tema desde cero. Control absoluto, cero herencia, ni una línea de CSS que no hayas escrito tú. Más trabajo por delante, pero ni un solo estilo fantasma. Stark, de hecho, está pensado precisamente para esto: es casi un tema vacío que te deja el marcado limpio.
Mi recomendación práctica: si quieres aprender la mecánica de Twig sin distracciones, parte de Stark. Si vas a producción y quieres accesibilidad y maquetación responsive resueltas, hazte subtema de Olivero. No hay una respuesta correcta universal, hay contexto.
La estructura mínima de un tema
Un tema de Drupal vive en themes/custom/. Esto es importante: nunca lo metas en la carpeta del core ni junto a los temas de contribución. La carpeta custom es tuya y sobrevive a las actualizaciones.
Lo mínimo que necesitas para que Drupal reconozca tu tema, al que aquí llamaré mitema, es:
themes/custom/mitema/
├── mitema.info.yml
├── mitema.libraries.yml
├── mitema.theme
├── css/
├── js/
└── templates/
El corazón de todo es el archivo .info.yml. Sin él, Drupal ni se entera de que tu tema existe. Aquí declaras el nombre, el tipo, el tema base, la versión de núcleo compatible, las regiones y las librerías:
name: 'Mi Tema'
type: theme
description: 'Tema personalizado para el proyecto.'
core_version_requirement: ^10 || ^11
base theme: olivero
libraries:
- mitema/global-styling
regions:
header: 'Cabecera'
primary_menu: 'Menú principal'
content: 'Contenido'
sidebar: 'Barra lateral'
footer_top: 'Pie superior'
footer_bottom: 'Pie inferior'
Dos detalles que la gente pasa por alto. El primero: core_version_requirement: ^10 || ^11 le dice a Drupal que el tema vale tanto para la versión 10 como para la 11, así te ahorras retoques cuando actualices. El segundo: las regiones que declaras aquí son las que aparecen luego en la página de bloques. Si una región no está en el .info.yml, no existe para Drupal, por mucho que la pintes en una plantilla.
Si te haces subtema de Olivero, heredas sus regiones por defecto. Si las redefines en tu archivo, sustituyes la lista entera, no la amplías. Es un fallo clásico: declaras dos regiones pensando que se suman a las del padre y de repente desaparecen el resto.
Librerías de CSS y JS: el archivo .libraries.yml
Drupal no quiere que sueltes etiquetas <link> o <script> a lo loco. Todo pasa por el sistema de librerías, que agrupa, versiona y agrega tus assets. Las defines en mitema.libraries.yml:
global-styling:
version: 1.0.0
css:
theme:
css/base.css: {}
css/layout.css: {}
js:
js/global.js: {}
dependencies:
- core/drupal
- core/once
componente-carrusel:
version: 1.0.0
css:
component:
css/carrusel.css: {}
js:
js/carrusel.js: {}
dependencies:
- core/drupalSettings
Fíjate en la categoría dentro de css (base, layout, component, state, theme). No es decorativa: define el orden de carga según la metodología SMACSS que sigue Drupal. Lo que pongas como theme carga después de lo de component, y así controlas la cascada sin pelear con !important.
Tienes dos formas de adjuntar una librería:
- Global, declarándola en
libraries:del.info.yml, como hicimos conglobal-styling. Carga en todas las páginas. - Por componente o plantilla, solo donde hace falta. Y aquí entra una de las herramientas más útiles de Twig:
attach_library(). En la plantilla del carrusel pondrías:
{{ attach_library('mitema/componente-carrusel') }}
Esto carga el CSS y el JS del carrusel únicamente en las páginas donde se renderiza esa plantilla. Es la diferencia entre una home ágil y una que arrastra todo el CSS del sitio en cada petición. Adjunta las librerías donde se usan, no por costumbre en el global.
El sistema de plantillas Twig
Aquí está el músculo del asunto. Drupal renderiza cada pieza de la página (la página entera, un nodo, un campo, un bloque, un menú) buscando una plantilla Twig. Y lo hace siguiendo una jerarquía de sugerencias de plantilla, de la más específica a la más genérica.
Por ejemplo, para un nodo de tipo "artículo" en la vista completa, Drupal busca en este orden:
node--articulo--full.html.twignode--articulo.html.twignode--full.html.twignode.html.twig
Usa la primera que encuentre en tu tema y, si no hay ninguna, recurre a la del módulo o tema base. Lo bonito es que no tienes que adivinar esos nombres: Drupal te los chiva si activas la depuración.
Activar la depuración de Twig
Esto es lo primero que hago en cualquier entorno de desarrollo. Vas a tu sites/default/services.yml (o al development.services.yml si trabajas con la configuración de desarrollo recomendada) y dejas el bloque de Twig así:
parameters:
twig.config:
debug: true
auto_reload: true
cache: false
Limpias caché y, a partir de ahí, si miras el código fuente HTML del navegador, verás comentarios como estos:
<!-- THEME DEBUG -->
<!-- THEME HOOK: 'node' -->
<!-- FILE NAME SUGGESTIONS:
* node--articulo--full.html.twig
x node--articulo.html.twig
* node.html.twig
-->
<!-- BEGIN OUTPUT from 'core/themes/olivero/templates/content/node.html.twig' -->
La x marca la plantilla que se está usando ahora mismo, y la ruta del BEGIN OUTPUT te dice de dónde sale. Con eso ya sabes exactamente qué archivo copiar y con qué nombre crear tu sobrescritura. Se acabó el ir a ciegas.
Un aviso importante: deja esto activo solo en desarrollo. En producción, debug: true y cache: false te destrozan el rendimiento. La caché de Twig existe por algo.
Sobrescribir plantillas
El procedimiento es siempre el mismo y es muy honesto: copias la plantilla original desde el core o el tema base a la carpeta templates/ de tu tema, la renombras según la sugerencia que quieras atacar y la editas.
Pongamos que quieres tocar la estructura general de la página. Copias page.html.twig de Olivero, la pegas en mitema/templates/page.html.twig y modificas el marcado. Lo mismo con node.html.twig, las plantillas de campo (field--field-imagen.html.twig) o las de bloque (block.html.twig). Drupal detectará tu versión y la priorizará sobre la original.
Nunca, jamás, edites la plantilla dentro de core/. En la próxima actualización de Drupal se sobrescribe y pierdes el trabajo. Copia a tu tema y edita ahí. Esta regla, que parece obvia, sigue siendo el error número uno que veo en auditorías.
Variables y sintaxis Twig
Twig usa tres construcciones que conviene tener grabadas:
{{ variable }}imprime un valor.{% if %},{% for %},{% set %}son lógica de control: condicionales, bucles, asignaciones.{# esto es un comentario #}no se renderiza.
Y luego están los filtros, que transforman valores con la barra vertical. Dos imprescindibles en Drupal:
|ttraduce una cadena al sistema de traducción de Drupal. Cualquier texto que escribas a mano en una plantilla debería pasar por|tpara no romper la internacionalización.|clean_classconvierte un texto arbitrario en un nombre de clase CSS válido (minúsculas, sin acentos ni espacios raros). Perfecto para generar clases a partir de datos.
Una plantilla de nodo sencilla podría quedar así:
<article{{ attributes.addClass('nodo', 'nodo--' ~ node.bundle|clean_class) }}>
{% if label and not page %}
<h2{{ title_attributes }}>
<a href="{{ url }}">{{ label }}</a>
</h2>
{% endif %}
<div{{ content_attributes.addClass('nodo__contenido') }}>
{{ content }}
</div>
{% if node.isPromoted %}
<span class="nodo__destacado">{{ 'Destacado'|t }}</span>
{% endif %}
</article>
Observa el operador ~: es la concatenación de cadenas en Twig (el equivalente al . de PHP). Y attributes, title_attributes o content_attributes son objetos especiales de Drupal que arrastran clases, IDs y atributos generados por el sistema; respétalos siempre con addClass() en vez de escribir el atributo class a mano, o perderás cosas que otros módulos inyectan ahí.
Pasar variables con preprocess en .theme
Twig debería ser tonto. La regla de oro es separar la lógica de la presentación: la plantilla pinta, no calcula. ¿Dónde va el cálculo? En el archivo mitema.theme, mediante hooks de preprocess en PHP.
Imagina que quieres mostrar el tiempo de lectura estimado de un artículo. No lo calculas en Twig; lo haces en el preprocess y le pasas la variable ya cocinada:
<?php
/**
* @file
* Funciones de preprocess para Mi Tema.
*/
use Drupal\Component\Utility\Unicode;
/**
* Implementa hook_preprocess_HOOK() para node.
*/
function mitema_preprocess_node(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() === 'articulo' && $node->hasField('body')) {
$texto = strip_tags($node->get('body')->value ?? '');
$palabras = str_word_count($texto);
// 200 palabras por minuto como referencia.
$variables['tiempo_lectura'] = max(1, (int) ceil($palabras / 200));
}
}
Y en la plantilla, simplemente:
{% if tiempo_lectura %}
<p class="nodo__lectura">{{ 'Lectura: @min min'|t({'@min': tiempo_lectura}) }}</p>
{% endif %}
El nombre del hook sigue el patrón MITEMA_preprocess_HOOK. Para nodos es _preprocess_node, para la página _preprocess_page, para bloques _preprocess_block. La & en array &$variables es clave: pasas el array por referencia para poder añadir y modificar variables que la plantilla recibirá. Si te la olvidas, tus cambios no llegan a ningún sitio.
Single Directory Components, la evolución moderna
Si trabajas con Drupal 10.1 o superior, hay una novedad que merece la pena conocer: los Single Directory Components (SDC). La idea es agrupar en una sola carpeta todo lo que define un componente (su plantilla Twig, su CSS, su JS y un archivo de metadatos *.component.yml con las props) en lugar de tener las piezas desperdigadas entre templates, css y js.
No voy a extenderme aquí porque da para un artículo entero, pero quédate con la idea: SDC es hacia donde va el theming en Drupal, encaja muy bien con design systems y te permite reutilizar componentes de forma mucho más limpia. Para un primer tema personalizado no es obligatorio, pero tenlo en el radar para cuando tu base de componentes empiece a crecer.
Buenas prácticas que te ahorrarán disgustos
Después de varios proyectos, esto es lo que de verdad marca la diferencia entre un tema mantenible y uno que acaba siendo el del marketplace que limpiamos al principio:
- No hardcodees nada que pueda cambiar. Rutas, textos, IDs de nodo... todo eso envejece fatal. Usa variables,
|ty la configuración. - Accesibilidad desde el primer día. Marcado semántico, atributos ARIA donde toquen, contraste suficiente. Olivero te lo da casi resuelto; si vas desde cero, es tu responsabilidad.
- Rendimiento con cabeza. En producción activa la agregación de CSS y JS (en la configuración de rendimiento) para que Drupal combine y minifique las librerías. Adjunta assets por componente, no todo al global.
- Control de versiones. Tu tema entero va a Git. Los
.info.yml, las plantillas, el CSS, todo. Es código. - Exporta la configuración. Lo que toques en la interfaz (bloques colocados en regiones, ajustes del tema) consérvalo con la gestión de configuración de Drupal para poder desplegarlo entre entornos sin clics manuales.
Los errores típicos (que cometemos todos)
Para terminar, la lista corta de tropiezos que veo una y otra vez:
- Editar plantillas del core o de un tema de contribución. Se pierden al actualizar. Copia siempre a tu tema.
- No limpiar la caché tras tocar plantillas. Drupal cachea las sugerencias de plantilla. Cambias el nombre de un
.html.twigy no pasa nada... porque la caché sigue sirviendo lo viejo. Tras renombrar o crear plantillas, limpia caché y vuelve a probar. - Usar CSS o JS sin declararlo como librería. Si tu hoja de estilos no carga, lo primero que reviso es si está en el
.libraries.ymly si la librería está adjunta. Drupal no carga assets sueltos. - Olvidar la referencia en el preprocess. El
&$variablesque mencionábamos. Sin la&, tus variables no aparecen en la plantilla y te vuelves loco buscando el fallo.
Crear un tema personalizado en Drupal con Twig no es complicado una vez entiendes la mecánica: estructura mínima, librerías bien declaradas, jerarquía de plantillas y la lógica en su sitio. Lo difícil es la disciplina de mantenerlo limpio cuando llegan las prisas. Si estás valorando rehacer un tema heredado o arrancar un proyecto Drupal con buenos cimientos y prefieres que te echemos una mano, puedes contarnos qué necesitas y lo vemos juntos.
El tema que limpiamos aquel año acabó pesando una cuarta parte y cargando en la mitad de tiempo. No por arte de magia, sino por hacer lo de siempre, pero hecho con cabeza. Y eso, al final, es todo el secreto.