Ir al contenido
Código en GitHub (se abre en una pestaña nueva)

Documentación · Referencia · El formato de un proyecto

7. Plantillas

Twig: layouts, parciales, variables, funciones y filtros.

Versión 0.16

Motor: Twig. Los layouts viven en plantillas/ y los fragmentos reutilizables en parciales/.

7.1 Plantillas y parciales

Dentro de Twig, las plantillas y los parciales se nombran con su ruta desde la raíz del proyecto, y Twig no puede leer nada fuera de esas dos carpetas, salvo las plantillas de los atajos que trae el motor, bajo @ehundu/: son suyas, un sitio no debe incluirlas (para cambiar un atajo, §8.1) y pueden cambiar o quitarse en cualquier versión (decisión 145):

{% extends 'plantillas/base.twig' %}
{% include 'parciales/cabecera.twig' %}

El layout de una página se elige con plantilla, por su nombre sin carpeta ni extensión: plantilla: articulo es plantillas/articulo.twig. Si no se indica, se usa pagina. Con plantilla: false la página sale tal cual, sin layout, que es lo que necesita un robots.txt. Si la plantilla que usa una página no existe, el build se detiene: publicar el contenido sin su layout sería peor que no publicarlo.

Las plantillas no llevan front matter. Se anidan con {% extends %} y bloques, como en cualquier proyecto de Twig. El cuerpo de la página está siempre en {{ pagina.contenido }}:

