From b5112ca87b26c5ae72f378cf5bad6f0868aac9b1 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Fri, 24 Jul 2026 14:45:07 +0200 Subject: [PATCH 1/7] Add Twig template support with precedence over legacy templates A matching Twig template "@Contao/metamodels//.html.twig" now takes precedence over the legacy ".html5" template, mirroring Contao core. When no Twig template exists the legacy template is used unchanged (full BC fallback). The new MetaModels\Render\TwigTemplateSurrogate resolves the candidate via Contao's managed "@Contao" filesystem loader (so the templates are Template Studio ready and support theme and project overrides) and renders it through Twig with a context built by Contao's ContextFactory. MetaModels\Render\Template consults it before including the PHP template; the render group (attribute, filter, item) is passed by the callers via TemplateFactory::createTemplate(). Only the visual "html5" format is a Twig candidate - the plain text format used for the search index and sorting stays on the PHP engine. Precedence applies in the front end and the back end. See docs/twig-templates.md for the naming scheme and how packages ship templates (twig/ namespace root with a .twig-root marker). --- docs/twig-templates.md | 95 ++++++++++++++++ src/Attribute/Base.php | 5 +- src/CoreBundle/Resources/config/services.yml | 8 ++ src/ItemList.php | 2 +- src/Render/Template.php | 84 +++++++++++--- src/Render/TemplateFactory.php | 12 +- src/Render/TwigTemplateSurrogate.php | 114 +++++++++++++++++++ 7 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 docs/twig-templates.md create mode 100644 src/Render/TwigTemplateSurrogate.php diff --git a/docs/twig-templates.md b/docs/twig-templates.md new file mode 100644 index 000000000..4e3f6ec3e --- /dev/null +++ b/docs/twig-templates.md @@ -0,0 +1,95 @@ +# 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` | + +Ein eigenes (nicht-präfigiertes) Template `mm_attr_text_fancy` wird zu +`metamodels/attribute/text_fancy`. + +### Nur visuelle Ausgabe + +Der Twig-Vorrang greift **ausschließlich für das Format `html5`** (die sichtbare Ausgabe). +Das Format `text` (Suchindex, Sortierung, Gruppen-Header) bleibt immer auf der PHP-Engine. + +### 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) +behält gegenüber einem Paket-Twig-Template den Vorrang – ebenfalls wie in Contao. + +## Rollout-Stand + +* **MetaModels Core:** Mechanismus implementiert (`Template`, `TemplateFactory`, + `TwigTemplateSurrogate`, DI). Aktiv, solange Twig-Templates vorhanden sind – ohne Templates + keine Verhaltensänderung. +* **Attribute/Filter/Core-Templates:** werden schrittweise 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/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index 89f6770f1..4da8ef594 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -80,11 +80,19 @@ 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" public: true metamodels.contao_input: 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..04151f837 100644 --- a/src/Render/Template.php +++ b/src/Render/Template.php @@ -112,6 +112,21 @@ 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 = ''; + /** * Template path cache. * @@ -145,13 +160,19 @@ 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). */ public function __construct( $strTemplate = '', ?Adapter $templateLoader = null, - ?RequestScopeDeterminator $scopeDeterminator = null + ?RequestScopeDeterminator $scopeDeterminator = null, + ?TwigTemplateSurrogate $twigSurrogate = null, + string $twigGroup = '' ) { - $this->strTemplate = $strTemplate; + $this->strTemplate = $strTemplate; + $this->twigSurrogate = $twigSurrogate; + $this->twigGroup = $twigGroup; if (null === $templateLoader) { // @codingStandardsIgnoreStart @@ -418,6 +439,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 +486,56 @@ 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 + { + return $this->twigSurrogate?->render( + $this->strTemplate, + $this->twigGroup, + $this->strFormat, + $this->arrData + ); + } + + /** + * 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..504ce932e 100644 --- a/src/Render/TemplateFactory.php +++ b/src/Render/TemplateFactory.php @@ -16,15 +16,25 @@ public function __construct( private Adapter $templateLoader, private RequestScopeDeterminator $requestScopeDeterminator, + private TwigTemplateSurrogate $twigSurrogate, ) { } - 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, ); } } diff --git a/src/Render/TwigTemplateSurrogate.php b/src/Render/TwigTemplateSurrogate.php new file mode 100644 index 000000000..f6bef98a6 --- /dev/null +++ b/src/Render/TwigTemplateSurrogate.php @@ -0,0 +1,114 @@ + + * @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. Only the visual "html5" output format is a + * candidate; the plain text format (search index and sorting) always stays on the PHP engine. + */ +final readonly class TwigTemplateSurrogate +{ + 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 + { + // Only the visual output is a candidate for Twig; the plain text format stays on the PHP engine. + if ('html5' !== $format) { + return null; + } + + if (null === ($identifier = $this->getIdentifier($templateName, $group))) { + return null; + } + + $candidate = '@Contao/' . $identifier . '.html.twig'; + 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. + if ('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; + } +} From 505d05a1f731cde478635521c4006c17808c3e26 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Fri, 24 Jul 2026 15:32:08 +0200 Subject: [PATCH 2/7] Add Twig base templates for item and filter and route filter widgets through the surrogate Ship the first core Twig templates under the namespace root Resources/contao/templates/twig/.twig-root: - metamodels/item/prerendered.html.twig (from metamodel_prerendered.html5) - metamodels/filter/default.html.twig (from mm_filteritem_default.html5) FrontendFilter::renderWidget() now renders the filter widget through MetaModels\Render\Template (via TemplateFactory, group "filter") instead of a raw Contao FrontendTemplate, so filter widgets use the same @Contao/metamodels/filter/ scheme as attributes and items. Verified in the front end: item list and filter widgets render through Twig with correct output (labels, values, actions) and fall back to the legacy .html5 templates where no Twig template exists. --- src/CoreBundle/Resources/config/services.yml | 1 + .../contao/templates/twig/.twig-root | 0 .../twig/metamodels/filter/default.html.twig | 17 ++++++++ .../metamodels/item/prerendered.html.twig | 41 +++++++++++++++++++ src/FrontendIntegration/FrontendFilter.php | 29 +++++++++++-- 5 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/CoreBundle/Resources/contao/templates/twig/.twig-root create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/default.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig diff --git a/src/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index 4da8ef594..69039c946 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -144,6 +144,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/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/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/item/prerendered.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig new file mode 100644 index 000000000..7e37905b9 --- /dev/null +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig @@ -0,0 +1,41 @@ +{# + 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 %} + {% if action.html|default('') %} + {{ action.html|raw }} + {% else %} + {{ action.label|raw }} + {% endif %} + {% 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; } From 28c8f4a1ef089ca05b3e19757f371b52f37c8309 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Fri, 24 Jul 2026 16:58:38 +0200 Subject: [PATCH 3/7] Add remaining core Twig templates and honour legacy flat overrides Ship the remaining core item and filter Twig templates: - item/unrendered, item/prerendered_debug - filter/checkbox, filter/radiobuttons, filter/linklist, filter/datepicker (each extends filter/default via Twig block inheritance) Transitional back-compat (deprecated, to be removed in 3.0 with the .html5 templates): a legacy override of the flat template name in the project "templates/" directory (or a theme folder) keeps precedence over a bundle provided Twig template, so existing customisations keep working after the upgrade. Template::hasLegacyTemplateOverride() detects such overrides using the kernel project directory, which is now injected via TemplateFactory instead of being fetched from the container. Verified in the front end across several list pages (HTTP 200). --- docs/twig-templates.md | 24 ++++++-- src/CoreBundle/Resources/config/services.yml | 1 + .../twig/metamodels/filter/checkbox.html.twig | 16 ++++++ .../metamodels/filter/datepicker.html.twig | 22 ++++++++ .../twig/metamodels/filter/linklist.html.twig | 21 +++++++ .../metamodels/filter/radiobuttons.html.twig | 16 ++++++ .../item/prerendered_debug.html.twig | 15 +++++ .../twig/metamodels/item/unrendered.html.twig | 37 ++++++++++++ src/Render/Template.php | 56 ++++++++++++++++++- src/Render/TemplateFactory.php | 2 + 10 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/checkbox.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/datepicker.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/linklist.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/filter/radiobuttons.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered_debug.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/twig/metamodels/item/unrendered.html.twig diff --git a/docs/twig-templates.md b/docs/twig-templates.md index 4e3f6ec3e..181bdc914 100644 --- a/docs/twig-templates.md +++ b/docs/twig-templates.md @@ -84,12 +84,26 @@ 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) -behält gegenüber einem Paket-Twig-Template den Vorrang – ebenfalls wie in Contao. +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). Aktiv, solange Twig-Templates vorhanden sind – ohne Templates - keine Verhaltensänderung. -* **Attribute/Filter/Core-Templates:** werden schrittweise als Twig-Varianten nachgezogen. + `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/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index 69039c946..f8a0f2d39 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -93,6 +93,7 @@ services: - "@=service('contao.framework').getAdapter('Contao\\\\TemplateLoader')" - "@cca.dc-general.scope-matcher" - "@metamodels.render.twig_surrogate" + - "%kernel.project_dir%" public: true metamodels.contao_input: 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/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_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..e733e09ce --- /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 %} + {% if action.html|default('') %}{{ action.html|raw }}{% else %}{{ action.label|raw }}{% endif %} + {% endfor %} +
+ {% endblock %} +
+ {% endblock %} + {% endfor %} +
+{% else %} + {% block noItem %} +

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

