Twig es el motor de plantillas de Grav. Dominar sus características avanzadas te permite crear themes potentes, dinámicos y mantenibles. En este módulo aprenderás desde sintaxis compleja hasta la creación de componentes reutilizables y conversión de HTML estático.
Tres tipos de delimitadores en Twig:
{{ ... }} - Expresiones (imprimir contenido){% ... %} - Declaraciones (lógica, bucles, bloques){# ... #} - Comentarios (no se renderizan)Variables y tipos de datos:
{# Strings #}
{{ 'Texto simple' }}
{{ "Texto con 'comillas'" }}
{# Numbers #}
{{ 42 }}
{{ 3.14 }}
{# Booleans #}
{{ true }}
{{ false }}
{# Arrays #}
{{ ['item1', 'item2', 'item3'] }}
{# Objects/Hashes #}
{{ {name: 'Juan', age: 30} }}
{# Null #}
{{ null }}
Acceso a propiedades:
{# Notación punto (preferida) #}
{{ page.title }}
{{ config.site.author.name }}
{# Notación array (si nombre tiene espacios o guiones) #}
{{ page['custom-field'] }}
{# Método con parámetros #}
{{ page.summary(300) }}
Condicionales complejos:
{# If básico #}
{% if page.header.featured %}
<span class="badge">Destacado</span>
{% endif %}
{# If-else #}
{% if user.authenticated %}
<p>Bienvenido, {{ user.fullname }}</p>
{% else %}
<p><a href="/login">Iniciar sesión</a></p>
{% endif %}
{# If-elseif-else #}
{% if page.header.level == 'beginner' %}
<span class="level-badge beginner">Principiante</span>
{% elseif page.header.level == 'intermediate' %}
<span class="level-badge intermediate">Intermedio</span>
{% elseif page.header.level == 'advanced' %}
<span class="level-badge advanced">Avanzado</span>
{% else %}
<span class="level-badge">Sin nivel</span>
{% endif %}
{# Operadores lógicos #}
{% if page.published and page.visible and not page.header.draft %}
{# Mostrar contenido #}
{% endif %}
{# Operador ternario (inline) #}
{{ page.header.author ? page.header.author : 'Anónimo' }}
{{ page.header.author|default('Anónimo') }} {# Forma preferida #}
{# Comprobar existencia #}
{% if page.header.hero is defined %}
{% include 'partials/hero.html.twig' %}
{% endif %}
{# Verificar si está vacío #}
{% if page.taxonomy.tag is not empty %}
<div class="tags">
{% for tag in page.taxonomy.tag %}
<span>{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
Bucles avanzados:
{# For básico #}
{% for item in items %}
<li>{{ item.title }}</li>
{% endfor %}
{# For con else (si array vacío) #}
{% for post in page.collection %}
<article>{{ post.title }}</article>
{% else %}
<p>No hay artículos disponibles.</p>
{% endfor %}
{# For con filtros #}
{% for post in page.collection|slice(0, 5) %}
{# Solo primeros 5 posts #}
{% endfor %}
{# Variables especiales del loop #}
{% for item in items %}
<li class="
{% if loop.first %}first{% endif %}
{% if loop.last %}last{% endif %}
{% if loop.index0 is even %}even{% else %}odd{% endif %}
">
{{ loop.index }}. {{ item.title }}
{# loop.index: 1, 2, 3... #}
{# loop.index0: 0, 1, 2... #}
{# loop.revindex: cuenta regresiva desde el final #}
{# loop.length: total de items #}
{# loop.parent: acceso al contexto padre #}
</li>
{% endfor %}
{# Loop sobre objetos (key-value) #}
{% for key, value in page.header.metadata %}
<meta name="{{ key }}" content="{{ value }}">
{% endfor %}
{# Loop con condición #}
{% for user in users if user.active %}
<li>{{ user.name }}</li>
{% endfor %}
Asignación de variables:
{# Variable simple #}
{% set title = page.title %}
{{ title }}
{# Variable con operaciones #}
{% set reading_time = page.content|striptags|split(' ')|length / 200 %}
{{ reading_time|round }} minutos de lectura
{# Múltiples variables #}
{% set foo, bar = 'foo', 'bar' %}
{# Arrays #}
{% set featured_posts = [] %}
{% for post in page.collection if post.header.featured %}
{% set featured_posts = featured_posts|merge([post]) %}
{% endfor %}
{# Ámbito de bloques #}
{% set outer = 'valor externo' %}
{% block content %}
{% set inner = 'valor interno' %}
{{ outer }} {# Accesible #}
{% endblock %}
{# inner no es accesible aquí #}
{# Comparación #}
{{ age > 18 }}
{{ name == 'Admin' }}
{{ value != null }}
{{ score >= 100 }}
{# Lógicos #}
{{ condition1 and condition2 }}
{{ condition1 or condition2 }}
{{ not condition }}
{# Pertenencia #}
{{ 'admin' in user.roles }}
{{ user.role starts with 'super' }}
{{ filename ends with '.jpg' }}
{# Null coalescing #}
{{ page.header.author ?? 'Anónimo' }}
{{ page.header.author|default('Anónimo') }} {# Preferido #}
{# Concatenación #}
{{ 'Hola ' ~ user.name }}
{{ page.title ~ ' - ' ~ site.title }}
{# Matemáticos #}
{{ 10 + 5 }}
{{ 20 - 8 }}
{{ 4 * 3 }}
{{ 15 / 3 }}
{{ 17 % 5 }} {# Módulo: 2 #}
{{ 2 ** 3 }} {# Potencia: 8 #}
{# Operador matches (regex) #}
{% if phone matches '/^[0-9]{9}$/' %}
Teléfono válido
{% endif %}
{# Array multidimensional #}
{% set menu = [
{
title: 'Inicio',
url: '/',
children: [
{title: 'Sobre nosotros', url: '/about'},
{title: 'Servicios', url: '/services'}
]
},
{
title: 'Blog',
url: '/blog',
children: []
}
] %}
{# Iteración anidada #}
<nav>
{% for item in menu %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item.children %}
<ul>
{% for child in item.children %}
<li><a href="{{ child.url }}">{{ child.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</nav>
{# Merge de arrays #}
{% set default_config = {color: 'blue', size: 'medium'} %}
{% set user_config = {size: 'large'} %}
{% set final_config = default_config|merge(user_config) %}
{# Resultado: {color: 'blue', size: 'large'} #}
Los filtros transforman valores usando el operador pipe |:
{{ variable|filter }}
{{ variable|filter(param1, param2) }}
{{ variable|filter1|filter2|filter3 }} {# Encadenamiento #}
Categorías de filtros:
{# Transformación de case #}
{{ 'hola mundo'|upper }} {# HOLA MUNDO #}
{{ 'HOLA MUNDO'|lower }} {# hola mundo #}
{{ 'hola mundo'|title }} {# Hola Mundo #}
{{ 'hola mundo'|capitalize }} {# Hola mundo #}
{# Recorte y padding #}
{{ ' espacios '|trim }} {# 'espacios' #}
{{ 'texto'|truncate(10) }} {# 'texto...' (si > 10) #}
{{ 'texto'|truncate(10, true) }} {# Corte por palabra #}
{{ 'texto'|truncate(10, false, '→') }} {# Custom ending #}
{# Reemplazo #}
{{ 'Hola NAME'|replace({'NAME': 'Juan'}) }}
{# Resultado: 'Hola Juan' #}
{# Split y join #}
{% set palabras = 'uno,dos,tres'|split(',') %}
{# ['uno', 'dos', 'tres'] #}
{{ ['uno', 'dos', 'tres']|join(', ') }}
{# 'uno, dos, tres' #}
{# Formato #}
{{ 'Hola %s, tienes %d años'|format('Ana', 25) }}
{# 'Hola Ana, tienes 25 años' #}
{# Encoding #}
{{ html_content|striptags }} {# Eliminar HTML #}
{{ url|url_encode }} {# Encode para URL #}
{{ text|e }} {# HTML escape (seguridad) #}
{{ text|e('html_attr') }} {# Para atributos HTML #}
{{ json_text|json_encode }} {# Convertir a JSON #}
{# Otros útiles #}
{{ text|nl2br }} {# \n a <br> #}
{{ text|raw }} {# No escapar (¡cuidado!) #}
{{ 'hello'|length }} {# 5 #}
{{ text|reverse }} {# Invertir string #}
{# Manipulación básica #}
{{ [1, 2, 3]|length }} {# 3 #}
{{ [3, 1, 2]|sort }} {# [1, 2, 3] #}
{{ [1, 2, 3]|reverse }} {# [3, 2, 1] #}
{{ [1, 2, 2, 3]|unique }} {# [1, 2, 3] #}
{# Slice (extraer porción) #}
{{ items|slice(0, 5) }} {# Primeros 5 #}
{{ items|slice(5) }} {# Desde el 5° en adelante #}
{{ items|slice(-3) }} {# Últimos 3 #}
{# First y last #}
{{ items|first }} {# Primer elemento #}
{{ items|last }} {# Último elemento #}
{# Merge (combinar arrays) #}
{% set arr1 = [1, 2] %}
{% set arr2 = [3, 4] %}
{{ arr1|merge(arr2) }} {# [1, 2, 3, 4] #}
{# Batch (dividir en grupos) #}
{% set items = [1, 2, 3, 4, 5, 6] %}
{% for row in items|batch(3) %}
<div class="row">
{% for item in row %}
<div class="col">{{ item }}</div>
{% endfor %}
</div>
{% endfor %}
{# Resultado: 2 filas de 3 columnas #}
{# Keys y column #}
{{ {a: 1, b: 2}|keys }} {# ['a', 'b'] #}
{% set users = [
{name: 'Ana', age: 30},
{name: 'Luis', age: 25}
] %}
{{ users|column('name') }} {# ['Ana', 'Luis'] #}
{# Map (transformar elementos) #}
{{ [1, 2, 3]|map(n => n * 2) }} {# [2, 4, 6] #}
{# Filter (filtrar elementos) #}
{{ [1, 2, 3, 4]|filter(n => n > 2) }} {# [3, 4] #}
{# Reduce (acumular) #}
{{ [1, 2, 3]|reduce((acc, n) => acc + n, 0) }} {# 6 #}
{# Formateo básico #}
{{ page.date|date('d/m/Y') }} {# 25/11/2024 #}
{{ page.date|date('d M Y') }} {# 25 Nov 2024 #}
{{ page.date|date('H:i') }} {# 14:30 #}
{# Formatos ISO #}
{{ page.date|date('c') }} {# ISO 8601: 2024-11-25T14:30:00+01:00 #}
{{ page.date|date('Y-m-d') }} {# 2024-11-25 #}
{# Fechas relativas (requiere Carbon o IntlExtension) #}
{{ page.date|date_modify('+1 day')|date('Y-m-d') }}
{# En español (configurar locale) #}
{{ page.date|date('d \\d\\e F \\d\\e Y') }} {# 25 de noviembre de 2024 #}
{# Timestamp #}
{{ 'now'|date('U') }} {# Unix timestamp #}
{{ page.date|date('U') }}
{# Fechas en Grav (filtros específicos) #}
{{ page.date|nicetime }} {# '2 horas atrás' #}
{{ page.date|nicetime(false) }} {# Sin 'atrás/adelante' #}
{# Markdown a HTML #}
{{ page.content|raw }} {# Ya procesado por Grav #}
{{ custom_markdown|markdown }} {# Procesar Markdown custom #}
{# URLs #}
{{ 'theme://images/logo.png'|url }}
{# Convierte a URL absoluta #}
{{ page.url(true) }} {# URL absoluta #}
{{ page.url(false) }} {# URL relativa #}
{# Media (imágenes) #}
{{ page.media['photo.jpg'].url }}
{{ page.media['photo.jpg'].cropResize(400, 300).url }}
{{ page.media['photo.jpg'].quality(85).url }}
{# Definir default si no existe #}
{{ page.header.author|default('Anónimo') }}
{{ page.header.image|default('theme://images/placeholder.jpg') }}
{# Randomize (aleatorizar colección) #}
{% set random_posts = page.collection|randomize %}
{# Ksort (ordenar por keys) #}
{{ array|ksort }}
{# Yaml encode/decode #}
{{ data|yaml_encode }}
{{ yaml_string|yaml_decode }}
Diferencia clave:
{{ value|filter }}{{ function(params) }}Funciones principales:
{# Date #}
{{ date() }} {# Fecha actual #}
{{ date('now') }} {# Equivalente #}
{{ date('+1 day') }} {# Mañana #}
{{ date(page.header.event_date) }} {# Desde string #}
{# Random #}
{{ random() }} {# Número aleatorio #}
{{ random(100) }} {# Entre 0-100 #}
{{ random(10, 50) }} {# Entre 10-50 #}
{{ random(['rojo', 'azul', 'verde']) }} {# Item aleatorio #}
{# Range (secuencias) #}
{% for i in range(1, 5) %}
{{ i }} {# 1, 2, 3, 4, 5 #}
{% endfor %}
{% for letra in range('a', 'e') %}
{{ letra }} {# a, b, c, d, e #}
{% endfor %}
{# Max/Min #}
{{ max(1, 3, 2) }} {# 3 #}
{{ min([5, 3, 8]) }} {# 3 #}
{# Attribute (acceso dinámico) #}
{% set field = 'title' %}
{{ attribute(page, field) }} {# page.title #}
{{ attribute(page.header, field) }}
{# Block (renderizar bloque) #}
{{ block('content') }}
{# Parent (contenido del bloque padre) #}
{% block title %}
{{ parent() }} - Mi Sitio
{% endblock %}
{# Include (incluir template) #}
{{ include('partials/header.html.twig') }}
{{ include('partials/card.html.twig', {title: 'Hola'}) }}
{# Source (leer archivo sin procesar) #}
{{ source('partials/code-example.html') }}
{# Dump (debug - solo desarrollo) #}
{{ dump(page) }}
{{ dump(page.header) }}
{# URL y rutas #}
{{ url('theme://images/logo.png') }}
{{ base_url }}
{{ base_url_absolute }}
{{ base_url_relative }}
{# Configuración #}
{{ config.site.title }}
{{ config.get('system.pages.theme') }}
{# Usuario #}
{{ grav.user.authenticated }}
{{ grav.user.username }}
{{ grav.user.email }}
{# Páginas #}
{{ page.find('/blog') }} {# Buscar página por ruta #}
{{ pages.all }} {# Todas las páginas #}
{{ pages.children }} {# Hijos de root #}
{# Traducción (i18n) #}
{{ 'PLUGIN_ADMIN.SAVE'|t }}
{{ 'Hola %s'|t|format(user.name) }}
{# Assets #}
{% do assets.addCss('theme://css/custom.css') %}
{% do assets.addJs('theme://js/app.js') %}
{# Autorización #}
{% if authorize(['admin.login']) %}
{# Usuario tiene permiso #}
{% endif %}
En [theme].php:
public function onTwigExtensions()
{
$twig = $this->grav['twig']->twig;
// Función simple
$twig->addFunction(
new \Twig\TwigFunction('my_function', function($param) {
return strtoupper($param);
})
);
// Función que accede a Grav
$twig->addFunction(
new \Twig\TwigFunction('get_pages_count', function() {
return count($this->grav['pages']->all());
})
);
// Función con opciones
$twig->addFunction(
new \Twig\TwigFunction('icon', [$this, 'iconHelper'], [
'is_safe' => ['html'] // Permite HTML
])
);
}
public function iconHelper($name, $class = '')
{
return sprintf('<i class="icon icon-%s %s"></i>', $name, $class);
}
Uso en Twig:
{{ my_function('hello') }} {# HELLO #}
{{ get_pages_count() }} {# 42 #}
{{ icon('star', 'text-yellow') }} {# <i class="icon icon-star text-yellow"></i> #}
Los patterns (patrones) son soluciones probadas para problemas comunes:
Ventajas:
{# Grid de 3 columnas con fallback #}
{% set items = page.collection %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for item in items %}
<div class="grid-item">
{% if item.media.images|first %}
<img src="{{ item.media.images|first.cropResize(400, 300).url }}"
alt="{{ item.title }}"
loading="lazy">
{% endif %}
<h3>{{ item.title }}</h3>
<p>{{ item.summary(150) }}</p>
<a href="{{ item.url }}">Leer más →</a>
</div>
{% else %}
<p class="col-span-full text-center">No hay items disponibles.</p>
{% endfor %}
</div>
{% set collection = page.collection %}
{% set pagination = collection.params.pagination %}
{% if pagination.totalPages > 1 %}
<nav class="pagination" aria-label="Paginación">
<ul class="pagination-list">
{# Anterior #}
{% if pagination.currentPage > 1 %}
<li>
<a href="{{ page.url ~ pagination.params ~ '/' ~ pagination.prevUrl }}"
rel="prev">
← Anterior
</a>
</li>
{% endif %}
{# Páginas numéricas #}
{% for i in range(1, pagination.totalPages) %}
{% if i == pagination.currentPage %}
<li class="active" aria-current="page">
<span>{{ i }}</span>
</li>
{% elseif i == 1 or i == pagination.totalPages or (i >= pagination.currentPage - 2 and i <= pagination.currentPage + 2) %}
<li>
<a href="{{ page.url ~ pagination.params ~ (i > 1 ? '/page:' ~ i : '') }}">
{{ i }}
</a>
</li>
{% elseif i == pagination.currentPage - 3 or i == pagination.currentPage + 3 %}
<li class="ellipsis">...</li>
{% endif %}
{% endfor %}
{# Siguiente #}
{% if pagination.currentPage < pagination.totalPages %}
<li>
<a href="{{ page.url ~ pagination.params ~ '/' ~ pagination.nextUrl }}"
rel="next">
Siguiente →
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{# Macro reutilizable #}
{% macro lazy_image(media, alt, width, height, class) %}
{% set placeholder = 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 ' ~ width ~ ' ' ~ height ~ '\'%3E%3C/svg%3E' %}
<img
src="{{ placeholder }}"
data-src="{{ media.cropResize(width, height).url }}"
alt="{{ alt|e('html_attr') }}"
class="lazy {{ class }}"
width="{{ width }}"
height="{{ height }}"
loading="lazy"
>
{% endmacro %}
{# Uso #}
{% import _self as macros %}
{% for post in page.collection %}
{{ macros.lazy_image(
post.media.images|first,
post.title,
400,
300,
'post-thumbnail'
) }}
{% endfor %}
{% set breadcrumbs = [] %}
{% set current = page %}
{# Construir jerarquía #}
{% for crumb in page.parents %}
{% if crumb.routable and crumb.visible %}
{% set breadcrumbs = breadcrumbs|merge([{
title: crumb.title,
url: crumb.url
}]) %}
{% endif %}
{% endfor %}
{# Añadir página actual #}
{% set breadcrumbs = breadcrumbs|merge([{
title: page.title,
url: null
}]) %}
{# Renderizar #}
<nav aria-label="Breadcrumb" class="breadcrumbs">
<ol itemscope itemtype="https://schema.org/BreadcrumbList">
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ base_url }}">
<span itemprop="name">Inicio</span>
</a>
<meta itemprop="position" content="1">
</li>
{% for crumb in breadcrumbs %}
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
{% if crumb.url %}
<a itemprop="item" href="{{ crumb.url }}">
<span itemprop="name">{{ crumb.title }}</span>
</a>
{% else %}
<span itemprop="name" aria-current="page">{{ crumb.title }}</span>
{% endif %}
<meta itemprop="position" content="{{ loop.index + 1 }}">
</li>
{% endfor %}
</ol>
</nav>
{# Macro de tarjeta reutilizable #}
{% macro card(title, content, image, url, tags) %}
<article class="card">
{% if image %}
<div class="card-image">
<img src="{{ image }}" alt="{{ title }}">
</div>
{% endif %}
<div class="card-content">
<h3 class="card-title">
{% if url %}
<a href="{{ url }}">{{ title }}</a>
{% else %}
{{ title }}
{% endif %}
</h3>
<p class="card-text">{{ content }}</p>
{% if tags %}
<div class="card-tags">
{% for tag in tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</article>
{% endmacro %}
{# Uso #}
{% import _self as components %}
{% for post in page.collection %}
{{ components.card(
post.title,
post.summary(150),
post.media.images|first.url,
post.url,
post.taxonomy.tag
) }}
{% endfor %}
Partials:
{% include 'partials/header.html.twig' %}Components (Macros):
{% import 'components.html.twig' as comp %}Cuándo usar cada uno:
templates/
├── partials/
│ ├── base.html.twig # Template base
│ ├── head.html.twig # <head> section
│ ├── header.html.twig # Header del sitio
│ ├── navigation.html.twig # Menú principal
│ ├── breadcrumbs.html.twig # Migas de pan
│ ├── sidebar.html.twig # Barra lateral
│ ├── footer.html.twig # Pie de página
│ └── pagination.html.twig # Paginación
{# Include básico (hereda todo el contexto) #}
{% include 'partials/header.html.twig' %}
{# Include con parámetros adicionales #}
{% include 'partials/card.html.twig' with {
title: 'Mi Título',
content: 'Mi contenido',
highlighted: true
} %}
{# Include solo con parámetros específicos (no hereda contexto) #}
{% include 'partials/button.html.twig' with {
text: 'Click aquí',
url: '/contact'
} only %}
{# Include condicional #}
{% if show_sidebar %}
{% include 'partials/sidebar.html.twig' %}
{% endif %}
{# Include dinámico #}
{% set template_name = page.template %}
{% include 'partials/' ~ template_name ~ '-extras.html.twig' ignore missing %}
{# ignore missing: no error si no existe #}
templates/macros/components.html.twig:
{# Botón reutilizable #}
{% macro button(text, url, style, size, icon) %}
<a href="{{ url }}"
class="btn btn-{{ style|default('primary') }} btn-{{ size|default('md') }}">
{% if icon %}
<i class="icon-{{ icon }}"></i>
{% endif %}
{{ text }}
</a>
{% endmacro %}
{# Badge/etiqueta #}
{% macro badge(text, color, rounded) %}
<span class="badge badge-{{ color|default('gray') }} {% if rounded %}rounded-full{% endif %}">
{{ text }}
</span>
{% endmacro %}
{# Alert/aviso #}
{% macro alert(message, type, dismissible) %}
<div class="alert alert-{{ type|default('info') }} {% if dismissible %}alert-dismissible{% endif %}"
role="alert">
{{ message|raw }}
{% if dismissible %}
<button type="button" class="alert-close" aria-label="Cerrar">×</button>
{% endif %}
</div>
{% endmacro %}
{# Card completa #}
{% macro card(data) %}
<article class="card {{ data.class|default('') }}">
{% if data.image %}
<div class="card-image">
<img src="{{ data.image }}" alt="{{ data.title }}">
{% if data.badge %}
{{ _self.badge(data.badge.text, data.badge.color) }}
{% endif %}
</div>
{% endif %}
<div class="card-body">
<h3 class="card-title">{{ data.title }}</h3>
{% if data.meta %}
<div class="card-meta">
{{ data.meta|raw }}
</div>
{% endif %}
<p class="card-text">{{ data.content }}</p>
{% if data.button %}
{{ _self.button(data.button.text, data.button.url, data.button.style) }}
{% endif %}
</div>
</article>
{% endmacro %}
Uso en templates:
{% import 'macros/components.html.twig' as ui %}
{# Botones #}
{{ ui.button('Descargar', '/download', 'success', 'lg', 'download') }}
{{ ui.button('Cancelar', '/back', 'secondary') }}
{# Badges #}
{{ ui.badge('Nuevo', 'green', true) }}
{{ ui.badge('Destacado', 'yellow') }}
{# Alerts #}
{{ ui.alert('¡Guardado correctamente!', 'success', true) }}
{{ ui.alert('<strong>Error:</strong> Campos inválidos', 'danger') }}
{# Card completa #}
{{ ui.card({
title: post.title,
content: post.summary(150),
image: post.media.images|first.url,
badge: {text: 'Popular', color: 'red'},
meta: post.date|date('d M Y'),
button: {text: 'Leer más', url: post.url, style: 'primary'}
}) }}
{# Diferencia entre include y embed #}
{# INCLUDE: Solo sustituye contenido #}
{% include 'partials/card.html.twig' %}
{# EMBED: Permite sobrescribir bloques #}
{% embed 'partials/card.html.twig' %}
{% block card_title %}
<h3 class="custom-title">{{ page.title }}</h3>
{% endblock %}
{% block card_footer %}
<div class="custom-footer">
<a href="{{ page.url }}">Ver más</a>
</div>
{% endblock %}
{% endembed %}
partials/card.html.twig (diseñado para embed):
<article class="card">
<div class="card-body">
{% block card_title %}
<h3>{{ title }}</h3>
{% endblock %}
{% block card_content %}
<p>{{ content }}</p>
{% endblock %}
{% block card_footer %}
{# Footer opcional #}
{% endblock %}
</div>
</article>
Metodología paso a paso:
Patrón de identificación:
| HTML Estático | Reemplazo Twig |
|---|---|
<title>Mi Sitio</title> |
<title>{{ site.title }}</title> |
<h1>Título fijo</h1> |
<h1>{{ page.title }}</h1> |
<img src="logo.png"> |
<img src="{{ theme_config.logo }}"> |
<p>Lorem ipsum...</p> |
<p>{{ page.content\|raw }}</p> |
| Listas hardcoded | Bucles {% for %} |
HTML estático original:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Blog - Mi Sitio Web</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<img src="images/logo.png" alt="Logo">
<nav>
<a href="/">Inicio</a>
<a href="/blog">Blog</a>
<a href="/contacto">Contacto</a>
</nav>
</header>
<main>
<h1>Últimos Artículos</h1>
<article>
<h2>Mi primer post</h2>
<p class="date">15 de noviembre, 2024</p>
<p>Este es el resumen del primer post...</p>
<a href="/blog/primer-post">Leer más</a>
</article>
<article>
<h2>Mi segundo post</h2>
<p class="date">10 de noviembre, 2024</p>
<p>Este es el resumen del segundo post...</p>
<a href="/blog/segundo-post">Leer más</a>
</article>
</main>
<footer>
<p>© 2024 Mi Sitio Web</p>
</footer>
<script src="js/main.js"></script>
</body>
</html>
Paso 1: Crear base.html.twig
<!DOCTYPE html>
<html lang="{{ grav.language.getActive|default('es') }}">
<head>
{% block head %}
<meta charset="UTF-8">
<title>{% block title %}{{ page.title }} - {{ site.title }}{% endblock %}</title>
{% block stylesheets %}
{{ assets.css()|raw }}
{% endblock %}
{% endblock %}
</head>
<body class="template-{{ page.template }}">
{% block header %}
{% include 'partials/header.html.twig' %}
{% endblock %}
<main class="main-content">
{% block content %}
{# Contenido específico por template #}
{% endblock %}
</main>
{% block footer %}
{% include 'partials/footer.html.twig' %}
{% endblock %}
{% block javascripts %}
{{ assets.js()|raw }}
{% endblock %}
</body>
</html>
Paso 2: Extraer header (partials/header.html.twig)
<header class="site-header">
<div class="container">
{% if theme_config.logo %}
<img src="{{ theme_config.logo }}" alt="{{ site.title }}">
{% else %}
<span class="site-title">{{ site.title }}</span>
{% endif %}
<nav class="main-nav">
{% for item in pages.children.visible %}
<a href="{{ item.url }}"
class="{% if item.active %}active{% endif %}">
{{ item.menu|default(item.title) }}
</a>
{% endfor %}
</nav>
</div>
</header>
Paso 3: Extraer footer (partials/footer.html.twig)
<footer class="site-footer">
<div class="container">
<p>© {{ 'now'|date('Y') }} {{ site.title }}. {{ 'Todos los derechos reservados.'|t }}</p>
{% if theme_config.social_links %}
<div class="social-links">
{% for social in theme_config.social_links %}
<a href="{{ social.url }}" target="_blank" rel="noopener">
{{ social.platform }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</footer>
Paso 4: Crear template de blog (blog.html.twig)
{% extends 'partials/base.html.twig' %}
{% block title %}{{ page.title }} - {{ site.title }}{% endblock %}
{% block content %}
<div class="container">
<h1>{{ page.title }}</h1>
{% if page.header.description %}
<p class="lead">{{ page.header.description }}</p>
{% endif %}
<div class="blog-posts">
{% for post in page.collection.order('date', 'desc') %}
<article class="post-card">
<h2>
<a href="{{ post.url }}">{{ post.title }}</a>
</h2>
<p class="date">
<time datetime="{{ post.date|date('c') }}">
{{ post.date|date('d \\d\\e F, Y') }}
</time>
</p>
<p class="summary">{{ post.summary(200) }}</p>
<a href="{{ post.url }}" class="read-more">Leer más →</a>
</article>
{% else %}
<p>No hay artículos disponibles.</p>
{% endfor %}
</div>
{# Paginación #}
{% if config.plugins.pagination.enabled %}
{% include 'partials/pagination.html.twig' %}
{% endif %}
</div>
{% endblock %}
Paso 5: Registrar assets en PHP
public function onTwigSiteVariables()
{
$this->grav['assets']->addCss('theme://css/style.css');
$this->grav['assets']->addJs('theme://js/main.js', ['group' => 'bottom']);
}
HTML original:
<div class="products">
<div class="product">
<img src="product1.jpg">
<h3>Producto 1</h3>
<p class="price">$29.99</p>
</div>
<div class="product">
<img src="product2.jpg">
<h3>Producto 2</h3>
<p class="price">$39.99</p>
</div>
</div>
Twig dinámico con filtros:
{% set products = page.header.products %}
<div class="products-grid">
{% for product in products|filter(p => p.available)|sort_by_field('price') %}
<div class="product-card">
{% if product.image %}
<img src="{{ product.image|url }}"
alt="{{ product.name }}"
loading="lazy">
{% else %}
<img src="{{ url('theme://images/placeholder.jpg') }}"
alt="Sin imagen">
{% endif %}
<h3>{{ product.name }}</h3>
<p class="price">
{% if product.discount %}
<span class="original">${{ product.price }}</span>
<span class="discounted">${{ product.price * (1 - product.discount) }}</span>
{% else %}
${{ product.price }}
{% endif %}
</p>
{% if product.rating %}
<div class="rating">
{% for i in range(1, 5) %}
<span class="star {% if i <= product.rating %}filled{% endif %}">★</span>
{% endfor %}
</div>
{% endif %}
<a href="{{ product.url }}" class="btn">Ver producto</a>
</div>
{% endfor %}
</div>
Datos en products.md:
---
title: Productos
products:
- name: Producto Premium
price: 49.99
discount: 0.2
available: true
rating: 5
image: theme://images/products/premium.jpg
url: /productos/premium
- name: Producto Básico
price: 29.99
available: true
rating: 4
image: theme://images/products/basico.jpg
url: /productos/basico
---
¿Por qué cachear?
Tipos de cache en Grav:
public function onTwigExtensions()
{
$twig = $this->grav['twig']->twig;
// Función que cachea resultados
$twig->addFunction(
new \Twig\TwigFunction('get_popular_posts', [$this, 'getPopularPosts'])
);
}
public function getPopularPosts($limit = 5)
{
$cache = $this->grav['cache'];
$cache_id = 'popular_posts_' . $limit;
// Intentar obtener del cache
$cached = $cache->fetch($cache_id);
if ($cached) {
return $cached;
}
// Si no existe en cache, calcular
$pages = $this->grav['pages'];
$collection = $pages->all()->published()->order('hits', 'desc');
$popular = [];
foreach ($collection as $page) {
if (count($popular) >= $limit) {
break;
}
$popular[] = [
'title' => $page->title(),
'url' => $page->url(),
'hits' => $page->header()->hits ?? 0
];
}
// Guardar en cache (1 hora = 3600 segundos)
$cache->save($cache_id, $popular, 3600);
return $popular;
}
Uso en Twig:
<aside class="popular-posts">
<h3>Posts Populares</h3>
<ul>
{% for post in get_popular_posts(5) %}
<li>
<a href="{{ post.url }}">
{{ post.title }}
<span class="hits">({{ post.hits }} vistas)</span>
</a>
</li>
{% endfor %}
</ul>
</aside>
En [theme].php:
public function onShortcodeHandlers()
{
$this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/shortcodes');
}
Crear shortcodes/AlertShortcode.php:
<?php
namespace Grav\Plugin\Shortcodes;
use Thunder\Shortcode\Shortcode\ShortcodeInterface;
class AlertShortcode extends Shortcode
{
public function init()
{
$this->shortcode->getHandlers()->add('alert', function(ShortcodeInterface $sc) {
$type = $sc->getParameter('type', 'info');
$icon = $this->getIcon($type);
$output = sprintf(
'<div class="alert alert-%s" role="alert">
<span class="alert-icon">%s</span>
<div class="alert-content">%s</div>
</div>',
$type,
$icon,
$sc->getContent()
);
return $output;
});
}
protected function getIcon($type)
{
$icons = [
'info' => 'ℹ️',
'success' => '✅',
'warning' => '⚠️',
'danger' => '🚫'
];
return $icons[$type] ?? $icons['info'];
}
}
Uso en contenido Markdown:
[alert type="success"]
¡El formulario se envió correctamente!
[/alert]
[alert type="warning"]
Este contenido está en revisión.
[/alert]
[alert type="danger"]
**Error crítico:** No se pudo conectar a la base de datos.
[/alert]
public function onTwigExtensions()
{
$twig = $this->grav['twig']->twig;
// Filtro para generar excerpt inteligente
$twig->addFilter(
new \Twig\TwigFilter('smart_excerpt', [$this, 'smartExcerpt'])
);
}
public function smartExcerpt($text, $length = 150)
{
// Limpiar HTML
$text = strip_tags($text);
// Si es más corto que el límite, retornar completo
if (mb_strlen($text) <= $length) {
return $text;
}
// Cortar en la palabra más cercana
$text = mb_substr($text, 0, $length);
$lastSpace = mb_strrpos($text, ' ');
if ($lastSpace !== false) {
$text = mb_substr($text, 0, $lastSpace);
}
// Añadir puntos suspensivos
return $text . '...';
}
Uso:
<p class="excerpt">{{ page.content|smart_excerpt(200) }}</p>
Has dominado:
✅ Sintaxis avanzada de Twig: Operadores, estructuras de control, variables complejas
✅ Filtros esenciales: String, array, fecha y específicos de Grav
✅ Funciones del core: date, random, range y funciones de Grav
✅ Patterns reutilizables: Grid, paginación, lazy loading, breadcrumbs
✅ Sistema modular: Partials, macros y componentes organizados
✅ Conversión HTML → Twig: Metodología completa de dinamización
✅ Funciones avanzadas: Cache, shortcodes personalizados y filtros complejos
Próximos pasos: En el siguiente módulo trabajaremos con páginas modulares, colecciones avanzadas y taxonomías para crear experiencias de contenido sofisticadas y dinámicas.