Módulo 5: Twig Avanzado y Patrones de Desarrollo

Introducción

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.


1. Sintaxis Avanzada de Twig: Tipos, Estructuras y Lógica Compleja

1.1 Teoría: Fundamentos de Twig

Tres tipos de delimitadores en Twig:

  1. {{ ... }} - Expresiones (imprimir contenido)
  2. {% ... %} - Declaraciones (lógica, bucles, bloques)
  3. {# ... #} - 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) }}

1.2 Estructuras de Control Avanzadas

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í #}

1.3 Operadores y Comparaciones

{# 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 %}

1.4 Estructuras de Datos Complejas

{# 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'} #}

2. Filtros Esenciales: String, Fecha, Arrays y Específicos de Grav

2.1 Teoría: Sistema de Filtros

Los filtros transforman valores usando el operador pipe |:

{{ variable|filter }}
{{ variable|filter(param1, param2) }}
{{ variable|filter1|filter2|filter3 }}  {# Encadenamiento #}

Categorías de filtros:

  • String: Manipulación de texto
  • Array: Operaciones con listas
  • Fecha: Formateo temporal
  • Escape: Seguridad y encoding
  • Grav: Específicos del CMS

2.2 Filtros de String

{# 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 #}

2.3 Filtros de Array

{# 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 #}

2.4 Filtros de Fecha

{# 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' #}

2.5 Filtros Específicos de Grav

{# 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 }}

3. Funciones del Core y Personalizadas

3.1 Teoría: Funciones vs Filtros

Diferencia clave:

  • Filtros: Transforman un valor → {{ value|filter }}
  • Funciones: Generan un valor → {{ function(params) }}

Funciones principales:

  • date(): Crear fechas
  • random(): Valores aleatorios
  • range(): Secuencias numéricas
  • max()/min(): Valores extremos
  • dump(): Debug (solo en desarrollo)

3.2 Funciones del Core de Twig

{# 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) }}

3.3 Funciones Específicas de Grav

{# 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 %}

3.4 Crear Funciones Personalizadas

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> #}

4. Recipes y Patrones Comunes Reutilizables

4.1 Teoría: DRY (Don't Repeat Yourself)

Los patterns (patrones) son soluciones probadas para problemas comunes:

  • Grid responsivo: Mostrar items en columnas adaptables
  • Paginación: Navegar entre páginas de resultados
  • Lazy loading: Cargar imágenes bajo demanda
  • Breadcrumbs: Rutas de navegación
  • Cards: Tarjetas de contenido

Ventajas:

  • Código consistente y predecible
  • Menos bugs (soluciones probadas)
  • Desarrollo más rápido
  • Fácil mantenimiento

4.2 Pattern: Grid Responsivo

{# 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>

4.3 Pattern: Paginación Completa

{% 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 %}

4.4 Pattern: Lazy Loading de Imágenes

{# 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 %}

4.5 Pattern: Breadcrumbs Estructurados

{% 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>

4.6 Pattern: Card Component

{# 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 %}

5. Sistema de Partials y Componentes Modulares

5.1 Teoría: Partials vs Components

Partials:

  • Fragmentos de template incluibles
  • No reciben parámetros formales (usan contexto)
  • Uso: {% include 'partials/header.html.twig' %}

Components (Macros):

  • Funciones Twig reutilizables
  • Reciben parámetros explícitos
  • Retornan HTML
  • Uso: {% import 'components.html.twig' as comp %}

Cuándo usar cada uno:

  • Partial: Secciones fijas (header, footer, sidebar)
  • Macro: Elementos repetitivos con variaciones (botones, cards, badges)

5.2 Organización de Partials

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

5.3 Incluir Partials con Parámetros

{# 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 #}

5.4 Sistema de Componentes con Macros

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'}
}) }}

5.5 Embed: Partials con Herencia

{# 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>

6. Conversión Completa de HTML Estático a Templates Dinámicos

6.1 Teoría: Proceso de Conversión

Metodología paso a paso:

  1. Analizar: Identificar secciones, datos estáticos vs dinámicos
  2. Extraer: Separar componentes reutilizables
  3. Dinamizar: Reemplazar contenido fijo por variables
  4. Modularizar: Crear partials y macros
  5. Optimizar: Aplicar filtros y funciones Twig

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 %}

6.2 Ejemplo Completo: De HTML a Twig

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>&copy; 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>&copy; {{ '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']);
}

6.3 Conversión Avanzada: Lista con Filtros

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
---

7. Funciones Personalizadas Avanzadas con Cache y Shortcodes

7.1 Teoría: Optimización con Cache

¿Por qué cachear?

  • Operaciones costosas (queries complejas, procesamiento)
  • Evitar recalcular en cada request
  • Mejorar rendimiento significativamente

Tipos de cache en Grav:

  • Page cache: Páginas HTML completas
  • Twig cache: Templates compilados
  • Data cache: Datos procesados personalizados

7.2 Función con Cache

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>

7.3 Shortcodes Personalizados

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]

7.4 Filtro Personalizado con Lógica Compleja

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>

Resumen del Módulo

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.