Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions docs/twig-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Twig-Templates in MetaModels

Ab MetaModels 2.5 kann jedes MetaModels-Template zusätzlich als **Twig-Template** angeboten
werden. Existiert für ein Template eine Twig-Variante, hat sie **Vorrang** vor dem klassischen
`.html5`-PHP-Template – analog zu Contao Core. Fehlt die Twig-Variante, wird unverändert das
`.html5` gerendert (voller Rückwärtskompatibilitäts-Fallback).

## Funktionsweise

`MetaModels\Render\Template::parse()` fragt vor dem Einbinden des Legacy-`.html5` den
`MetaModels\Render\TwigTemplateSurrogate` ab. Dieser prüft im gemanagten Contao-Twig-Loader
(`contao.twig.filesystem_loader`), ob ein passendes Twig-Template existiert, und rendert es via
`twig`. Der Twig-Context wird über Contaos `ContextFactory::fromData()` aus den Template-Daten
gebaut – dieselben Variablen wie im `.html5` stehen zur Verfügung.

Das entspricht 1:1 Contaos eigenem Surrogat-Mechanismus
(`\Contao\Template::renderTwigSurrogateIfExists()`).

### Namensschema

Aus dem Legacy-Namen wird der Twig-Identifier gebildet:

```
@Contao/metamodels/<gruppe>/<leaf>.html.twig
```

* **Gruppe** kommt aus dem Render-Kontext: `attribute`, `filter` oder `item`.
* **Leaf** ist der Template-Name ohne das konventionelle Legacy-Präfix.

| Legacy `.html5` | Gruppe | Twig-Identifier |
|------------------------|-------------|-------------------------------------|
| `mm_attr_text` | `attribute` | `metamodels/attribute/text` |
| `mm_filter_default` | `filter` | `metamodels/filter/default` |
| `mm_filteritem_...` | `filter` | `metamodels/filter/...` |
| `mm_default` | `item` | `metamodels/item/default` |

Für eigene Templates gilt dieselbe Regel: `mm_attr_text_fancy` wird zu
`metamodels/attribute/text_fancy` — das Präfix entfällt, der Rest wird zum Leaf.

### Textformat

Neben der sichtbaren Ausgabe (`html5`) kann auch das Format `text` (Suchindex, Sortierung,
Gruppen-Header) auf Twig laufen. Die Templates dafür tragen ein zusätzliches `.text` im
Namen:

| Legacy | Twig-Datei | Twig-Identifier |
|-------------------|---------------------------|----------------------------------|
| `mm_attr_text.html5` | `attribute/text.html.twig` | `metamodels/attribute/text` |
| `mm_attr_text.text` | `attribute/text.text.html.twig` | `metamodels/attribute/text.text` |

Die doppelte Endung ist **kein Schönheitsfehler, sondern zwingend**. Contao bildet den
Identifier, indem es das abschließende `.html.twig` bzw. `.twig` abschneidet, und verbietet
gemischte Typen unter einem Identifier. Ein `text.text.twig` neben `text.html.twig` hätte
also denselben Identifier `…/text`, aber einen anderen Typ — der `ContaoFilesystemLoader`
bricht dann den Aufbau der **gesamten** Hierarchie mit einer `OutOfBoundsException` ab:

```
The "metamodels/item/prerendered" template has incompatible types,
got "html.twig/html5" in "…/prerendered.html.twig" and "text.twig" in "…/prerendered.text.twig".
```

Das legt Backend **und** Frontend komplett lahm, nicht nur das betroffene Template. Mit
`.text.html.twig` bleibt die echte Endung `html.twig`, und die Textvariante bekommt den
eigenen Identifier `…/text.text`. Dieselbe Benennung nutzt bereits
`email_metamodels_notelist.text.html.twig` im Paket `notelist`.

Ein projekteigenes `templates/mm_attr_text.text` behält weiterhin Vorrang vor dem
mitgelieferten Twig-Template — genauso wie im Format `html5`.

### Frontend und Backend

