Become a member!

TemplatePro: template inheritance, la feature más potente que muchos olvidan

🌐
Este artículo también está disponible en otros idiomas:
🇮🇹 Italiano  •  🇬🇧 English  •  🇩🇪 Deutsch

Hace unos días estaba en casa de un cliente para una sesión de consultoría sobre una aplicación web construida con DMVCFramework, TemplatePro y HTMX. Estábamos haciendo una revisión del código cuando abro la carpeta templates/ y me detengo.

Veinte archivos HTML. En cada uno de esos archivos estaban los veinte dialogs - copiados y pegados. La estructura exterior era siempre la misma: header con título, formulario, dos botones. Los campos del formulario y el endpoint de destino cambiaban de dialog en dialog, pero la shell HTML era idéntica, repetida 20 veces por archivo, en 20 archivos. Cuatrocientos bloques de markup para lo que lógicamente era un componente con variantes.

Pregunto: “¿Por qué duplicaron todos los dialogs en cada archivo?”

El programador junior, criado a base de React, responde: “Claro, con React podría haberlo hecho mejor…”

"¿De qué manera?" pregunto.

“Ya sabes, estos lenguajes viejos, como HTML y CSS, te obligan a repetirte…”

Me callé. Conté hasta cien. Luego continué.

“Antes de seguir, hablemos de la feature más potente de TemplatePro.”

No es la primera vez. Casos como este me ocurren con cierta frecuencia, y cada vez me encuentro explicando las mismas cosas. Es lo que me convenció de escribir este post - aunque la documentación oficial lo trata ampliamente, y aunque existe una guía de más de 200 páginas que cubre TemplatePro en cada detalle. Esperemos que un post narrativo, que parte de un caso real, ayude a fijar el concepto de forma diferente.

En resumen: En TemplatePro, {{extends "base.html"}} hereda un template padre; {{block "name"}} define una región sobreescribible (como un método virtual de Delphi); {{inherited}} llama a la implementación del padre. La herencia es multinivel sin límites (A←B←C). Los partials reutilizables se incluyen con {{include "file.html"}} y variable mapping para scope aislado.

Template inheritance: el concepto

La template inheritance es el mecanismo con el que un template puede extender otro template, heredar su estructura y sobreescribir solo las partes que cambian. La idea proviene de Jinja2 y otros motores de template modernos. En TemplatePro se realiza con tres construcciones:

  • {{extends "other_template.html"}} - declara que este template extiende otro
  • {{block "name"}}...{{endblock}} - define una región sustituible
  • {{inherited}} - incluye el contenido del bloque del padre sin sobreescribirlo

Si eres programador Delphi, ya tienes todo el modelo mental necesario para entender este mecanismo: cada {{block}} es exactamente como un método virtual. El template padre lo declara con una implementación por defecto. El template hijo puede sobreescribirlo - o no, en cuyo caso vale la implementación del padre. El template nieto puede a su vez sobreescribir el del hijo. Y {{inherited}} corresponde exactamente a la palabra clave inherited de Delphi: llama a la implementación del nivel superior en la cadena, y le añade algo.

Delphi - métodos virtuales:

type
  TBaseView = class
  public
    procedure RenderTitle; virtual;
    procedure RenderContent; virtual;
  end;

  TChildView = class(TBaseView)
  public
    procedure RenderContent; override; // overridden
    // RenderTitle is NOT overridden: parent's implementation applies
  end;

implementation

procedure TBaseView.RenderTitle;
begin
  WriteLn('<title>My App</title>');
end;

procedure TBaseView.RenderContent;
begin
  WriteLn('<!-- default content -->');
end;

procedure TChildView.RenderContent;
begin
  inherited; // calls TBaseView.RenderContent - optional
  WriteLn('<!-- adds its own content -->');
end;

TemplatePro - bloques:

