diff --git a/docs/twig-templates.md b/docs/twig-templates.md new file mode 100644 index 000000000..d8a636dfc --- /dev/null +++ b/docs/twig-templates.md @@ -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//.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//`-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//.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//…` 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. diff --git a/src/Attribute/Base.php b/src/Attribute/Base.php index 1323b29cd..935368fb5 100644 --- a/src/Attribute/Base.php +++ b/src/Attribute/Base.php @@ -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); @@ -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); diff --git a/src/CoreBundle/Controller/ListControllerTrait.php b/src/CoreBundle/Controller/ListControllerTrait.php index 9c7af525d..61eece917 100644 --- a/src/CoreBundle/Controller/ListControllerTrait.php +++ b/src/CoreBundle/Controller/ListControllerTrait.php @@ -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). @@ -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 */ diff --git a/src/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index 89f6770f1..f8a0f2d39 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -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: @@ -136,6 +145,7 @@ services: - '@database_connection' - '@MetaModels\Filter\FilterUrlBuilder' - '@translator' + - '@metamodels.template_factory' public: true metamodels.controller.abstract.add_all: diff --git a/src/CoreBundle/Resources/contao/templates/ce_metamodel_list.html.twig b/src/CoreBundle/Resources/contao/templates/ce_metamodel_list.html.twig new file mode 100644 index 000000000..3b7ee82dd --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/ce_metamodel_list.html.twig @@ -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 %} diff --git a/src/CoreBundle/Resources/contao/templates/mm_actionbutton.html.twig b/src/CoreBundle/Resources/contao/templates/mm_actionbutton.html.twig new file mode 100644 index 000000000..88411e519 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/mm_actionbutton.html.twig @@ -0,0 +1 @@ +{% if action.html|default('') %}{{ action.html|raw }}{% else %}{{ action.label|raw }}{% endif %} diff --git a/src/CoreBundle/Resources/contao/templates/mm_clearall_default.html.twig b/src/CoreBundle/Resources/contao/templates/mm_clearall_default.html.twig new file mode 100644 index 000000000..5e6c6620c --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/mm_clearall_default.html.twig @@ -0,0 +1,6 @@ + + + diff --git a/src/CoreBundle/Resources/contao/templates/mm_filter_default.html.twig b/src/CoreBundle/Resources/contao/templates/mm_filter_default.html.twig new file mode 100644 index 000000000..0aac1fbd1 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/mm_filter_default.html.twig @@ -0,0 +1,23 @@ + +
+ {% if headline|default('') %} + <{{ hl }}>{{ headline|raw }} + {% endif %} + + + +
+ {% for filter in filters|default([]) %} +
+ {{ filter.value|raw }} +
+ {% endfor %} + {% if submit|default('') %} +
+ +
+ {% endif %} +
+ +
+ diff --git a/src/CoreBundle/Resources/contao/templates/mm_pagination.html.twig b/src/CoreBundle/Resources/contao/templates/mm_pagination.html.twig new file mode 100644 index 000000000..ff9b489ae --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/mm_pagination.html.twig @@ -0,0 +1,27 @@ +{% set fragment = paginationFragment|default('') ? '#' ~ paginationFragment : '' %} + + + diff --git a/src/CoreBundle/Resources/contao/templates/twig/.twig-root b/src/CoreBundle/Resources/contao/templates/twig/.twig-root new file mode 100644 index 000000000..e69de29bb diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/checkbox.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/checkbox.html.twig new file mode 100644 index 000000000..55d0afb37 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/checkbox.html.twig @@ -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 %} +
+ {% for option in options %} + {% set cls = (loop.first ? 'first ' : '') ~ (loop.last ? 'last ' : '') ~ (loop.index0 % 2 == 1 ? 'even' : 'odd') ~ (option.class|default('') ? ' ' ~ option.class : '') %} + + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/datepicker.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/datepicker.html.twig new file mode 100644 index 000000000..bd06caa19 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/datepicker.html.twig @@ -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) %} + + {% endif %} + {% set toIndex = (raw.eval.size|default(0) == 2) ? 1 : 0 %} + {% if raw.eval.toField|default(false) %} + + {% endif %} +{% endblock %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/default.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/default.html.twig new file mode 100644 index 000000000..810d281d0 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/default.html.twig @@ -0,0 +1,17 @@ +{# + MetaModels default filter widget - Twig version of mm_filteritem_default.html5. +#} +{% block error %} + {% for error in errors|default([]) %} +

{{ error }}

+ {% endfor %} +{% endblock %} + +{% block formlabel %} + {% if not hide_label|default(false) %} + {{ label|default('')|raw }} + {% endif %} +{% endblock %} +{% block formfield %} + {{ formfield|default('')|raw }} +{% endblock %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/linklist.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/linklist.html.twig new file mode 100644 index 000000000..d562eee8a --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/linklist.html.twig @@ -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 : '' %} +
    + {% for option in options %} + {% set cls = (loop.first ? 'first ' : '') ~ (loop.last ? 'last ' : '') ~ (loop.index0 % 2 == 1 ? 'even' : 'odd') ~ (option.class|default('') ? ' ' ~ option.class : '') %} +
  • + {{ option.value|raw }} +
  • + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/radiobuttons.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/radiobuttons.html.twig new file mode 100644 index 000000000..448412660 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/radiobuttons.html.twig @@ -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 %} +
+ {% for option in options %} + {% set cls = (loop.first ? 'first ' : '') ~ (loop.last ? 'last ' : '') ~ (loop.index0 % 2 == 1 ? 'even' : 'odd') ~ (option.class|default('') ? ' ' ~ option.class : '') %} + + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig new file mode 100644 index 000000000..c44a55ef6 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig @@ -0,0 +1,37 @@ +{# + MetaModels prerendered item list - Twig version of metamodel_prerendered.html5. +#} +{% if data|default([])|length %} +
+ {% for item in data %} + {% block item %} + {% set html5vals = item.html5|default({}) %} + {% set textvals = item.text|default({}) %} +
+ {% for field, name in item.attributes %} + {% set value = html5vals[field] is defined ? html5vals[field] : (textvals[field] is defined ? textvals[field] : null) %} + {% if value is not null %} +
+ {% if not view.get('hideLabels') %} +
{{ 'field_label'|trans({'%field_label%': name}, 'metamodels_list') }}
+ {% endif %} +
{{ value|raw }}
+
+ {% endif %} + {% endfor %} + {% block actions %} +
+ {% for action in item.actions %} + {{ include('@Contao/mm_actionbutton.html.twig', {action: action}) }} + {% endfor %} +
+ {% endblock %} +
+ {% endblock %} + {% endfor %} +
+{% else %} + {% block noItem %} +

{{ noItemsMsg|default('')|raw }}

+ {% endblock %} +{% endif %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered_debug.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered_debug.html.twig new file mode 100644 index 000000000..2215a7d90 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered_debug.html.twig @@ -0,0 +1,15 @@ +{# + MetaModels prerendered debug list - Twig version of metamodel_prerendered_debug.html5. +#} +{% if app.debug %} + {{ dump(data) }} +{% endif %} +{% if data|default([])|length %} + {% for item in data %} + {# Include here your output, e.g. item.html5['attribute_name'] or item.text['attribute_name']. #} + {% endfor %} +{% else %} + {% block noItem %} +

{{ noItemsMsg|default('')|raw }}

+ {% endblock %} +{% endif %} diff --git a/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/unrendered.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/unrendered.html.twig new file mode 100644 index 000000000..66565df21 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/unrendered.html.twig @@ -0,0 +1,37 @@ +{# + MetaModels unrendered item list - Twig version of metamodel_unrendered.html5. +#} +{% if items.getCount() %} +
+ {% for item in items.parseAll('html5', view) %} + {% block item %} + {% set html5vals = item.html5|default({}) %} + {% set textvals = item.text|default({}) %} +
+ {% for field, attr in items.getItem().getMetaModel().getAttributes() %} + {% set value = html5vals[field] is defined ? html5vals[field] : (textvals[field] is defined ? textvals[field] : null) %} + {% if value is not null %} +
+ {% if not view.get('hideLabels') %} +
{{ 'field_label'|trans({'%field_label%': attr.getName()}, 'metamodels_list') }}
+ {% endif %} +
{{ value|raw }}
+
+ {% endif %} + {% endfor %} + {% block actions %} +
+ {% for action in item.actions %} + {{ include('@Contao/mm_actionbutton.html.twig', {action: action}) }} + {% endfor %} +
+ {% endblock %} +
+ {% endblock %} + {% endfor %} +
+{% else %} + {% block noItem %} +

{{ noItemsMsg|default('')|raw }}

+ {% endblock %} +{% endif %} diff --git a/src/FrontendIntegration/FrontendFilter.php b/src/FrontendIntegration/FrontendFilter.php index 06150aafc..98b3ffa7d 100644 --- a/src/FrontendIntegration/FrontendFilter.php +++ b/src/FrontendIntegration/FrontendFilter.php @@ -34,7 +34,6 @@ use Contao\CoreBundle\Csrf\ContaoCsrfTokenManager; use Contao\CoreBundle\Exception\PageNotFoundException; use Contao\CoreBundle\Exception\RedirectResponseException; -use Contao\FrontendTemplate; use Contao\Input; use Contao\System; use Doctrine\DBAL\Connection; @@ -43,6 +42,7 @@ use MetaModels\Filter\FilterUrlBuilder; use MetaModels\FrontendIntegration\Content\FilterClearAll as ContentElementFilterClearAll; use MetaModels\FrontendIntegration\Module\FilterClearAll as ModuleFilterClearAll; +use MetaModels\Render\TemplateFactory; use RuntimeException; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -92,17 +92,26 @@ class FrontendFilter */ private TranslatorInterface $translator; + /** + * The template factory. + * + * @var TemplateFactory + */ + private TemplateFactory $templateFactory; + /** * FrontendFilter constructor. * * @param Connection|null $connection Database connection. * @param FilterUrlBuilder|null $filterUrlBuilder The filter URL builder. * @param TranslatorInterface|null $translator The translator. + * @param TemplateFactory|null $templateFactory The template factory. */ public function __construct( ?Connection $connection = null, ?FilterUrlBuilder $filterUrlBuilder = null, - ?TranslatorInterface $translator = null + ?TranslatorInterface $translator = null, + ?TemplateFactory $templateFactory = null ) { if (null === $connection) { // @codingStandardsIgnoreStart @@ -139,6 +148,18 @@ public function __construct( assert($translator instanceof TranslatorInterface); } $this->translator = $translator; + + if (null === $templateFactory) { + // @codingStandardsIgnoreStart + @trigger_error( + 'TemplateFactory is missing. It has to be passed in the constructor. Fallback will be dropped.', + E_USER_DEPRECATED + ); + // @codingStandardsIgnoreEnd + $templateFactory = System::getContainer()->get('metamodels.template_factory'); + assert($templateFactory instanceof TemplateFactory); + } + $this->templateFactory = $templateFactory; } /** @@ -326,13 +347,13 @@ protected function renderWidget($widget, $filterOptions) { $filter = $widget; $templateName = ($filter['raw']['eval']['template'] ?? 'mm_filteritem_default'); - $template = new FrontendTemplate($templateName); + $template = $this->templateFactory->createTemplate($templateName, 'filter'); $template->setData($filter); /** @psalm-suppress UndefinedMagicPropertyAssignment */ $template->submit = $filterOptions->isAutoSubmit(); - $filter['value'] = $template->parse(); + $filter['value'] = $template->parse('html5'); return $filter; } diff --git a/src/FrontendIntegration/MetaModelHybrid.php b/src/FrontendIntegration/MetaModelHybrid.php index 2265d8308..3fab4a1bf 100644 --- a/src/FrontendIntegration/MetaModelHybrid.php +++ b/src/FrontendIntegration/MetaModelHybrid.php @@ -37,6 +37,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Translation\TranslatorInterface; +use Twig\Markup; /** * Base implementation of a MetaModel Hybrid element. @@ -266,8 +267,9 @@ public function generate() } $objTemplate = new BackendTemplate('be_wildcard'); + // Mark the info text as safe HTML so the (auto-escaping) Twig be_wildcard template does not escape it. /** @psalm-suppress UndefinedMagicPropertyAssignment */ - $objTemplate->wildcard = $this->wildCardName . $strInfo; + $objTemplate->wildcard = new Markup($this->wildCardName . $strInfo, 'UTF-8'); /** @psalm-suppress UndefinedMagicPropertyAssignment */ $objTemplate->title = $this->headline; /** @psalm-suppress UndefinedMagicPropertyAssignment */ diff --git a/src/ItemList.php b/src/ItemList.php index 2ed42dc5e..f754f9711 100644 --- a/src/ItemList.php +++ b/src/ItemList.php @@ -685,7 +685,7 @@ protected function prepareView(): void $templateFactory = System::getContainer()->get('metamodels.template_factory'); assert($templateFactory instanceof TemplateFactory); - $this->objTemplate = $templateFactory->createTemplate((string) $this->objView->get('template')); + $this->objTemplate = $templateFactory->createTemplate((string) $this->objView->get('template'), 'item'); /** @psalm-suppress UndefinedMagicPropertyAssignment */ $this->objTemplate->view = $this->objView; } diff --git a/src/Render/Template.php b/src/Render/Template.php index 22c6f83d6..3c81cc8b2 100644 --- a/src/Render/Template.php +++ b/src/Render/Template.php @@ -112,6 +112,28 @@ class Template */ protected $scopeDeterminator; + /** + * The Twig surrogate. When set, a matching Twig template takes precedence over the legacy PHP template. + * + * @var TwigTemplateSurrogate|null + */ + protected $twigSurrogate; + + /** + * The Twig template group ("attribute", "filter" or "item") used to build the Twig candidate name + * "@Contao/metamodels/<group>/<leaf>.html.twig". Empty disables the Twig surrogate. + * + * @var string + */ + protected $twigGroup = ''; + + /** + * The kernel project directory, used to detect legacy template overrides in the project "templates/" folder. + * + * @var string + */ + protected $projectDir = ''; + /** * Template path cache. * @@ -145,13 +167,22 @@ public function __call($strMethod, $arrArgs) * @param string $strTemplate The name of the template file. * @param Adapter|Adapter|null $templateLoader Template loader adapter. * @param RequestScopeDeterminator|null $scopeDeterminator Request scope determinator. + * @param TwigTemplateSurrogate|null $twigSurrogate Twig surrogate (enables Twig precedence). + * @param string $twigGroup Twig template group (attribute|filter|item). + * @param string $projectDir The kernel project directory. */ public function __construct( $strTemplate = '', ?Adapter $templateLoader = null, - ?RequestScopeDeterminator $scopeDeterminator = null + ?RequestScopeDeterminator $scopeDeterminator = null, + ?TwigTemplateSurrogate $twigSurrogate = null, + string $twigGroup = '', + string $projectDir = '' ) { - $this->strTemplate = $strTemplate; + $this->strTemplate = $strTemplate; + $this->twigSurrogate = $twigSurrogate; + $this->twigGroup = $twigGroup; + $this->projectDir = $projectDir; if (null === $templateLoader) { // @codingStandardsIgnoreStart @@ -418,6 +449,11 @@ public function parse($strOutputFormat, $blnFailIfNotFound = false) // HOOK: add custom parse filters. $this->callParseTemplateHook(); + // A matching Twig template takes precedence over the legacy PHP template (like Contao core does). + if (null !== ($twigResult = $this->renderTwigSurrogate())) { + return $twigResult; + } + $strBuffer = ''; // Start with the template itself. @@ -460,26 +496,98 @@ public function parse($strOutputFormat, $blnFailIfNotFound = false) // Reset the internal arrays. $this->arrBlocks = []; - // Add start and end markers in debug mode. + return $this->addDebugMarkers((string) $strBuffer); + } + + /** + * Render a matching Twig template, taking precedence over the legacy PHP template, or null to fall back. + * + * @return string|null The rendered markup or null when no Twig surrogate applies. + */ + protected function renderTwigSurrogate(): ?string + { + if (null === $this->twigSurrogate) { + return null; + } + + // Transitional (deprecated, to be removed in 3.0 together with the ".html5" templates): a legacy override of + // the flat template name in the project "templates/" directory (or a theme folder) still wins over a bundle + // provided Twig template, so existing customisations keep working until they are migrated to + // "templates/metamodels//". + if ($this->hasLegacyTemplateOverride()) { + return null; + } + + return $this->twigSurrogate->render( + $this->strTemplate, + $this->twigGroup, + $this->strFormat, + $this->arrData + ); + } + + /** + * Check whether the flat template name is overridden in the project template directory (or a theme folder). + * + * Such a legacy override keeps precedence over a bundle provided Twig template for backwards compatibility. + * + * @return bool + * + * @deprecated Deprecated since 2.5 and to be removed in 3.0 - migrate overrides to "templates/metamodels/...". + */ + private function hasLegacyTemplateOverride(): bool + { + if (!\in_array($this->strFormat, ['html5', 'text'], true)) { + return false; + } + + if ('' === $this->projectDir) { + return false; + } + + $path = $this->getTemplate($this->strTemplate, $this->strFormat); + if (null === $path) { + return false; + } + + $templatesDir = (\realpath($this->projectDir . '/templates') ?: ($this->projectDir . '/templates')) + . \DIRECTORY_SEPARATOR; + + return \str_starts_with((string) (\realpath($path) ?: $path), $templatesDir); + } + + /** + * Wrap the buffer with template start/end markers when running in debug mode (html5 format only). + * + * @param string $strBuffer The rendered buffer. + * + * @return string The buffer, optionally wrapped with debug markers. + * + * @SuppressWarnings(PHPMD.Superglobals) + * @SuppressWarnings(PHPMD.CamelCaseVariableName) + */ + protected function addDebugMarkers(string $strBuffer) + { $container = System::getContainer(); if ( - ($container instanceof ContainerInterface) - && true === $container->getParameter('kernel.debug') - && ('html5' === $this->strFormat) + !($container instanceof ContainerInterface) + || true !== $container->getParameter('kernel.debug') + || ('html5' !== $this->strFormat) ) { - $rootDir = $container->getParameter('kernel.project_dir'); - assert(\is_string($rootDir)); - $strRelPath = - \str_replace($rootDir . '/', '', (string) $this->getTemplate($this->strTemplate, $this->strFormat)); - $strBuffer = <<getParameter('kernel.project_dir'); + assert(\is_string($rootDir)); + $strRelPath = + \str_replace($rootDir . '/', '', (string) $this->getTemplate($this->strTemplate, $this->strFormat)); + + return << $strBuffer EOF; - } - - return (string) $strBuffer; } /** diff --git a/src/Render/TemplateFactory.php b/src/Render/TemplateFactory.php index 370a2849e..7d8679cd9 100644 --- a/src/Render/TemplateFactory.php +++ b/src/Render/TemplateFactory.php @@ -16,15 +16,27 @@ public function __construct( private Adapter $templateLoader, private RequestScopeDeterminator $requestScopeDeterminator, + private TwigTemplateSurrogate $twigSurrogate, + private string $projectDir, ) { } - public function createTemplate(string $templateName): Template + /** + * Create a template instance. + * + * @param string $templateName The legacy template name (e.g. "mm_attr_text"). + * @param string $twigGroup The Twig group ("attribute", "filter" or "item") enabling the Twig surrogate + * "@Contao/metamodels/<group>/<leaf>.html.twig". Empty disables it. + */ + public function createTemplate(string $templateName, string $twigGroup = ''): Template { return new Template( $templateName, $this->templateLoader, $this->requestScopeDeterminator, + $this->twigSurrogate, + $twigGroup, + $this->projectDir, ); } } diff --git a/src/Render/TwigTemplateSurrogate.php b/src/Render/TwigTemplateSurrogate.php new file mode 100644 index 000000000..9d7c069aa --- /dev/null +++ b/src/Render/TwigTemplateSurrogate.php @@ -0,0 +1,133 @@ + + * @copyright 2012-2025 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\Render; + +use Contao\CoreBundle\Twig\ContaoTwigUtil; +use Contao\CoreBundle\Twig\Interop\ContextFactory; +use Contao\CoreBundle\Twig\Loader\ContaoFilesystemLoader; +use Twig\Environment; + +/** + * Renders a matching Twig template as a surrogate for a legacy MetaModels PHP template. + * + * A Twig template "@Contao/metamodels/<group>/<leaf>.html.twig" takes precedence over the legacy ".html5" + * template (frontend and backend), mirroring Contao's own Twig surrogate. + * + * The plain text format (search index, sorting, group headers) works the same way, but its templates carry an + * additional ".text" in the name: "<leaf>.text.html.twig" surrogates the legacy "<leaf>.text". + * + * The doubled extension is not cosmetic. Contao derives the template identifier by stripping the trailing + * ".html.twig" resp. ".twig", and it refuses to mix types under one identifier: a "<leaf>.text.twig" beside a + * "<leaf>.html.twig" would share the identifier "<leaf>" but count as a different type, which makes + * ContaoFilesystemLoader abort the whole hierarchy with an OutOfBoundsException - the entire back end and front end + * would answer with HTTP 500. Keeping ".html.twig" as the real extension puts the text variant under its own + * identifier "<leaf>.text". The same naming is already in use for the notelist mail template. + */ +final readonly class TwigTemplateSurrogate +{ + /** + * The output formats that can be surrogated, mapped to the suffix of the Twig template name. + */ + private const SUFFIX_BY_FORMAT = [ + 'html5' => '.html.twig', + 'text' => '.text.html.twig', + ]; + + public function __construct( + private Environment $twig, + private ContaoFilesystemLoader $loader, + private ContextFactory $contextFactory, + ) { + } + + /** + * Render the Twig surrogate for the given template or return null to fall back to the legacy PHP template. + * + * @param string $templateName The legacy template name (e.g. "mm_attr_text"). + * @param string $group The Twig group ("attribute", "filter" or "item"). + * @param string $format The current output format. + * @param array $data The template data. + */ + public function render(string $templateName, string $group, string $format, array $data): ?string + { + if (null === ($suffix = self::SUFFIX_BY_FORMAT[$format] ?? null)) { + return null; + } + + if (null === ($identifier = $this->getIdentifier($templateName, $group))) { + return null; + } + + // The ".text" of the suffix becomes part of the identifier (".text"), the real extension stays + // ".html.twig" - see the class comment on why. + $candidate = '@Contao/' . $identifier . $suffix; + if (!$this->loader->exists($candidate)) { + return null; + } + + // Respect a higher priority legacy ".html5" override in the managed hierarchy (mirror Contao). The actual + // render below stays theme aware through the loader's internal state; only this precedence check ignores the + // rarely used theme layer to avoid depending on Contao internal API. The text identifier has no legacy + // counterpart in the hierarchy, so the check only applies to the visual format. + if ('html5' === $format && 'html5' === ContaoTwigUtil::getExtension($this->loader->getFirst($identifier))) { + return null; + } + + return $this->twig->render($candidate, $this->contextFactory->fromData($data)); + } + + /** + * Build the Twig template identifier ("metamodels/<group>/<leaf>") for a legacy template name. + * + * The group comes from the render context (attribute, filter or item); the leaf is the configured template name + * with the conventional legacy prefix removed - e.g. "mm_attr_text" in the "attribute" group becomes + * "metamodels/attribute/text". + * + * @param string $templateName The legacy template name. + * @param string $group The Twig group. + * + * @return string|null The identifier or null when no Twig group is set. + */ + private function getIdentifier(string $templateName, string $group): ?string + { + if ('' === $group || '' === $templateName) { + return null; + } + + $prefixes = match ($group) { + 'attribute' => ['mm_attr_'], + 'filter' => ['mm_filteritem_', 'mm_filter_', 'mm_clearall_'], + 'item' => ['metamodel_', 'mm_'], + default => [], + }; + + $leaf = $templateName; + foreach ($prefixes as $prefix) { + if (\str_starts_with($leaf, $prefix)) { + $leaf = \substr($leaf, \strlen($prefix)); + break; + } + } + + return 'metamodels/' . $group . '/' . $leaf; + } +} diff --git a/tests/Render/TwigTemplateSurrogateTest.php b/tests/Render/TwigTemplateSurrogateTest.php new file mode 100644 index 000000000..1996a0c70 --- /dev/null +++ b/tests/Render/TwigTemplateSurrogateTest.php @@ -0,0 +1,189 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\Test\Render; + +use Contao\CoreBundle\Routing\ScopeMatcher; +use Contao\CoreBundle\Twig\Interop\ContextFactory; +use Contao\CoreBundle\Twig\Loader\ContaoFilesystemLoader; +use MetaModels\Render\TwigTemplateSurrogate; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Twig\Environment; + +/** + * Tests for the Twig surrogate that renders a Twig template in place of the legacy MetaModels PHP template. + */ +#[CoversClass(TwigTemplateSurrogate::class)] +final class TwigTemplateSurrogateTest extends TestCase +{ + /** + * Build a surrogate with the given (mocked) Twig environment and filesystem loader. + * + * The context factory is used with a real instance because it is final and cannot be mocked; for plain array + * data (as used in these tests) it returns the data unchanged. + */ + private function buildSurrogate(Environment $twig, ContaoFilesystemLoader $loader): TwigTemplateSurrogate + { + return new TwigTemplateSurrogate( + $twig, + $loader, + new ContextFactory($this->createStub(ScopeMatcher::class)) + ); + } + + public function testReturnsNullForUnsupportedFormat(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $twig->expects(self::never())->method('render'); + $loader->expects(self::never())->method('exists'); + + self::assertNull($this->buildSurrogate($twig, $loader)->render('mm_attr_text', 'attribute', 'xml', [])); + } + + /** + * The text format keeps ".html.twig" as its real extension and carries the ".text" in the identifier - a + * ".text.twig" would collide with the ".html.twig" of the same identifier and make Contao's + * template hierarchy fail as a whole. + */ + public function testRendersTheTextVariantForTheTextFormat(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $loader->expects(self::once())->method('exists') + ->with('@Contao/metamodels/attribute/text.text.html.twig') + ->willReturn(true); + // The text identifier has no legacy counterpart, so the precedence check must not run. + $loader->expects(self::never())->method('getFirst'); + $twig->expects(self::once())->method('render') + ->with('@Contao/metamodels/attribute/text.text.html.twig', ['foo' => 'bar']) + ->willReturn('plain text'); + + self::assertSame( + 'plain text', + $this->buildSurrogate($twig, $loader)->render('mm_attr_text', 'attribute', 'text', ['foo' => 'bar']) + ); + } + + public function testReturnsNullWhenTheTextVariantDoesNotExist(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $loader->expects(self::once())->method('exists') + ->with('@Contao/metamodels/attribute/text.text.html.twig') + ->willReturn(false); + $twig->expects(self::never())->method('render'); + + self::assertNull($this->buildSurrogate($twig, $loader)->render('mm_attr_text', 'attribute', 'text', [])); + } + + public function testReturnsNullForEmptyGroup(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $twig->expects(self::never())->method('render'); + $loader->expects(self::never())->method('exists'); + + self::assertNull($this->buildSurrogate($twig, $loader)->render('mm_attr_text', '', 'html5', [])); + } + + public function testReturnsNullWhenTwigTemplateDoesNotExist(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $loader->expects(self::once())->method('exists') + ->with('@Contao/metamodels/attribute/text.html.twig') + ->willReturn(false); + $twig->expects(self::never())->method('render'); + + self::assertNull($this->buildSurrogate($twig, $loader)->render('mm_attr_text', 'attribute', 'html5', [])); + } + + public function testReturnsNullWhenLegacyHtml5OverrideHasPrecedence(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $loader->method('exists')->willReturn(true); + $loader->method('getFirst') + ->with('metamodels/attribute/text') + ->willReturn('@Contao/metamodels/attribute/text.html5'); + $twig->expects(self::never())->method('render'); + + self::assertNull($this->buildSurrogate($twig, $loader)->render('mm_attr_text', 'attribute', 'html5', [])); + } + + public function testRendersTwigTemplateWhenItExists(): void + { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $loader->method('exists') + ->with('@Contao/metamodels/attribute/text.html.twig') + ->willReturn(true); + $loader->method('getFirst') + ->with('metamodels/attribute/text') + ->willReturn('@Contao/metamodels/attribute/text.html.twig'); + $twig->expects(self::once())->method('render') + ->with('@Contao/metamodels/attribute/text.html.twig', ['value' => 'x']) + ->willReturn(''); + + self::assertSame( + '', + $this->buildSurrogate($twig, $loader)->render('mm_attr_text', 'attribute', 'html5', ['value' => 'x']) + ); + } + + /** + * @return array + */ + public static function provideIdentifierMapping(): array + { + return [ + 'attribute strips the mm_attr_ prefix' => + ['mm_attr_text', 'attribute', '@Contao/metamodels/attribute/text.html.twig'], + 'filter strips the mm_filteritem_ prefix' => + ['mm_filteritem_default', 'filter', '@Contao/metamodels/filter/default.html.twig'], + 'filter strips the mm_filter_ prefix' => + ['mm_filter_default', 'filter', '@Contao/metamodels/filter/default.html.twig'], + 'item strips the metamodel_ prefix' => + ['metamodel_prerendered', 'item', '@Contao/metamodels/item/prerendered.html.twig'], + 'item strips the mm_ prefix' => + ['mm_default', 'item', '@Contao/metamodels/item/default.html.twig'], + 'a name without a known prefix is kept' => + ['custom', 'attribute', '@Contao/metamodels/attribute/custom.html.twig'], + ]; + } + + #[DataProvider('provideIdentifierMapping')] + public function testBuildsTwigIdentifierFromGroupAndName( + string $name, + string $group, + string $expectedCandidate + ): void { + $twig = $this->createMock(Environment::class); + $loader = $this->createMock(ContaoFilesystemLoader::class); + $twig->expects(self::never())->method('render'); + $loader->expects(self::once())->method('exists')->with($expectedCandidate)->willReturn(false); + + self::assertNull($this->buildSurrogate($twig, $loader)->render($name, $group, 'html5', [])); + } +}