Der Vorrang gilt in **beiden** Scopes. Die Backend-Listen rendern Attribute ebenfalls mit
`html5` (siehe `ItemRendererListener`), daher wirken Attribut-Twig-Templates auch dort.
**Standardtemplates müssen deshalb backend-tauglich bleiben** (schlanke `div`/`span`-Wrapper,
wie die bisherigen `.html5`). Für abweichende Backend-Darstellung empfiehlt sich ein eigenes
Render-Setting mit eigener Template-Auswahl.

## Twig-Templates in einem Paket bereitstellen

Der Contao-Twig-Loader behandelt den Legacy-Ordner `Resources/contao/templates` **flach**
(Unterordner werden verworfen, der Identifier wäre nur der Dateiname). Damit die
`metamodels/<gruppe>/<leaf>`-Struktur erhalten bleibt, müssen die Templates – genau wie in
Contaos eigenen Bundles – unter einem **Namespace-Root** liegen: ein Ordner `twig/` mit einer
leeren Marker-Datei **`.twig-root`**.

```
src/CoreBundle/Resources/contao/templates/
└── twig/
├── .twig-root (leere Marker-Datei, einmal pro Paket)
└── metamodels/
├── attribute/
│ └── text.html.twig
├── filter/
│ └── default.html.twig
└── item/
└── default.html.twig
```

(In Paketen mit moderner Struktur entsprechend unter `contao/templates/twig/…`.)

Es ist **kein PHP-Code** nötig – die Dateien werden vom Loader automatisch unter `@Contao`
erfasst.

## Template Studio, Themes und Overrides

Weil die Templates im gemanagten `@Contao`-Namespace liegen (Untergruppe `metamodels/`), sind
sie ohne Zusatzaufwand:

* im **Template Studio** sichtbar und editierbar,
* über **Theme-Ordner** und das globale Projekt-`templates/`-Verzeichnis überschreibbar.

Ein höher priorisiertes `.html5` in der gemanagten Hierarchie (z. B. ein Projekt-Override am
neuen Pfad `templates/metamodels/<gruppe>/<leaf>.html5`) behält gegenüber einem Paket-Twig-Template
den Vorrang – ebenfalls wie in Contao.

### Legacy-Flach-Override (Übergangslösung, remove in 3.0)

Zusätzlich behält ein Override am **flachen** Legacy-Namen im Projekt-`templates/`-Ordner (oder in
einem Theme-Ordner) – z. B. `templates/metamodel_prerendered.html5` – weiterhin Vorrang vor einem
Paket-Twig-Template. `Template::hasLegacyTemplateOverride()` erkennt solche Overrides (Pfad unterhalb
`%kernel.project_dir%/templates`, per DI injiziert) und überspringt dann den Twig-Surrogaten. So
funktionieren bestehende Anpassungen nach dem Upgrade unverändert weiter.

**Diese Rücksichtnahme ist bewusst als `@deprecated` markiert und entfällt in 3.0** gemeinsam mit den
`.html5`-Templates – Overrides sollten nach `templates/metamodels/<gruppe>/…` umgezogen werden.

## Rollout-Stand