{{# _base.html #}}
<title>{{block "title"}}My App{{endblock}}</title>

{{block "content"}}
  <!-- default content -->
{{endblock}}
{{# child.html #}}
{{extends "_base.html"}}

{{# "title" is not overridden: parent content applies #}}

{{block "content"}}
  {{inherited}}  {{# calls parent block - optional #}}
  <!-- adds its own content -->
{{endblock}}

La estructura es idéntica. Solo cambia el dominio: clases y métodos por un lado, templates y bloques por el otro.

Caso 1: herencia simple - layout compartido

El uso más común es separar la estructura común de la página (navbar, footer, CSS, JS) de la parte que cambia vista a vista.

_layout.html - el template padre:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{block "title"}}My App{{endblock}}</title>
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  {{block "extra_css"}}{{endblock}}
</head>
<body>
  <nav class="navbar navbar-dark bg-dark">
    <div class="container">
      <a class="navbar-brand" href="/">My App</a>
    </div>
  </nav>

  <div class="container mt-4">
    {{block "content"}}{{endblock}}
  </div>

  <script src="/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
  {{block "extra_scripts"}}{{endblock}}
</body>
</html>

customers.html - el template hijo:

{{extends "_layout.html"}}

{{block "title"}}Customers{{endblock}}

{{block "content"}}
<h2>Customer list</h2>
<table class="table table-striped">
  ...
</table>
{{endblock}}

Cuando TemplatePro renderiza customers.html, toma la estructura de _layout.html y sustituye los bloques con lo definido en el hijo. Los bloques no redefinidos (extra_css, extra_scripts) usan el contenido del padre - en este caso vacío.

Una sola regla a tener en cuenta: el contenido fuera de los bloques en el template hijo se ignora. Si escribes HTML libre en customers.html fuera de un {{block}}, no se renderizará.

{{inherited}}: extender sin sobreescribir

A veces no quieres sustituir el bloque del padre - quieres añadirle algo. Para eso existe {{inherited}}.

Supongamos que el layout base ya incluye las meta etiquetas estándar en el <head>, y que para la página de informes queremos añadir Chart.js sin perder las etiquetas comunes:

_layout.html:

<head>
  {{block "head"}}
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  {{endblock}}
</head>

reports.html:

{{extends "_layout.html"}}

{{block "head"}}
{{inherited}}
<link href="/assets/css/charts.css" rel="stylesheet">
<script src="/assets/js/chartjs.min.js"></script>
{{endblock}}

{{inherited}} inserta el contenido del bloque padre, luego se añaden las nuevas etiquetas. El resultado renderizado es:

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  <link href="/assets/css/charts.css" rel="stylesheet">
  <script src="/assets/js/chartjs.min.js"></script>
</head>

Sin duplicación. La página de informes hereda todo el padre y añade solo lo que necesita.

Caso 2: herencia múltiple - A ← B ← C

Y aquí llegamos a la parte que el cliente desconocía por completo.

TemplatePro soporta la herencia a profundidad ilimitada. Un template B puede extender A, y un template C puede extender B. La cadena de resolución recorre todos los niveles de arriba hacia abajo.

Esto es enormemente útil en las aplicaciones reales, donde típicamente tienes:

  • un layout base igual para toda la aplicación
  • un layout de sección que añade elementos específicos (por ejemplo, la barra lateral del área de administración)
  • las páginas concretas que extienden el layout de sección

_base.html - la raíz absoluta (nivel A):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{block "title"}}App{{endblock}}</title>
  {{block "head_extra"}}{{endblock}}
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  {{block "body"}}{{endblock}}
  <script src="/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
  {{block "scripts_extra"}}{{endblock}}
</body>
</html>

_admin.html - el layout del área administrativa, extiende _base.html (nivel B):

{{extends "_base.html"}}

{{block "head_extra"}}
<link href="/assets/css/admin.css" rel="stylesheet">
{{endblock}}

{{block "body"}}
<div class="d-flex">
  <nav class="sidebar bg-dark text-white" style="min-height:100vh; width:220px">
    <ul class="nav flex-column p-3">
      <li><a class="nav-link text-white" href="/admin/customers">Customers</a></li>
      <li><a class="nav-link text-white" href="/admin/orders">Orders</a></li>
      <li><a class="nav-link text-white" href="/admin/reports">Reports</a></li>
    </ul>
  </nav>
  <div class="flex-grow-1">
    <nav class="navbar navbar-light bg-light border-bottom px-3">
      <span class="navbar-text">Welcome back, {{:username}}</span>
    </nav>
    <div class="container-fluid p-4">
      {{block "content"}}{{endblock}}
    </div>
  </div>
</div>
{{endblock}}

_admin.html no sabe nada de las páginas concretas. Solo sabe que existe una barra lateral, una navbar, y un slot content para rellenar.

customers.html - la página de clientes, extiende _admin.html (nivel C):

{{extends "_admin.html"}}

{{block "title"}}Customers - Admin{{endblock}}

{{block "content"}}
<h2>Customer list</h2>
<p class="text-muted">{{:total_customers}} registered customers</p>

<table class="table table-striped table-hover">
  <thead>
    <tr>
      <th>Code</th><th>Company name</th><th>City</th><th></th>
    </tr>
  </thead>
  <tbody>
    {{for customer in customers}}
    <tr>
      <td><code>{{:customer.code}}</code></td>
      <td>{{:customer.company_name}}</td>
      <td>{{:customer.city}}</td>
      <td>
        <button onclick="document.getElementById('deleteDialog-{{:customer.id}}').showModal()">
          Delete
        </button>
      </td>
    </tr>
    {{endfor}}
  </tbody>
</table>
{{endblock}}

Tres archivos. Tres responsabilidades distintas. customers.html no sabe cómo funciona la barra lateral. _admin.html no sabe qué datos mostrará la página concreta. _base.html ni siquiera sabe que existe un área de administración.

Así es exactamente como debería funcionar.

Volvemos a los veinte dialogs

Una vez que el concepto de herencia está claro, el problema de los veinte dialogs se resuelve con include + variable mapping - la feature complementaria a la herencia para componentizar los partials reutilizables.

El elemento HTML nativo para los dialogs es <dialog>, soportado por todos los navegadores modernos. No se necesita ninguna librería: showModal() para abrirlo, close() para cerrarlo via JavaScript.

En el caso concreto del cliente, los veinte dialogs compartían la estructura exterior - la etiqueta <dialog>, un título, el formulario, los dos botones - pero dentro cambiaba todo: el action del endpoint, el título del dialog, y el número y la naturaleza de los campos del formulario.

include con variable mapping no basta cuando el contenido a variar es HTML estructurado: no puedes pasar bloques de markup como parámetro. Hace falta de nuevo la template inheritance.

La solución es crear un partial base para la shell del dialog, y un partial hijo para cada tipo de dialog que herede su estructura y sobreescriba los bloques variables.

partials/_dialog_base.html - la shell compartida (nivel A):

<dialog id="{{:dialog_id}}">
  <h3>{{block "dialog_title"}}{{endblock}}</h3>
  <form method="post" action="{{block "form_action"}}{{endblock}}">
    <div class="dialog-body">
      {{block "form_fields"}}{{endblock}}
    </div>
    <div class="dialog-footer">
      <button type="button" onclick="this.closest('dialog').close()">Cancel</button>
      <button type="submit" class="btn-{{block "submit_style"}}primary{{endblock}}">
        {{block "submit_label"}}Confirm{{endblock}}
      </button>
    </div>
  </form>
</dialog>

Cada {{block}} es un método virtual: el hijo lo sobreescribe, o usa el valor por defecto del padre.

partials/delete_customer_dialog.html - el dialog de eliminación (nivel B):

{{extends "partials/_dialog_base.html"}}

{{block "dialog_title"}}Delete customer{{endblock}}
{{block "form_action"}}/admin/customers/{{:customer.id}}/delete{{endblock}}
{{block "submit_style"}}danger{{endblock}}
{{block "submit_label"}}Delete permanently{{endblock}}

{{block "form_fields"}}
<p>You are about to delete <strong>{{:customer.company_name}}</strong>.</p>
<p>This action cannot be undone.</p>
<input type="hidden" name="customer_id" value="{{:customer.id}}">
{{endblock}}

partials/edit_customer_dialog.html - el dialog de edición (nivel B):

{{extends "partials/_dialog_base.html"}}

{{block "dialog_title"}}Edit customer{{endblock}}
{{block "form_action"}}/admin/customers/{{:customer.id}}/edit{{endblock}}
{{block "submit_label"}}Save changes{{endblock}}

{{block "form_fields"}}
<div class="form-group">
  <label>Company name</label>
  <input type="text" name="company_name"
         value="{{:customer.company_name}}" class="form-control">
</div>
<div class="form-group">
  <label>City</label>
  <input type="text" name="city"
         value="{{:customer.city}}" class="form-control">
</div>
{{endblock}}

En el {{block "content"}} de customers.html, cada fila incluye sus partials específicos:

{{for customer in customers}}
<tr>
  <td><code>{{:customer.code}}</code></td>
  <td>{{:customer.company_name}}</td>
  <td>
    <button onclick="document.getElementById('editDialog-{{:customer.id}}').showModal()">
      Edit
    </button>
    <button onclick="document.getElementById('deleteDialog-{{:customer.id}}').showModal()">
      Delete
    </button>
  </td>
</tr>

{{set dialog_id := @("editDialog-" + customer.id)}}
{{include "partials/edit_customer_dialog.html"}}

{{set dialog_id := @("deleteDialog-" + customer.id)}}
{{include "partials/delete_customer_dialog.html"}}

{{endfor}}

Los veinte dialogs copy-paste se convierten en: una shell base, un partial hijo por tipo, y una línea de include por cada ocurrencia. Si la estructura de la shell cambia - un icono en el header, un atributo ARIA, un campo hidden adicional - solo se toca _dialog_base.html.

El scope de los datos se propaga normalmente en el include: los partials hijos ven customer.id, customer.company_name y todo lo disponible en el punto de inclusión. El {{set dialog_id}} antes del include pasa el valor que necesita la shell base.

El modelo mental completo

Al cliente le dejé este esquema:

_base.html                              <- absolute base (DOCTYPE, global CSS, JS)
  └── _admin.html                       <- section layout (sidebar, admin navbar)
        └── customers.html              <- concrete page (content only)
              └── partials/
                  ├── _dialog_base.html            <- dialog shell (virtual blocks)
                  ├── delete_customer_dialog.html  <- override: title, action, fields
                  └── edit_customer_dialog.html    <- override: title, action, fields

Cinco niveles. Cada uno sabe solo lo que le compete. Cuando algo cambia, se toca un solo archivo.

La template inheritance no es una feature avanzada para usar solo en proyectos grandes. Es la forma correcta de estructurar cualquier aplicación web no trivial con TemplatePro. Los veinte dialogs copiados eran el síntoma visible de un problema más profundo: la ausencia de esta estructura.


TemplatePro es open source (Apache 2.0). La documentación oficial, incluida la sección completa sobre template inheritance, está disponible aquí. El repositorio está en GitHub.


¿Quieres profundizar?

He escrito una guía completa sobre TemplatePro: TemplatePro 1.1 - The Definitive Guide - 209 páginas, 12 proyectos completos. Cubre todo lo que hemos visto aquí, y mucho más: macros, filtros personalizados, internacionalización, integración con WebBroker, caché de templates compilados, despliegue en producción.

El libro está disponible en cuatro idiomas en Patreon:

🇪🇸 Español  •  🇮🇹 Italiano  •  🇬🇧 English  •  🇩🇪 Deutsch


Buen coding!
Daniele Teti

Comments

comments powered by Disqus