{# plantillas/articulo.twig #}
{% extends 'plantillas/base.twig' %}
{% block cuerpo %}
  <article>{{ pagina.contenido }}</article>
{% endblock %}

Las páginas .twig de contenido/ son plantillas de Twig con las mismas variables; su resultado es pagina.contenido y va dentro de su layout como el de cualquier otra página.

7.2 Variables

  • sitio: lo que hay en sitio.yml sin la sección despliegue, con url sin la barra final y con idioma (el predeterminado) e idiomas (la lista de {codigo, nombre, url}, §15.8) siempre, aunque el fichero no los declare. Los días sin hora, en la zona del sitio (§4).
  • datos: los ficheros de datos/, con los del idioma de la página (§15.6).
  • pagina: el front matter de la página actual, más pagina.url, pagina.contenido (el cuerpo ya convertido a HTML), pagina.ruta, pagina.idioma y pagina.traducciones (§15.8).

Las páginas que dan coleccion() y sus filtros tienen la misma forma: articulo.titulo, articulo.url. También articulo.contenido, que se convierte la primera vez que alguien lo pide. Si una página acaba necesitando su propio contenido para construirse (una página .twig que lista una colección en la que está ella misma y pide el contenido de cada una), el build se detiene con un error que muestra la cadena de páginas.

pagina.markdown es el cuerpo de una página en Markdown tal como está escrito: sin el front matter y sin convertir, con los atajos sin resolver. Las páginas de una colección la tienen también (articulo.markdown). Es lo que necesita un llms-full.txt, que reúne el texto de varios artículos. Twig la escapa como cualquier texto: en un fichero que no es HTML se escribe con |raw. No existe en una página .twig, cuyo cuerpo es una plantilla. Quien la lee depende del fichero de esa página: si cambia, se rehace la que la muestra. Como contenido, tapa un campo del front matter que se llame igual, y no sirve para filtrar ni ordenar (§6.2).

pagina.encabezados son los encabezados del cuerpo que reciben id (§2, §8), en el orden en que aparecen, cada uno con nivel, texto e id. El texto es texto plano, sin enlaces ni énfasis y sin el enlace permanente. Es lo que necesita un índice de apartados. Las páginas de una colección la tienen también (articulo.encabezados) y, como contenido, se calcula la primera vez que alguien la pide, con el mismo error si una página acaba necesitándose a sí misma. Sin la sección encabezados en sitio.yml está vacía; en una página .twig, cuyo cuerpo es una plantilla, también. Quien la lee depende del contenido de esa página: si cambia, se rehace la que la muestra. Como contenido, tapa un campo del front matter que se llame igual, y no sirve para filtrar ni ordenar (§6.2).

Una página que genera otra con agrupar (§6.3) tiene además pagina.grupo, el valor, y pagina.paginas, las páginas del grupo. Las páginas generadas las tienen también cuando salen de coleccion(). Una página con paginar (§6.4), y las que genera, tienen pagina.paginas, las de su trozo, y pagina.paginacion.

Los nombres que el motor escribe en pagina son estos, y solo estos. Si el front matter de una página tiene un campo con el mismo nombre, el del motor tapa el suyo (decisión 133):

Nombre Qué es Cuándo Un campo propio con ese nombre
url su dirección (§5) siempre reservado (§4)
idioma su idioma (§15.2) siempre reservado: se avisa y se ignora
pdf la dirección de su PDF (§10.3) con pdf: sí reservado: en el front matter es sí/no
ruta su fichero, desde contenido/; en lo que genera agrupar o paginar, el del fichero que lo define con # y el slug del grupo o el número (etiqueta.md#cultura-pop, blog/index.md#2) siempre se avisa y se ignora
contenido su cuerpo ya convertido a HTML siempre se avisa y se ignora
markdown su cuerpo en Markdown, sin convertir en una página .md se avisa y se ignora
encabezados sus encabezados con id (§8) siempre, vacía si no hay se avisa y se ignora
traducciones sus traducciones (§15.8) siempre, vacía si no hay se avisa y se ignora
grupo el valor de su grupo (§6.3) en lo que genera agrupar solo se tapa en esas páginas
paginas las páginas del grupo o del trozo (§6.3, §6.4) en lo que genera agrupar o paginar solo se tapa en esas páginas
paginacion dónde está en la lista partida (§6.4) en lo que genera paginar solo se tapa en esas páginas

Un nombre nuevo no se añade sin anunciarlo (§12, regla 5).

Un campo que falta sale vacío, como en Nunjucks: {% if pagina.imagen %} funciona sin comprobar antes si existe. Twig escapa el HTML de todo lo que escribe, salvo lo que ya es HTML hecho por el motor: pagina.contenido (también el articulo.contenido de las páginas de una colección y el contenido y el pie que reciben los atajos, §8.1), lo que devuelven svg(), css(), js(), css_fichero() y js_fichero(), y el resultado del filtro markdown (§7.3). Cualquier otro texto, un campo del front matter por ejemplo, se escapa salvo que la plantilla pida lo contrario con |raw.

7.3 Funciones y filtros

  • coleccion(nombre) y los filtros de colección del §6.2.

Las funciones que nombran un fichero de publico/ (svg, dimensiones, variantes, css, js, css_fichero, js_fichero y version) admiten la ruta con o sin / delante.

  • svg(ruta) incrusta un SVG de publico/, tal cual; solo le quita la declaración <?xml ?> y el DOCTYPE (con su subconjunto interno, si lo lleva), que no caben dentro de HTML, estén donde estén antes de la etiqueta <svg>; los comentarios se dejan: {{ svg('svg/telefono.svg') }}. Si el fichero no existe, no es un .svg o la ruta sale de publico/, se avisa y no se inserta nada.

  • dimensiones(ruta) da el ancho y el alto de una imagen de publico/, en píxeles, para width, height o aspect-ratio: {% set d = dimensiones('img/sede.jpg') %} y luego {{ d.ancho }} y {{ d.alto }}. Lee la cabecera del fichero con lo que trae PHP (JPEG, PNG, GIF, WebP, AVIF); no procesa la imagen. De un SVG o de un fichero que no es imagen da null, sin aviso. Si el fichero no existe o la ruta sale de publico/, avisa y da null.

  • variantes(ruta) da las variantes de ancho de una imagen, las que declara la sección imagenes de sitio.yml (§2), para escribir srcset. Admite el tronco (img/foto) o el fichero (img/foto.jpg): quita la extensión si es la de una imagen. Da un mapa con:

    • src, la dirección de la variante más ancha del último formato, con / delante, y ancho y alto de ese fichero;
    • srcset, las variantes del último formato, de menor a mayor ancho y con su ancho real: /img/foto-480.webp 480w, /img/foto-960.webp 960w;
    • fuentes, una lista con un elemento por cada formato anterior al último, en el orden de formatos, cada uno con su tipo (image/avif) y su srcset. Con un solo formato está vacía.

    La plantilla escribe el HTML y decide el sizes, que el motor no sabe:

    {% set f = variantes('img/foto') %}
    <picture>
      {% for fuente in f.fuentes %}<source type="{{ fuente.tipo }}" srcset="{{ fuente.srcset }}" sizes="100vw">{% endfor %}
      <img src="{{ f.src }}" srcset="{{ f.srcset }}" sizes="100vw" width="{{ f.ancho }}" height="{{ f.alto }}" alt="">
    </picture>
    

    Avisa y da null si el sitio no declara imagenes, si la ruta sale de publico/ o si no hay ninguna variante de la imagen. Avisa de cada fichero de variante que falta, y sigue con los que hay; un formato sin ninguna variante no entra. De un SVG no da nada, sin aviso.

  • activo(url) dice si la página actual es esa URL o está dentro de ella: activo('/blog/') es cierto en /blog/ y en /blog/un-articulo/. / solo es activo en la portada, y la raíz de cada idioma (/eu/), en la suya (§15.8). Para comparar exacto, pagina.url == url.

  • version(ruta) da la dirección de un fichero de publico/ con su huella: {{ version('css/estilo.css') }} da /css/estilo.css?v=3fa9c1d2ab. La huella son los 10 primeros caracteres hexadecimales del SHA-256 del fichero, así que cambia cuando cambia su contenido y no antes. Es para enlazar los CSS, los JS, las tipografías y las imágenes que no pasan por css() ni css_fichero() (§9.1): el navegador puede guardarlos mucho tiempo sin que, tras un despliegue, nadie vea una versión vieja. Admite la ruta con o sin / delante. Si el fichero no existe o la ruta sale de publico/, se avisa y da la dirección sin huella. Quien la usa depende del fichero: si cambia, se rehace la página.

  • slug (§5.2).

  • fecha(formato) escribe una fecha con las mismas letras que el filtro date de Twig, pero con los nombres de meses y días en el idioma de la página (§15.9). En castellano, pagina.fecha|fecha('d F Y') da 18 marzo 2025, y sin formato, 18 de marzo de 2025. Una letra que tiene que salir tal cual se escapa con dos barras dentro de la plantilla, igual que con date: fecha('j \\d\\e F') da 18 de marzo. Un valor vacío no da nada; uno que no es una fecha detiene el build.

  • markdown convierte a HTML un texto en Markdown, para los campos del front matter o de datos/ que llevan formato: un titular con una palabra en cursiva, un resumen con una negrita. Con titular: "Una cocina que *viaja*", {{ pagina.titular|markdown }} da Una cocina que <em>viaja</em>. Sin argumento es de línea, el Markdown del §8 sin bloques: no hay <p>, ni encabezados, listas, citas, tablas ni figuras, y los atajos (§8.1) no se reconocen, porque solo viven en el cuerpo. Con markdown('bloque') es de bloque: lo que haya en el campo se convierte como el cuerpo, con párrafos, listas, citas y encabezados, para un texto con varios párrafos dentro de un bloque que la plantilla pone ({{ pagina.intro|markdown('bloque') }}). El modo de bloque sigue sin atajos, y tampoco convierte las tablas ni las notas al pie, ni da id a los encabezados (§2, §8), ni hace figuras de las imágenes: todo eso es del cuerpo. markdown('linea') es lo mismo que sin argumento; cualquier otro modo avisa y se usa el de línea. En los dos modos valen el énfasis, la negrita, el tachado, el código, los enlaces (los externos se tratan como en el cuerpo, con su target y su rel, y el aviso de enlacesExternos), los enlaces automáticos y el HTML escrito a mano (<mark>, <br>). Un salto de línea con una barra invertida o con dos espacios al final de la línea sale como <br>, seguido del salto de línea del texto, como en el cuerpo; uno suelto, como un salto en el HTML, que el navegador lee como un espacio. El resultado es HTML ya hecho: Twig no lo escapa de nuevo, y lo escrito a mano pasa tal cual, como en el cuerpo. Un valor vacío o que falta no da nada, sin aviso; un número se toma como texto; una lista, un mapa o un booleano avisan y no dan nada. En un texto sin formato, la plantilla sigue usando {{ campo }}, que sí escapa.

Todas las fechas se escriben en la zona horaria del sitio (§2), también las del filtro date de Twig, que por sí solo usaría la de la máquina.

Las plantillas pueden usar random() de Twig para elegir al azar, y la fecha del momento ('now'|date('Y'), para el año del pie). Con la misma versión del motor y de sus dependencias, son las dos únicas formas de que el mismo proyecto dé otro HTML en las páginas que salen (§12, regla 3), y tienen un precio: con random(), cada compilación completa elige de nuevo, así que esa página cambia y se vuelve a subir en cada despliegue. La previsualización, que compila de forma incremental, conserva lo elegido, o la fecha, mientras no cambie nada de lo que la página usa.

Ver este documento en GitHub (se abre en una pestaña nueva)