* **MetaModels Core:** Mechanismus implementiert (`Template`, `TemplateFactory`,
`TwigTemplateSurrogate`, DI) inkl. Legacy-Flach-Override-Vorrang (Übergangslösung). Als erste
Core-Twig-Templates ausgeliefert: `item/prerendered`, `item/unrendered`, `item/prerendered_debug`
sowie `filter/default`, `filter/checkbox`, `filter/radiobuttons`, `filter/linklist`,
`filter/datepicker`. `FrontendFilter` rendert die Filter-Widgets über die MetaModels-Engine.
* **Attribute-Templates:** werden schrittweise paketweise als Twig-Varianten nachgezogen.
5 changes: 3 additions & 2 deletions src/Attribute/Base.php
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ public function parseValue($arrRowData, $strOutputFormat = 'text', $objSettings
if ($objSettings && ($strTemplate = (string) $objSettings->get('template'))) {
$templateFactory = System::getContainer()->get('metamodels.template_factory');
assert($templateFactory instanceof TemplateFactory);
$objTemplate = $templateFactory->createTemplate($strTemplate);
$objTemplate = $templateFactory->createTemplate($strTemplate, 'attribute');

$this->prepareTemplate($objTemplate, $arrRowData, $objSettings);

Expand All @@ -657,7 +657,8 @@ public function parseValue($arrRowData, $strOutputFormat = 'text', $objSettings
// FIXME: this throws when no parent has been set - need to catch!
$objSettingsFallback = $this->getDefaultRenderSettings()->setParent($objSettings->getParent());

$objTemplate = $templateFactory->createTemplate((string) $objSettingsFallback->get('template'));
$objTemplate =
$templateFactory->createTemplate((string) $objSettingsFallback->get('template'), 'attribute');
$this->prepareTemplate($objTemplate, $arrRowData, $objSettingsFallback);

$arrResult['text'] = $objTemplate->parse('text', true);
Expand Down
4 changes: 3 additions & 1 deletion src/CoreBundle/Controller/ListControllerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Markup;

/**
* Helper trait for lists (CE and MOD).
Expand Down Expand Up @@ -410,8 +411,9 @@ private function renderBackendWildcard(string $href, string $name, Model $model)

/** @psalm-suppress UndefinedMagicPropertyFetch */
$headline = StringUtil::deserialize($model->headline);
// Mark the info text as safe HTML so the (auto-escaping) Twig be_wildcard template does not escape it.
/** @psalm-suppress UndefinedMagicPropertyAssignment */
$template->wildcard = $this->getWildcardInfoText($model, $href, $name);
$template->wildcard = new Markup($this->getWildcardInfoText($model, $href, $name), 'UTF-8');
/** @psalm-suppress UndefinedMagicPropertyAssignment */
$template->title = (\is_array($headline) ? $headline['value'] : $headline);
/** @psalm-suppress UndefinedMagicPropertyAssignment */
Expand Down
10 changes: 10 additions & 0 deletions src/CoreBundle/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,20 @@ services:
- "%kernel.project_dir%"
- "@translator"

metamodels.render.twig_surrogate:
class: MetaModels\Render\TwigTemplateSurrogate
arguments:
- "@twig"
- "@contao.twig.filesystem_loader"
- "@contao.twig.interop.context_factory"

metamodels.template_factory:
class: MetaModels\Render\TemplateFactory
arguments:
- "@=service('contao.framework').getAdapter('Contao\\\\TemplateLoader')"
- "@cca.dc-general.scope-matcher"
- "@metamodels.render.twig_surrogate"
- "%kernel.project_dir%"
public: true

metamodels.contao_input:
Expand Down Expand Up @@ -136,6 +145,7 @@ services:
- '@database_connection'
- '@MetaModels\Filter\FilterUrlBuilder'
- '@translator'
- '@metamodels.template_factory'
public: true

metamodels.controller.abstract.add_all:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% extends searchable|default(false) ? '@Contao/block_searchable.html.twig' : '@Contao/block_unsearchable.html.twig' %}
{% block content %}
{{ items|raw }}
{{ pagination|raw }}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% if action.html|default('') %}{{ action.html|raw }}{% else %}<a href="{{ action.href|default('') }}"{% if action.class|default('') %} class="{{ action.class }}"{% endif %}{% if action.title|default('') %} title="{{ action.title }}"{% endif %} {{ action.attribute|default('')|raw }}>{{ action.label|raw }}</a>{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- indexer::stop -->
<div class="block clearall {{ class|default('') }}"{{ cssID|default('')|raw }}{% if style|default('') %} style="{{ style }}"{% endif %}>
<a title="{{ 'clear_all'|trans([], 'metamodels_filter') }}"
href="{{ href|default('') }}{{ metamodel_fef_urlfragment|default('') ? '#' ~ metamodel_fef_urlfragment : '' }}">{{ 'clear_all'|trans([], 'metamodels_filter') }}</a>
</div>
<!-- indexer::continue -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!-- indexer::stop -->
<section class="{{ class|default('') }} block"{{ cssID|default('')|raw }}{% if style|default('') %} style="{{ style }}"{% endif %}>
{% if headline|default('') %}
<{{ hl }}>{{ headline|raw }}</{{ hl }}>
{% endif %}
<form{% if action|default('') %} action="{{ action }}"{% endif %} method="post">
<input name="REQUEST_TOKEN" type="hidden" value="{{ requestToken|default('') }}"/>
<input type="hidden" name="FORM_SUBMIT" value="{{ formid|default('') }}">
<div class="formbody">
{% for filter in filters|default([]) %}
<div class="widget {{ filter.class|default('') }}"{{ filter.cssID|default('')|raw }}>
{{ filter.value|raw }}
</div>
{% endfor %}
{% if submit|default('') %}
<div class="submit_container">
<input type="submit" class="submit" value="{{ submit }}">
</div>
{% endif %}
</div>
</form>
</section>
<!-- indexer::continue -->
27 changes: 27 additions & 0 deletions src/CoreBundle/Resources/contao/templates/mm_pagination.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% set fragment = paginationFragment|default('') ? '#' ~ paginationFragment : '' %}
<!-- indexer::stop -->
<nav class="pagination block" aria-label="{{ 'MSC.pagination'|trans([], 'contao_default') }}">
<p>{{ ('MSC.totalPages'|trans([], 'contao_default'))|format(page, totalPages) }}</p>
<ul>
{% if hasFirst|default(false) %}
<li class="first"><a href="{{ first }}{{ fragment }}" class="first" title="{{ 'MSC.goToPage'|trans({'%s': 1}, 'contao_default') }}">{{ 'MSC.first'|trans([], 'contao_default') }}</a></li>
{% endif %}
{% if hasPrevious|default(false) %}
<li class="previous"><a href="{{ previous }}{{ fragment }}" class="previous" title="{{ 'MSC.goToPage'|trans({'%s': page - 1}, 'contao_default') }}">{{ 'MSC.previous'|trans([], 'contao_default') }}</a></li>
{% endif %}
{% for entry in pages|default([]) %}
{% if entry.href is null %}
<li><strong class="active">{{ entry.page }}</strong></li>
{% else %}
<li><a href="{{ entry.href }}{{ fragment }}" class="link" title="{{ 'MSC.goToPage'|trans({'%s': entry.page}, 'contao_default') }}">{{ entry.page }}</a></li>
{% endif %}
{% endfor %}
{% if hasNext|default(false) %}
<li class="next"><a href="{{ next }}{{ fragment }}" class="next" title="{{ 'MSC.goToPage'|trans({'%s': page + 1}, 'contao_default') }}">{{ 'MSC.next'|trans([], 'contao_default') }}</a></li>
{% endif %}
{% if hasLast|default(false) %}
<li class="last"><a href="{{ last }}{{ fragment }}" class="last" title="{{ 'MSC.goToPage'|trans({'%s': totalPages}, 'contao_default') }}">{{ 'MSC.last'|trans([], 'contao_default') }}</a></li>
{% endif %}
</ul>
</nav>
<!-- indexer::continue -->
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends "@Contao/metamodels/filter/default.html.twig" %}
{% block formlabel %}
{% if not hide_label|default(false) %}
{{ legend|default('')|raw }}
{% endif %}
{% endblock %}
{% block formfield %}
{% if options|default([]) is iterable and options|length %}
<fieldset id="ctrl_{{ urlparam }}" class="checkbox_container">
{% for option in options %}
{% set cls = (loop.first ? 'first ' : '') ~ (loop.last ? 'last ' : '') ~ (loop.index0 % 2 == 1 ? 'even' : 'odd') ~ (option.class|default('') ? ' ' ~ option.class : '') %}
<span class="{{ cls }}"><input type="checkbox" name="{{ urlparam }}[]" id="opt_{{ urlparam }}_{{ loop.index0 }}" class="checkbox{% if submit|default(false) %} submitonchange{% endif %}" value="{{ option.key|default('') ? option.key : '--none--' }}"{% if option.active|default(false) %} checked="checked"{% endif %} /> <label id="lbl_{{ urlparam }}_{{ loop.index0 }}" for="opt_{{ urlparam }}_{{ loop.index0 }}">{{ option.value|raw }}</label></span>
{% endfor %}
</fieldset>
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends "@Contao/metamodels/filter/default.html.twig" %}
{% block formlabel %}
{% if not hide_label|default(false) %}
{{ label|default('')|raw }}
{% endif %}
{% endblock %}
{% block formfield %}
{% set minKey = raw.optionsMin.key|default(null) %}
{% set maxKey = raw.optionsMax.key|default(null) %}
{% set dateMin = minKey is not null ? minKey|date('Y-m-d') : '' %}
{% set dateMax = maxKey is not null ? maxKey|date('Y-m-d') : '' %}
{% set placeholderMin = raw.optionsMin.value|default('') %}
{% set placeholderMax = raw.optionsMax.value|default('') %}
{% set values = raw.value|default([]) %}
{% if raw.eval.fromField|default(false) %}
<input type="date" name="{{ urlparam }}[]" id="ctrl_{{ urlparam }}_0" class="text {{ class|default('') }}" value="{{ values[0]|default('') }}" placeholder="{{ placeholderMin }}" title="{{ raw.eval.labelFrom }}: {{ placeholderMin }}" min="{{ dateMin }}" max="{{ dateMax }}">
{% endif %}
{% set toIndex = (raw.eval.size|default(0) == 2) ? 1 : 0 %}
{% if raw.eval.toField|default(false) %}
<input type="date" name="{{ urlparam }}[]" id="ctrl_{{ urlparam }}_{{ toIndex }}" class="text {{ class|default('') }}" value="{{ values[toIndex]|default('') }}" placeholder="{{ placeholderMax }}" title="{{ raw.eval.labelTo }}: {{ placeholderMax }}" min="{{ dateMin }}" max="{{ dateMax }}">
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{#
MetaModels default filter widget - Twig version of mm_filteritem_default.html5.
#}
{% block error %}
{% for error in errors|default([]) %}
<p class="error">{{ error }}</p>
{% endfor %}
{% endblock %}

{% block formlabel %}
{% if not hide_label|default(false) %}
{{ label|default('')|raw }}
{% endif %}
{% endblock %}
{% block formfield %}
{{ formfield|default('')|raw }}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "@Contao/metamodels/filter/default.html.twig" %}
{% block formlabel %}
{% if not hide_label|default(false) %}
{{ label|default('')|raw }}
{% endif %}
{% endblock %}
{% block formfield %}
{% if options|default([]) is iterable and options|length %}
{% set urlFragment = urlfragment|default('') ? '#' ~ urlfragment : '' %}
<ul>
{% for option in options %}
{% set cls = (loop.first ? 'first ' : '') ~ (loop.last ? 'last ' : '') ~ (loop.index0 % 2 == 1 ? 'even' : 'odd') ~ (option.class|default('') ? ' ' ~ option.class : '') %}
<li class="{{ cls }}">
<a href="{{ option.href }}{{ urlFragment }}" class="{{ cls }}"
data-escargot-ignore rel="nofollow"
title="{{ option.value }}">{{ option.value|raw }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends "@Contao/metamodels/filter/default.html.twig" %}
{% block formlabel %}
{% if not hide_label|default(false) %}
{{ legend|default('')|raw }}
{% endif %}
{% endblock %}
{% block formfield %}
{% if options|default([]) is iterable and options|length %}
<fieldset id="ctrl_{{ urlparam }}" class="radio_container">
{% for option in options %}
{% set cls = (loop.first ? 'first ' : '') ~ (loop.last ? 'last ' : '') ~ (loop.index0 % 2 == 1 ? 'even' : 'odd') ~ (option.class|default('') ? ' ' ~ option.class : '') %}
<span class="{{ cls }}"><input type="radio" name="{{ urlparam }}" id="opt_{{ urlparam }}_{{ loop.index0 }}" class="radio{% if submit|default(false) %} submitonchange{% endif %}" value="{{ option.key }}"{% if option.active|default(false) %} checked="checked"{% endif %} /> <label id="lbl_{{ urlparam }}_{{ loop.index0 }}" for="opt_{{ urlparam }}_{{ loop.index0 }}">{{ option.value|raw }}</label></span>
{% endfor %}
</fieldset>
{% endif %}
{% endblock %}
Loading