Saltar a contenido

Helpers y Filtros de Twig

El motor Twig expone el puente phtml de MageObsidian como funciones Twig, y los escapers conscientes del contexto de Magento como filtros. Reflejan los métodos que ya usas en .phtml.

El bloque que renderiza se lee automáticamente del contexto de Twig, así que los renders anidados y recursivos cada uno apunta a su propio bloque —nunca le pasas block a estos helpers.


Funciones

Función Mapea a Devuelve
render_vue(name, props = {}, eager = false, server_html = '', hydrate = false) renderVueComponent() Un marcador de isla Vue (HTML seguro).
__(text, ...args) El __() de Magento Texto traducido con sustitución de argumentos %1/%2 (auto-escapado).
child_html(alias = '', use_cache = true) getChildHtml() El HTML de un bloque hijo (seguro).
hero_icon(name, set = 'solid', size = '24', class = '') getHeroIcon() Un <svg> de Heroicons (seguro).
vite_url(path) getViteFileUrl() URL de un archivo generado por Vite.
component_path(name) resolveComponentPath() URL resuelta de un componente por su nombre Vendor::Component.
view_file_url(file_id, params = {}) getViewFileUrl() URL de un archivo de vista.
json_ld(type, data = {}) renderJsonLd() Un <script> JSON-LD de schema.org para un tipo personalizado (HTML seguro).
image(src, options = {}) renderImage() Un <img>/<picture> amigable con CWV (HTML seguro).

Twig admite argumentos nombrados, que vale la pena usar en cuanto una llamada llega a la cola de la firma de render_vue:

{{ render_vue('Acme_Catalog::BuyBox', props, true, server_html, hydrate = true) }}

Los helpers que emiten markup (render_vue, child_html, hero_icon, json_ld, image) están marcados como seguros, así que el auto-escaping de Twig deja su HTML intacto. Los helpers de URL devuelven cadenas planas y se auto-escapan como cualquier valor.

Nota: render_vue, hero_icon, vite_url, component_path, json_ld e image requieren que el bloque que renderiza extienda MageObsidian\ModernFrontend\Block\Template. Si un .twig lo renderiza un bloque no relacionado, el helper lanza un error accionable que nombra el método ausente. child_html y view_file_url funcionan en cualquier bloque de Magento; __ es independiente del bloque.

Helpers de formato

Una segunda extensión (FormatExtension) añade formato a nivel de framework, respaldado por servicios de Magento (no por el bloque, así que funcionan en cualquier .twig). Toda la salida es texto plano y se auto-escapa —ninguno está marcado como seguro. Cada uno tolera entrada null/vacía.

Helper Respaldado por Uso
value\|number LocaleFormatter Agrupa un número según el locale de la tienda (1234567 → 1,234,567).
date\|date_format(format = 'medium', part = 'date') TimezoneInterface Fecha por locale/zona horaria. format: short\|medium\|long\|full; part: date\|time\|datetime.
html\|strip_tags(allowed = null) Filter\StripTags Quita el markup dejando texto plano.
config(path, scope = 'store') ScopeConfigInterface Un valor de configuración de tienda.
url(route, params = {}) UrlInterface Una URL de framework para una ruta.
media_url(path) base media de la tienda Una URL bajo la base media de la tienda.

El formato de comercio vive en el PriceExtension del módulo storefront (registrado en el motor vía DI — ver Extender el motor): amount\|price(include_container = true) y amount\|currency(code = null), ambos is_safe html porque PriceCurrency::format() devuelve un <span class="price">.

Ejemplos