+ {% endblock %} +{% endif %} diff --git a/src/Render/Template.php b/src/Render/Template.php index 04151f837..8f1eadcfa 100644 --- a/src/Render/Template.php +++ b/src/Render/Template.php @@ -127,6 +127,13 @@ class Template */ protected $twigGroup = ''; + /** + * The kernel project directory, used to detect legacy template overrides in the project "templates/" folder. + * + * @var string + */ + protected $projectDir = ''; + /** * Template path cache. * @@ -162,17 +169,20 @@ public function __call($strMethod, $arrArgs) * @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, ?TwigTemplateSurrogate $twigSurrogate = null, - string $twigGroup = '' + string $twigGroup = '', + string $projectDir = '' ) { $this->strTemplate = $strTemplate; $this->twigSurrogate = $twigSurrogate; $this->twigGroup = $twigGroup; + $this->projectDir = $projectDir; if (null === $templateLoader) { // @codingStandardsIgnoreStart @@ -496,7 +506,19 @@ public function parse($strOutputFormat, $blnFailIfNotFound = false) */ protected function renderTwigSurrogate(): ?string { - return $this->twigSurrogate?->render( + 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, @@ -504,6 +526,36 @@ protected function renderTwigSurrogate(): ?string ); } + /** + * 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 ('html5' !== $this->strFormat) { + return false; + } + + if ('' === $this->projectDir) { + return false; + } + + $path = $this->getTemplate($this->strTemplate, 'html5'); + 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). * diff --git a/src/Render/TemplateFactory.php b/src/Render/TemplateFactory.php index 504ce932e..7d8679cd9 100644 --- a/src/Render/TemplateFactory.php +++ b/src/Render/TemplateFactory.php @@ -17,6 +17,7 @@ public function __construct( private Adapter $templateLoader, private RequestScopeDeterminator $requestScopeDeterminator, private TwigTemplateSurrogate $twigSurrogate, + private string $projectDir, ) { } @@ -35,6 +36,7 @@ public function createTemplate(string $templateName, string $twigGroup = ''): Te $this->requestScopeDeterminator, $this->twigSurrogate, $twigGroup, + $this->projectDir, ); } } From 710bb15973f974d9694c65e4eef988815339b1f9 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Fri, 24 Jul 2026 21:21:05 +0200 Subject: [PATCH 4/7] Add Twig wrapper templates and keep the action button separate Provide Twig versions of the Contao content element / module wrapper templates (flat @Contao identifiers): ce_metamodel_list, mm_filter_default, mm_clearall_default and the pagination template mm_pagination. The item templates now include the separate @Contao/mm_actionbutton.html.twig instead of inlining the markup, so existing custom action button templates keep overriding it. --- .../templates/ce_metamodel_list.html.twig | 5 ++++ .../templates/mm_actionbutton.html.twig | 1 + .../templates/mm_clearall_default.html.twig | 6 +++++ .../templates/mm_filter_default.html.twig | 23 ++++++++++++++++ .../contao/templates/mm_pagination.html.twig | 27 +++++++++++++++++++ .../metamodels/item/prerendered.html.twig | 6 +---- .../twig/metamodels/item/unrendered.html.twig | 2 +- 7 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 src/CoreBundle/Resources/contao/templates/ce_metamodel_list.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/mm_actionbutton.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/mm_clearall_default.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/mm_filter_default.html.twig create mode 100644 src/CoreBundle/Resources/contao/templates/mm_pagination.html.twig 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/metamodels/item/prerendered.html.twig b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig index 7e37905b9..c44a55ef6 100644 --- a/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/prerendered.html.twig @@ -22,11 +22,7 @@ {% block actions %}
{% for action in item.actions %} - {% if action.html|default('') %} - {{ action.html|raw }} - {% else %} - {{ action.label|raw }} - {% endif %} + {{ include('@Contao/mm_actionbutton.html.twig', {action: action}) }} {% endfor %}
{% endblock %} 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 index e733e09ce..66565df21 100644 --- a/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/unrendered.html.twig +++ b/src/CoreBundle/Resources/contao/templates/twig/metamodels/item/unrendered.html.twig @@ -22,7 +22,7 @@ {% block actions %}
{% for action in item.actions %} - {% if action.html|default('') %}{{ action.html|raw }}{% else %}{{ action.label|raw }}{% endif %} + {{ include('@Contao/mm_actionbutton.html.twig', {action: action}) }} {% endfor %}
{% endblock %} From e98a0f9716de5c8cb5986fc33314f696356688d1 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Sat, 25 Jul 2026 14:02:22 +0200 Subject: [PATCH 5/7] Add unit tests for the Twig template surrogate Cover MetaModels\Render\TwigTemplateSurrogate: the html5-only format gate, the empty group and missing template short-circuits, precedence of a higher priority legacy .html5 template, rendering the Twig template when it exists, and the identifier mapping (metamodels//) for the attribute, filter and item groups including the prefix stripping. --- tests/Render/TwigTemplateSurrogateTest.php | 153 +++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/Render/TwigTemplateSurrogateTest.php diff --git a/tests/Render/TwigTemplateSurrogateTest.php b/tests/Render/TwigTemplateSurrogateTest.php new file mode 100644 index 000000000..a8116a51c --- /dev/null +++ b/tests/Render/TwigTemplateSurrogateTest.php @@ -0,0 +1,153 @@ + + * @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 testReturnsNullForNonHtml5Format(): 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', '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', [])); + } +} From 6aec428ee4342ff10fc016492f11ae36458c6e58 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Sat, 25 Jul 2026 14:48:33 +0200 Subject: [PATCH 6/7] Render the back end wildcard info as safe HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contao 5.7 renders be_wildcard through an auto-escaping Twig template, so the HTML info text built for the MetaModels content element / module preview ("MM: …", "Fi: …") showed up as escaped source in the back end list. Wrap the info text in a Twig Markup instance so it is treated as safe HTML by the Twig be_wildcard template while still rendering correctly via the legacy .html5 one. --- src/CoreBundle/Controller/ListControllerTrait.php | 4 +++- src/FrontendIntegration/MetaModelHybrid.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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 */ From 7c8bf5349d3374e425ee96f513832c5b1bb4f645 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Mon, 27 Jul 2026 22:27:13 +0200 Subject: [PATCH 7/7] Let the text output format use Twig templates as well So far only the visual "html5" format could be surrogated; the plain text format (search index, sorting, group headers, alias generation) always stayed on the PHP engine. Its templates carry an additional ".text" in the name: ".text.html.twig" surrogates the legacy ".text". The doubled extension is not cosmetic. Contao derives the template identifier by stripping the trailing ".html.twig" resp. ".twig" and refuses to mix types under one identifier, so a ".text.twig" beside a ".html.twig" would share the identifier and make ContaoFilesystemLoader abort the whole hierarchy with an OutOfBoundsException - back end and front end would answer with HTTP 500. The naming is already established by the notelist mail template. A project side "templates/.text" keeps its precedence, the same way it does for the visual format. The test for the previously unsupported format now uses a format that really is unsupported, and two new ones cover the text variant. --- docs/twig-templates.md | 35 +++++++++++++++++--- src/Render/Template.php | 4 +-- src/Render/TwigTemplateSurrogate.php | 33 +++++++++++++++---- tests/Render/TwigTemplateSurrogateTest.php | 38 +++++++++++++++++++++- 4 files changed, 95 insertions(+), 15 deletions(-) diff --git a/docs/twig-templates.md b/docs/twig-templates.md index 181bdc914..d8a636dfc 100644 --- a/docs/twig-templates.md +++ b/docs/twig-templates.md @@ -34,13 +34,38 @@ Aus dem Legacy-Namen wird der Twig-Identifier gebildet: | `mm_filteritem_...` | `filter` | `metamodels/filter/...` | | `mm_default` | `item` | `metamodels/item/default` | -Ein eigenes (nicht-präfigiertes) Template `mm_attr_text_fancy` wird zu -`metamodels/attribute/text_fancy`. +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. -### Nur visuelle Ausgabe +### Textformat -Der Twig-Vorrang greift **ausschließlich für das Format `html5`** (die sichtbare Ausgabe). -Das Format `text` (Suchindex, Sortierung, Gruppen-Header) bleibt immer auf der PHP-Engine. +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 diff --git a/src/Render/Template.php b/src/Render/Template.php index 8f1eadcfa..3c81cc8b2 100644 --- a/src/Render/Template.php +++ b/src/Render/Template.php @@ -537,7 +537,7 @@ protected function renderTwigSurrogate(): ?string */ private function hasLegacyTemplateOverride(): bool { - if ('html5' !== $this->strFormat) { + if (!\in_array($this->strFormat, ['html5', 'text'], true)) { return false; } @@ -545,7 +545,7 @@ private function hasLegacyTemplateOverride(): bool return false; } - $path = $this->getTemplate($this->strTemplate, 'html5'); + $path = $this->getTemplate($this->strTemplate, $this->strFormat); if (null === $path) { return false; } diff --git a/src/Render/TwigTemplateSurrogate.php b/src/Render/TwigTemplateSurrogate.php index f6bef98a6..9d7c069aa 100644 --- a/src/Render/TwigTemplateSurrogate.php +++ b/src/Render/TwigTemplateSurrogate.php @@ -30,11 +30,28 @@ * 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. Only the visual "html5" output format is a - * candidate; the plain text format (search index and sorting) always stays on the PHP engine. + * 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, @@ -52,8 +69,7 @@ public function __construct( */ public function render(string $templateName, string $group, string $format, array $data): ?string { - // Only the visual output is a candidate for Twig; the plain text format stays on the PHP engine. - if ('html5' !== $format) { + if (null === ($suffix = self::SUFFIX_BY_FORMAT[$format] ?? null)) { return null; } @@ -61,15 +77,18 @@ public function render(string $templateName, string $group, string $format, arra return null; } - $candidate = '@Contao/' . $identifier . '.html.twig'; + // 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. - if ('html5' === ContaoTwigUtil::getExtension($this->loader->getFirst($identifier))) { + // 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; } diff --git a/tests/Render/TwigTemplateSurrogateTest.php b/tests/Render/TwigTemplateSurrogateTest.php index a8116a51c..1996a0c70 100644 --- a/tests/Render/TwigTemplateSurrogateTest.php +++ b/tests/Render/TwigTemplateSurrogateTest.php @@ -51,13 +51,49 @@ private function buildSurrogate(Environment $twig, ContaoFilesystemLoader $loade ); } - public function testReturnsNullForNonHtml5Format(): void + 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', [])); }