Si has estado observando cómo evoluciona el mundo front-end con React, Vue y todos esos frameworks modernos de JavaScript, quizás te preguntes si Drupal puede mantenerse al día. ¿Las buenas noticias? Definitivamente puede. Con Single Directory Components (SDC), Tailwind CSS v4, Vite y Storybook, puedes crear temas de Drupal basados en componentes que se ven sorprendentemente modernos.
Esta guía te llevará a través de la configuración de un flujo de trabajo moderno completo. He probado cada comando aquí, corregido los problemas comunes y documentado las cosas que suelen causar dificultades. Comencemos.
Necesitarás algunas cosas ya instaladas en tu máquina:
Si te falta algo de esto, configúralo primero. Créeme, te ahorrará dolores de cabeza más adelante.
Primero, creemos un lugar adecuado para tu tema personalizado. Drupal espera que los temas personalizados estén en un lugar específico:
mkdir -p web/themes/customAhora viene la parte divertida. Drush tiene un generador de temas que te hará un montón de preguntas. No te preocupes, te diré qué responder:
ddev drush generate themeEsto es lo que te preguntarán:
Blog SDC (este es el nombre legible para humanos)blog_sdc (solo minúsculas y guiones bajos)stable9 (esto es importante, no lo omitas o tendrás errores de dependencias más tarde)A custom theme for testing SDC componentsCustomNo (usaremos Tailwind para diseño responsive)No (mantengámoslo simple por ahora)Una vez generado tu tema, instalémoslo y hagámoslo el tema predeterminado para tu sitio:
ddev drush theme:install blog_sdc -y
ddev drush config-set system.theme default blog_sdc -y
ddev drush config-get system.theme defaultEse último comando solo confirma que tu tema ahora está activo. Deberías ver blog_sdc en la salida.
Single Directory Components (SDC) son revolucionarios. En lugar de dispersar archivos de componentes en múltiples directorios, todo vive junto: template, CSS, JavaScript y configuración, todo en una carpeta. Es la arquitectura de componentes que querías en Drupal.
Generemos un componente Card:
ddev drush generate sdcResponde las preguntas:
blog_sdcCard ComponentcardA reusable card componentyn (agregaremos esto más tarde)nnDespués de la generación, limpia el caché de Drupal. Harás esto mucho, así que acostúmbrate a este comando:
ddev drush crAhora es donde las cosas se ponen interesantes. Necesitamos conectar nuestro componente al contenido real de Drupal. Primero, configuremos la depuración de Twig para que puedas ver qué está pasando bajo el capó.
Copia el archivo de services predeterminado:
cp web/sites/default/default.services.yml web/sites/default/services.ymlAbre web/sites/default/services.yml y encuentra la sección twig.config. Solo estás cambiando tres valores aquí, no reemplaces toda la sección:
parameters:
twig.config:
debug: true
auto_reload: true
cache: falseEstas configuraciones te mostrarán comentarios útiles en tu código fuente HTML y recargarán templates automáticamente. Salvavidas durante el desarrollo.
Ahora instalemos algunas herramientas de desarrollo que facilitarán tu vida:
ddev composer require drupal/devel drupal/twig_tweak 'drupal/kint-kint:^2.2'
ddev drush en devel devel_generate twig_tweak kint -yEstos módulos son esenciales para el desarrollo: Devel proporciona herramientas de depuración, Twig Tweak agrega funciones útiles de Twig, y Kint te da volcados de variables hermosos.
Nota rápida: Sí, es drupal/kint-kint con el nombre repetido. Es raro, pero ese es el nombre real del paquete debido a un conflicto con otro paquete.
Genera un artículo de prueba para trabajar:
ddev drush devel-generate:content 1 --bundles=articleEncuentra el ID de nodo de tu artículo (necesitarás esto):
ddev drush sqlq "SELECT nid, title FROM node_field_data WHERE type='article' LIMIT 1"Ahora sobrescribamos el template del artículo para usar nuestro componente card. Crea el directorio de template y copia el template base:
mkdir -p web/themes/custom/blog_sdc/templates/node
cp web/core/themes/stable9/templates/content/node.html.twig web/themes/custom/blog_sdc/templates/node/node--article.html.twigAbre templates/node/node--article.html.twig y reemplaza todo con esta única declaración include:
{% include 'blog_sdc:card' with {
title: label,
content: content.body,
image: content.field_image,
tags: content.field_tags,
url: url
} %}Aquí es donde ocurre la magia. Le estás diciendo a Drupal "usa mi componente card y pásale estos valores". Nota qué limpio es esto comparado con las sobrescrituras de template tradicionales.
Pero necesitamos decirle a nuestro componente qué datos esperar. Edita components/card/card.component.yml:
'$schema': 'https://git.drupalcode.org/project/drupal/-/raw/10.1.x/core/modules/sdc/src/metadata.schema.json'
name: Card Component
status: stable
description: A reusable card component
props:
type: object
properties:
title:
type: string
title: Card Title
content:
type: string
title: Card Content
image:
type: string
title: Card Image
tags:
type: string
title: Card Tags
url:
type: string
title: Card URLLimpia el caché nuevamente:
ddev drush crAntes de agregar Tailwind, asegurémonos de que nuestro componente realmente muestre algo. Edita components/card/card.twig:
<article {{ attributes.addClass('card') }}>
{% if title %}
<h2 class="card__title">
{% if url %}
<a href="{{ url }}">{{ title }}</a>
{% else %}
{{ title }}
{% endif %}
</h2>
{% endif %}
{% if content %}
<div class="card__content">
{{ content }}
</div>
{% endif %}
</article>Y agrega algunos estilos básicos a components/card/card.css (pronto reemplazaremos esto con Tailwind):
[data-component-id="blog_sdc:card"] {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
[data-component-id="blog_sdc:card"]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
[data-component-id="blog_sdc:card"] .card__title {
margin: 0 0 1rem 0;
font-size: 1.5rem;
color: #333;
}
[data-component-id="blog_sdc:card"] .card__title a {
color: #0073e6;
text-decoration: none;
}
[data-component-id="blog_sdc:card"] .card__title a:hover {
text-decoration: underline;
}
[data-component-id="blog_sdc:card"] .card__content {
color: #666;
line-height: 1.6;
}Limpia el caché y prueba:
ddev drush crVisita tu artículo (probablemente en /node/1) y deberías ver tu componente card en acción. Si ejecutaste esa consulta SQL antes, sabrás exactamente qué URL visitar.
Aquí es donde las cosas se ponen realmente interesantes. Vamos a configurar un sistema de compilación moderno con Vite y Tailwind CSS v4. Navega al directorio de tu tema:
cd web/themes/custom/blog_sdc
bun init -yInstala las dependencias que necesitamos:
bun add -D vite @tailwindcss/vite tailwindcss
bun add -D prettier prettier-plugin-tailwindcss @ttskch/prettier-plugin-tailwindcss-anywherePrettier formateará tu código automáticamente, incluyendo ordenar las clases de Tailwind. Ese segundo plugin de Prettier es especial: permite que Prettier entienda templates de Twig, lo que Prettier normal no puede hacer.
Crea un archivo .prettierrc:
{
"plugins": ["prettier-plugin-tailwindcss", "@ttskch/prettier-plugin-tailwindcss-anywhere"],
"overrides": [
{
"files": ["*.twig", "*.html.twig"],
"options": {
"parser": "anywhere",
"regex": "(?:class(?:Name)?|addClass)\\\\s*(?:[=:]|\\\\()\\\\s*[\"']([^\"'{}~]*)"
}
}
],
"printWidth": 100,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}Ahora configura Vite. Crea vite.config.js en la raíz de tu tema:
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { resolve } from 'path';
export default defineConfig({
plugins: [tailwindcss()],
build: {
outDir: 'dist',
manifest: true,
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'src/main.js'),
},
output: {
entryFileNames: '[name]-[hash].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name]-[hash].[ext]',
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
cors: true,
},
base: process.env.NODE_ENV === 'production' ? '/themes/custom/blog_sdc/dist/' : '/',
});Esta configuración maneja la compilación de tus recursos con hashes para caché y configura un servidor de desarrollo con reemplazo de módulos en caliente.
Crea un directorio fuente para tus archivos principales de JavaScript y CSS:
mkdir -p srcCrea src/main.css con directivas de Tailwind:
@import 'tailwindcss';
@layer base {
body {
@apply text-gray-900;
}
}
@layer components {
/* Your custom component styles */
}
@layer utilities {
/* Your custom utilities */
}Aquí es donde todo se vuelve inteligente. Crea src/main.js con descubrimiento automático de componentes:
/**
* @file
* Main JavaScript entry point for blog_sdc theme.
*/
// Import main CSS (includes Tailwind)
import './main.css';
// Auto-discover component JavaScript files
const componentModules = import.meta.glob('../components/**/*.js', { eager: false });
// Initialize components
async function initializeComponents() {
const componentPaths = Object.keys(componentModules);
console.log(`[blog_sdc] Found ${componentPaths.length} component JS files`);
for (const path of componentPaths) {
const componentName = path.match(/components\/([^/]+)\//)?.[1];
const componentSelector = `[data-component-id="blog_sdc:${componentName}"]`;
if (document.querySelector(componentSelector)) {
console.log(`[blog_sdc] Loading component: ${componentName}`);
try {
const module = await componentModules[path]();
if (module.default && typeof module.default === 'function') {
module.default();
}
} catch (error) {
console.error(`[blog_sdc] Error loading component ${componentName}:`, error);
}
}
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeComponents);
} else {
initializeComponents();
}
export { initializeComponents };Este script encuentra y carga automáticamente JavaScript para cualquier componente que esté en la página. ¡Ya no necesitas importar manualmente cada archivo de componente!
Actualiza tu package.json con scripts útiles:
{
"name": "blog_sdc",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{js,css}\" \"components/**/*.{js,twig}\" \"templates/**/*.twig\""
}
}Como ahora estamos usando Tailwind, ya no necesitamos ese archivo CSS:
rm components/card/card.cssAhora reconstruyamos el componente card con utilidades de Tailwind. Edita components/card/card.twig:
<article {{ attributes.addClass('bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden mb-6') }}>
{% if image %}
<div class="aspect-video overflow-hidden">
{{ image }}
</div>
{% endif %}
{% if title %}
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-3">
{% if url %}
<a href="{{ url }}" class="text-blue-600 hover:text-blue-800 transition-colors">
{{ title }}
</a>
{% else %}
{{ title }}
{% endif %}
</h2>
</div>
{% endif %}
{% if content %}
<div class="px-6 pb-4">
<div class="text-gray-600 leading-relaxed">
{{ content }}
</div>
</div>
{% endif %}
{% if tags %}
<div class="px-6 pb-6 border-t border-gray-100 pt-4">
<div class="flex flex-wrap gap-2">
{{ tags }}
</div>
</div>
{% endif %}
</article>Mira eso: no se necesita archivo CSS. Todos los estilos están ahí mismo en el template usando clases utilitarias de Tailwind.
Agreguemos algo de interactividad. Crea components/card/card.js:
/**
* @file
* Card component JavaScript.
*/
export default function initCard() {
const cards = document.querySelectorAll('[data-component-id="blog_sdc:card"]');
console.log(`[Card] Initializing ${cards.length} card(s)`);
cards.forEach((card) => {
card.addEventListener('mouseenter', () => {
card.classList.add('card--hover');
});
card.addEventListener('mouseleave', () => {
card.classList.remove('card--hover');
});
const title = card.querySelector('.card__title');
if (title) {
console.log(`[Card] Initialized: ${title.textContent.trim()}`);
}
});
}Ahora compila todo:
bun run buildPresta atención a la salida. Verás algo como:
dist/main-BuEGDbdA.css
dist/main-BicIW-eB.jsEsos valores de hash (BuEGDbdA y BicIW-eB) son importantes. Cópialos, los necesitarás en un segundo.
Edita blog_sdc.libraries.yml y agrega tus recursos compilados con los valores de hash reales:
# Vite-built assets with Tailwind CSS
vite-main:
css:
theme:
dist/main-BuEGDbdA.css: {} # Replace with your actual hash
js:
dist/main-BicIW-eB.js: {} # Replace with your actual hash
dependencies:
- core/drupal
# Legacy theme library (if it exists)
global:
css:
theme:
css/style.css: {}Importante: Esos valores de hash cambian cada vez que compilas. Si modificas tus archivos fuente y recompilas, necesitarás actualizar estos hashes en tu archivo de bibliotecas. Es molesto, pero así funciona el cache-busting.
Ahora dile a tu tema que cargue esta biblioteca. Edita blog_sdc.info.yml:
name: Blog SDC
type: theme
description: 'A custom theme for testing SDC components'
package: Custom
core_version_requirement: ^10 || ^11
base theme: stable9
libraries:
- blog_sdc/vite-mainLimpia el caché una vez más:
ddev drush cr¿Quieres que tu código se vea bonito? Ejecuta el formateador:
bun run formatAquí es donde el flujo de trabajo realmente brilla. Storybook te permite desarrollar componentes de forma aislada, sin necesidad de navegar por la interfaz de administración de Drupal o crear contenido de prueba. Inicialízalo:
npx storybook@latest init --builder vite --type html --no-dev --yesLimpia las stories de ejemplo que incluyen:
rm -rf storiesInstala el accessibility addon (esto es muy útil):
bun add -D @storybook/addon-a11y@10.0.7Elimina las configuraciones de TypeScript (lo mantendremos simple):
rm .storybook/main.ts .storybook/preview.tsCrea .storybook/main.js:
import { join } from 'node:path';
import { cwd } from 'node:process';
const config = {
stories: ['../components/**/*.stories.js'],
addons: [
'@storybook/addon-a11y',
'@storybook/addon-docs',
],
framework: {
name: '@storybook/html-vite',
options: {
builder: {
viteConfigPath: './.storybook/vite.config.js',
},
},
},
core: {
disableTelemetry: true,
},
};
export default config;Crea .storybook/preview.js para configurar cómo se muestran las stories:
import '../src/main.css';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#1a1a1a' },
{ name: 'gray', value: '#f3f4f6' },
],
},
layout: 'padded',
a11y: {
config: {
rules: [{ id: 'color-contrast', enabled: true }],
},
},
};
export const decorators = [
(Story) => {
const wrapper = document.createElement('div');
wrapper.style.padding = '1rem';
wrapper.appendChild(Story());
return wrapper;
},
];Crea .storybook/vite.config.js (separado de tu configuración principal de Vite):
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
optimizeDeps: {
include: [],
},
});Ahora crea tu primera story. Crea components/card/card.stories.js:
export default {
title: 'Components/Card',
tags: ['autodocs'],
parameters: { layout: 'padded' },
argTypes: {
title: { control: 'text', description: 'The title displayed in the card header' },
content: { control: 'text', description: 'The main content of the card' },
image: { control: 'text', description: 'Optional image HTML for the card' },
tags: { control: 'text', description: 'Optional tags HTML for the card' },
url: { control: 'text', description: 'Optional URL for the title link' },
},
};
const renderCard = (args) => {
const article = document.createElement('article');
article.className = 'mb-6 overflow-hidden rounded-lg bg-white shadow-md transition-shadow duration-300 hover:shadow-lg';
article.setAttribute('data-component-id', 'blog_sdc:card');
if (args.image) {
const imageDiv = document.createElement('div');
imageDiv.className = 'aspect-video overflow-hidden';
imageDiv.innerHTML = args.image;
article.appendChild(imageDiv);
}
if (args.title) {
const titleDiv = document.createElement('div');
titleDiv.className = 'p-6';
const h2 = document.createElement('h2');
h2.className = 'mb-3 text-xl font-semibold text-gray-900';
if (args.url) {
const link = document.createElement('a');
link.href = args.url;
link.className = 'text-blue-600 transition-colors hover:text-blue-800';
link.textContent = args.title;
h2.appendChild(link);
} else {
h2.textContent = args.title;
}
titleDiv.appendChild(h2);
article.appendChild(titleDiv);
}
if (args.content) {
const contentDiv = document.createElement('div');
contentDiv.className = 'px-6 pb-4';
const innerDiv = document.createElement('div');
innerDiv.className = 'leading-relaxed text-gray-600';
innerDiv.innerHTML = args.content;
contentDiv.appendChild(innerDiv);
article.appendChild(contentDiv);
}
if (args.tags) {
const tagsDiv = document.createElement('div');
tagsDiv.className = 'border-t border-gray-100 px-6 pt-4 pb-6';
const innerDiv = document.createElement('div');
innerDiv.className = 'flex flex-wrap gap-2';
innerDiv.innerHTML = args.tags;
tagsDiv.appendChild(innerDiv);
article.appendChild(tagsDiv);
}
return article;
};
export const Basic = {
args: { title: 'Basic Card', content: 'This is a basic card with minimal styling.' },
render: renderCard,
};
export const WithImage = {
args: {
title: 'Card with Image',
content: 'This card includes a featured image at the top.',
image: '<img src="https://placehold.co/600x400" alt="Placeholder" class="w-full h-auto" />',
url: '/example',
},
render: renderCard,
};
export const Complete = {
args: {
title: 'Complete Card',
content: 'This card showcases all available features.',
image: '<img src="https://placehold.co/600x400" alt="Complete" class="w-full h-auto" />',
tags: '<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">Featured</span>',
url: '/complete',
},
parameters: { backgrounds: { default: 'gray' } },
render: renderCard,
};Actualiza tu package.json una vez más para agregar scripts de Storybook:
{
"name": "blog_sdc",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{js,css}\" \"components/**/*.{js,twig}\" \"templates/**/*.twig\"",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}
}Inicia Storybook:
bun run storybookAbre http://localhost:6006 en tu navegador. Deberías ver tu componente card con tres variantes diferentes con las que puedes interactuar y modificar en tiempo real.
¿Quieres compilar una versión estática para despliegue o compartir con tu equipo?
bun run build-storybookEcho Flow ayuda a empresas canadienses con ingeniería Drupal de nivel empresarial.
Ahora que todo está configurado, así es como trabajarás día a día.
Veamos cómo agregar un nuevo componente desde cero. Digamos que quieres agregar un componente Button:
Genera el componente:
ddev drush generate sdc
# Theme: blog_sdc, Component: Button, machine: buttonElimina el archivo CSS (estamos usando Tailwind, ¿recuerdas?):
rm components/button/button.cssEstilízalo con Tailwind en components/button/button.twig:
<button {{ attributes.addClass('px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors') }}>
{{ label }}
</button>Agrega JavaScript si es necesario en components/button/button.js:
export default function initButton() {
console.log('Button component initialized');
// Your button logic here
}components/button/button.stories.js con diferentes variantesPruébalo en Storybook:
bun run storybookCompila tus recursos:
bun run buildblog_sdc.libraries.yml con los nuevos valores de la salida de compilaciónLimpia el caché de Drupal:
ddev drush crFormatea tu código:
bun run formatEstos son los comandos que usarás constantemente:
# Iniciar el servidor de desarrollo de Vite (recarga en caliente, mucho más rápido que recompilar)
bun run dev
# Iniciar Storybook (usualmente en una terminal separada)
bun run storybook
# Generar contenido de prueba cuando necesites más artículos para trabajar
ddev drush devel-generate:content 5 --bundles=article
# Encontrar IDs de nodos para visitar contenido específico
ddev drush sqlq "SELECT nid, title FROM node_field_data WHERE type='article' LIMIT 5"
# Limpiar caché (en serio, haz esto todo el tiempo)
ddev drush cr
# Verificar qué temas están instalados
ddev drush pm:list --type=theme
# Desinstalar un tema si necesitas empezar de nuevo
ddev drush theme:uninstall blog_sdcSeamos honestos: las cosas saldrán mal. Aquí está cómo solucionar los problemas más comunes.
Esto usualmente significa que tu blog_sdc.info.yml tiene un problema. Verifica que base theme: esté configurado con un nombre de tema válido como stable9. El generador de Drush a veces se confunde y mezcla el orden de las entradas durante la generación.
Esto es casi siempre un problema de caché. Ejecuta ddev drush cr e intenta de nuevo. Si eso no funciona:
web/themes/custom/blog_sdc/components/card/ddev drush pm:list --type=themeddev drush config-get system.theme defaultAlgunas cosas para verificar:
bun run build?blog_sdc.libraries.yml?blog_sdc.info.yml para la sección libraries:ddev drush crUsualmente esto significa un problema de dependencias:
bun install para reinstalar todosrc/main.js y src/main.css existanvite.config.js esté en el directorio raíz de tu temanode_modules y ejecuta bun install de nuevo.storybook/main.js y .storybook/preview.js existanEl problema clásico. Prueba esto en orden:
ddev drush crCmd+Shift+R en Mac o Ctrl+Shift+R en Windows/LinuxSi obtienes un error al instalar Kint, usa el nombre exacto del paquete: drupal/kint-kint (sí, "kint" aparece dos veces). Esto es debido a un conflicto de nombres con otro paquete. Es confuso, pero así es.
Para cuando solo necesites copiar y pegar algo rápidamente:
# Limpiar caché (usa esto constantemente)
ddev drush cr
# Generar un nuevo componente SDC
ddev drush generate sdc
# Compilar recursos para producción
bun run build
# Iniciar servidor de desarrollo con recarga en caliente
bun run dev
# Iniciar Storybook
bun run storybook
# Formatear tu código
bun run format
# Generar contenido de prueba
ddev drush devel-generate:content 5 --bundles=article
# Encontrar IDs de nodos
ddev drush sqlq "SELECT nid, title FROM node_field_data WHERE type='article' LIMIT 5"
# Verificar temas instalados
ddev drush pm:list --type=theme
# Obtener el tema predeterminado actual
ddev drush config-get system.theme default
# Compilar Storybook estático para despliegue
bun run build-storybookstable9 para una base sólidadrush cr (en serio, haz esto a menudo)component.ymlbun run build para despliegues de producciónbun run dev durante el desarrollobun run formattags: ['autodocs']Esto no es solo un tema: es un entorno de desarrollo moderno completo:
Basado en este flujo de trabajo, esto es lo que puedes esperar:
Esto es significativamente más rápido que los flujos de trabajo tradicionales de desarrollo de temas de Drupal.
Ahora tienes un flujo de trabajo moderno de desarrollo de temas listo para producción que lleva a Drupal a la misma liga que los frameworks modernos de JavaScript. La combinación de SDC, Tailwind, Vite y Storybook te da aislamiento de componentes, iteración rápida y una experiencia de desarrollo que realmente se siente bien.
El verdadero poder aquí no son solo las herramientas, es cómo trabajan juntas. Puedes prototipar en Storybook, estilizar con clases utilitarias y desplegar en Drupal sin cambiar de contexto. Tus componentes son portables, tus compilaciones son rápidas, y tu flujo de trabajo escala a medida que tu proyecto crece.
Comienza pequeño. Construye algunos componentes, familiarízate con el flujo de trabajo, luego expande tu biblioteca de componentes. Pronto te preguntarás cómo alguna vez construiste temas de Drupal de otra manera.
¡Feliz desarrollo de temas! 🎨