{# Monta una isla Vue (hidratación perezosa por defecto) #}
{{ render_vue('Acme_Catalog::ProductCard', { id: block.getProductId(), featured: true }) }}

{# Monta eager una isla por encima del pliegue (header, trigger del mini-cart) #}
{{ render_vue('Acme_Theme::MiniCart', { count: block.getItemCount() }, true) }}

{# Traduce texto — %1/%2 se sustituyen, la salida se escapa #}
<span>{{ __('Items %1 to %2 of %3', first, last, total) }}</span>

{# Renderiza un bloque hijo declarado en layout #}
{{ child_html('product.reviews') }}

{# Inserta un icono en línea #}
{{ hero_icon('shopping-cart', 'outline', '20') }}

{# Resuelve URLs #}
<link rel="stylesheet" href="{{ view_file_url('Acme_Catalog::css/extra.css') }}">
<script type="module" src="{{ vite_url('Acme_Catalog::js/widget') }}"></script>

{# Emite un tipo schema.org personalizado (@context/@type se añaden por ti) #}
{{ json_ld('FAQPage', { mainEntity: [{ '@type': 'Question', name: '¿P?', acceptedAnswer: { '@type': 'Answer', text: 'R.' } }] }) }}

{# Formato #}
<span>{{ product.getFinalPrice()|price }}</span>
<span>{{ block.getTotalCount()|number }}</span>
<time>{{ order.getCreatedAt()|date_format('long') }}</time>
<a href="{{ url('customer/account') }}">{{ config('general/store_information/name') }}</a>

render_vue monta el componente con la estrategia visible (perezosa) por defecto. Para una isla por encima del pliegue (header, trigger del mini-cart), pasa true como tercer argumento para montarla eager —reflejando $block->renderVueComponent($name, $props, true) en .phtml.


Filtros

El escape de HTML ya es el default de Twig, así que no hay un filtro escape_html —basta con imprimir un valor. Los demás escapers conscientes del contexto reflejan el $escaper->escape* de Magento para los casos donde el escape de HTML es el contexto equivocado:

Filtro Mapea a Úsalo para
escape_url escapeUrl() Un valor en un href/src.
escape_html_attr escapeHtmlAttr() Un valor en un atributo HTML.
escape_js escapeJs() Un valor inyectado en un contexto JS.
escape_css escapeCss() Un valor inyectado en un contexto CSS.

Ejemplos

<a href="{{ block.getLink() | escape_url }}">Detalles</a>

<div data-label="{{ label | escape_html_attr }}"></div>

<script>
    const term = "{{ query | escape_js }}";
</script>

<style>
    .promo { color: {{ brandColor | escape_css }}; }
</style>

Nombrar una plantilla: namespaces

Una referencia a plantilla se puede escribir como Vendor_Module::path.twig, o con un namespace: @catalog/path.twig. Ambas se resuelven por el fallback de temas de Magento, así que un tema hijo sobrescribe cualquiera de ellas igual que un .phtml.

Los namespaces se derivan de la lista de módulos habilitados en vez de declararse, así que no hay nada que registrar:

Módulo Siempre Además, mientras ningún otro módulo lo reclame
Magento_Catalog @magento-catalog @catalog
MageObsidian_Storefront @mage-obsidian-storefront @storefront

La forma con vendor nunca colisiona y nunca cambia, así que es la que una plantilla puede usar sin condiciones. La corta es la comodidad.

Cuando dos vendors publican el mismo nombre de módulo, el desempate lo gana el que realmente contiene plantillas — MageObsidian_Catalog extiende a Magento_Catalog con código y JS pero no envía ningún view/*/templates, así que @catalog es el del módulo core. Un empate que ninguno o ambos pueden romper se deja sin registrar en vez de decidirlo en silencio; decláralo en di.xml para zanjarlo:

1
2
3
4
5
6
7
<type name="MageObsidian\ModernFrontendTwig\Model\Template\TemplateNamespaces">
    <arguments>
        <argument name="namespaces" xsi:type="array">
            <item name="catalog" xsi:type="string">MageObsidian_Catalog</item>
        </argument>
    </arguments>
</type>

bin/magento mage-obsidian:twig:namespaces imprime la tabla resuelta y marca los nombres cortos perdidos por una colisión. Un namespace desconocido falla al cargar, sugiriendo los más parecidos.


Composición: includes, embeds y macros

Todas las referencias usan la notación Vendor_Module::path.twig, resuelta por el fallback de temas de Magento —así que un tema hijo sobrescribe cualquier partial/macro igual que un .phtml. Prefiere componer plantillas antes que copiar markup.

{% extends %} + {% block %} — una base con regiones sobrescribibles:

1
2
3
4
{% extends 'Acme_Catalog::layout/base.twig' %}
{% block content %}
    {{ render_vue('Acme_Catalog::ProductCard', { id: block.getProductId() }) }}
{% endblock %}

@parent — extender la plantilla que estás sobrescribiendo. Un override de tema no puede hacer {% extends %} de su propio nombre: el fallback lo resuelve de vuelta al override y Twig entra en recursión. @parent es la copia un nivel más arriba de la cadena — la del tema padre, o la del módulo si no hay ninguno:

1
2
3
4
5
6
7
{# app/design/frontend/Acme/child/Magento_Theme/templates/html/header.twig #}
{% extends '@parent' %}

{% block nav %}
    {{ parent() }}
    <a href="/outlet">Outlet</a>
{% endblock %}

Cada copia resuelve su propio nivel, así que una cadena de tres temas funciona: el hijo extiende el del padre, el padre el de la base, y la base el del módulo. En una plantilla de módulo es un error — no hay nada por encima.

{% include %} con with/only — un partial parametrizado (only aísla el scope del partial):

{% include '@catalog/partials/badge.twig' with { label: 'New', tone: 'accent' } only %}

only también descarta block

render_vue, hero_icon, image y json_ld leen el bloque que renderiza desde el contexto. Una parcial incluida con only no lo hereda, así que pásalo — with { block: block, … } only — o el helper lanza un error diciendo exactamente esto.

{% embed %} — el equivalente de slots en Twig. Incluye una carcasa y rellena sus bloques en el sitio de llamada. Ideal para cards/secciones cuyo armazón es compartido pero el contenido varía:

{# Acme_Catalog::components/card.twig #}
<article class="card">
    {% block media %}{% endblock %}
    <div class="card__body">{% block body %}{% endblock %}</div>
</article>

{# sitio de llamada — rellena los slots #}
{% embed 'Acme_Catalog::components/card.twig' %}
    {% block media %}{{ image(product.getImage()) }}{% endblock %}
    {% block body %}<h3>{{ product.getName() }}</h3>{% endblock %}
{% endembed %}

{% macro %} — fragmentos reutilizables sin pasar por layout. Importa una vez, llama muchas:

{% from 'Magento_Theme::macros/ui.twig' import nav_list %}
<nav>{{ nav_list(block.getNavigation().getItems(), 'nav-link') }}</nav>

ViewModels en Twig

Mantén la lógica de datos fuera de la plantilla: ponla en un ViewModel (ArgumentInterface), regístralo como <argument> del bloque en el layout, y léelo desde Twig con el getter mágico del bloque.

1
2
3
4
5
<referenceBlock name="header-content" template="Acme_Theme::html/header.twig">
    <arguments>
        <argument name="navigation" xsi:type="object">Acme\Theme\ViewModel\Navigation</argument>
    </arguments>
</referenceBlock>
1
2
3
4
{# el argumento `navigation` → block.getNavigation() #}
{% for item in block.getNavigation().getItems() %}
    <a href="{{ item.url|escape_url }}">{{ item.label }}</a>
{% endfor %}

El mismo ViewModel puede registrarse en varios bloques (header, footer, …) para compartir una sola fuente de verdad —sin datos duplicados en las plantillas.


Extender el motor Twig

El entorno Twig compartido lo construye EnvironmentFactory, cuyo argumento array extensions se mergea entre módulos por clave de item —como el engines de TemplateEngineFactory. Cualquier módulo añade sus filtros/funciones registrando un Twig\Extension\ExtensionInterface en ese array, sin tocar el motor:

1
2
3
4
5
6
7
8
<!-- Acme_Catalog/etc/di.xml — scope GLOBAL, no etc/frontend (ver nota) -->
<type name="MageObsidian\ModernFrontendTwig\Model\Template\EnvironmentFactory">
    <arguments>
        <argument name="extensions" xsi:type="array">
            <item name="acme_catalog" xsi:type="object">Acme\Catalog\Twig\CatalogExtension</item>
        </argument>
    </arguments>
</type>

Así es exactamente como el PriceExtension del storefront añade price/currency.

Decláralo en scope global, no en etc/frontend/di.xml. Los argumentos array se mergean acumulativamente dentro de un scope, pero un scope de área reemplaza un array global en vez de mergear —lo que descartaría las extensiones del propio motor en el frontend. La extensión solo se instancia al construir el entorno (frontend), así que una declaración global es inerte en otros lados. Los nombres de filtro/función son un namespace compartido; prefija los de terceros para evitar colisiones (gana el último).


Próximos Pasos

  • Motor Twig — instalación y cómo Twig coexiste con phtml.
  • Islas Vue — qué emite render_vue y cómo se hidrata.