cresset / module-template-parser
Parse-to-AST template directive engine for Magento/Mage-OS, installable as a module
Package info
github.com/cresset-tools/module-template-parser
Type:magento2-module
pkg:composer/cresset/module-template-parser
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- symfony/console: ^6.4 || ^7.0
- symfony/string: ^7.2
Requires (Dev)
- phpunit/phpunit: ^12.0
Suggests
- magento/framework: Required only for the src/Magento adapters; the engine itself has no Magento dependency
- n98/magerun2: Lists the module's bin/magento commands (template:*) once the module is enabled
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-02 08:15:34 UTC
README
A parser for Magento's {{...}} template directives. It builds an AST and evaluates it once,
instead of matching regexes against its own output the way Magento\Framework\Filter\Template
does.
use Cresset\TemplateParser\TemplateEngine; $engine = new TemplateEngine(); echo $engine->render('Dear {{var name}},', ['name' => 'Ada']); // Dear Ada,
Everything in this README lives under Cresset\TemplateParser\; later snippets leave the
use lines out.
composer require cresset/module-template-parser
That puts vendor/bin/template-parser in place too, which is what the CLI section below runs.
Requires PHP 8.3, 8.4 or 8.5. The engine is plain PHP with no Magento dependency; the Magento
bindings sit behind narrow interfaces — ports — in src/Magento/.
Pre-1.0, not yet used in production, and the API may change. What that means before you switch a store over.
Looking for what a directive actually does, rather than what this engine does with it? docs/directives.md is a reference for the template language itself — every example in it was produced by rendering that example through the real filter.
Why
Magento\Framework\Filter\Template finds directives with regular expressions and scans its
own output a second time. Because a regex cannot express nesting, nested rendering is done
by re-running the whole engine over substrings, which means a child render sometimes holds a
directive belonging to its parent. The only channel back up is the output text, so deferred
directives are marked in-band with a per-request signature. An in-band marker sitting in the
same buffer as attacker-controlled data can be relocated, which is the StyleSmuggler class of
bug Sansec reported. The signing mechanism it
subverts was itself added in 2022 to fix an earlier bug of the same shape.
This engine removes the conditions rather than tightening the check:
- A value is never source. Parse once, evaluate once. There is no second scan, so content introduced by a variable cannot be executed at any depth, under any modifier.
- Nesting comes from a grammar.
DirectiveSpecdeclares which directives take a body. A closing tag closes only a block that is actually open. - Deferral is structured.
Context::defer()records work andContext::absorb()hands a child's entries up one level. Nothing travels in the output stream, so nothing needs signing. - Unknown constructs are inert. No handler means the directive round-trips as text. Nothing is guessed at, and there is no reflection-based dispatch.
- Parsing is lossless. Every AST reproduces its source byte for byte, which is what makes shadow-mode comparison possible.
Modes
Three presets, differing only in how much they refuse:
| Mode | Behaviour | Use it for |
|---|---|---|
new TemplateEngine() |
strict: unparseable input, unknown directives and unknown variables all raise | templates being authored or validated |
TemplateEngine::lenient() |
recovers instead of raising; unknown constructs render verbatim | content already stored in a database |
TemplateEngine::compatible() |
lenient, plus the legacy filter's rendering quirks; refuses what the filter crashes on | shadow comparison, and switching a store over |
Strictness has three independent axes — syntax, directives and variables — so you can mix them:
TemplateEngine::withOptions(Options::strict()->withVariables(false));
All of that holds in every mode; the modes differ in what they refuse, never in what they run.
The command line calls these the posture — --posture=strict|lenient|compatible — because
in a store "mode" means the rollout stage below, Legacy, Shadow or Parser. They are different
axes: the posture is how this engine reads a template, the stage is whether a store view uses
it. Parser mode runs the compatible posture, which is why that is the CLI's default.
Using it as a Magento module
The package is a magento2-module with registration.php and etc/, so
bin/magento setup:upgrade after the composer require above adds it to app/etc/config.php.
Installing it changes no rendering behaviour. The module wires its plugin on install, and
the plugin does nothing until a store view is told to.
The stage is chosen per store view, under Stores › Configuration › Advanced › System ›
Template Engine (system/template_engine/mode):
| Stage | What renders | What the customer gets |
|---|---|---|
| Legacy (default) | Magento's filter only | the legacy result |
| Shadow | both engines, compared | the legacy result |
| Parser | this engine; the filter only for what it declines, and for a sample | this engine's result, or legacy's where it declined |
bin/magento config:set --scope=stores --scope-code=default system/template_engine/mode shadow
Parser serves this engine's output, and falls back to the legacy filter for any render this
engine declines: a refusal, a render the policy cut short (a layout handle not allowed), an
exception from the host while rendering (a block that raises), or a crash. A fallback is the filter's own render of that template, so it is exactly what
Legacy would have served. The one way Parser can do worse than Legacy is by serving different
output without raising — which is what Shadow measures before the switch, and what Parser keeps
measuring after it: system/template_engine/parser_shadow_rate percent of its renders (1 by
default, shown in the admin only for Parser) are also rendered by the filter and compared. The
customer still gets this engine's result.
"Declines" is deliberately wide. Wherever this engine would render less than Mage-OS 3.5.0's filter for the same template — a guard stricter than the filter's, a port that cannot answer, a directive it does not model — the render records a policy violation instead of rendering an unexplained nothing, and Parser hands it to the filter. That covers:
- a
{{store}},{{media}},{{view}},{{protocol}},{{css}},{{customvar}}or{{template}}parameter one of this engine's guards refuses (the filter checks none of them); {{block id=...}}and{{widget id=...}}, which load a CMS block or a widget instance by id;- a
{{block output=...}}method outside the allowed list, a class that is not a block, a layout handle or area that is not allowed, and an integrator's class or widget allowlist; - a
{{template}}include the loader cannot produce, a{{config}}country or region with no store information to name it, a stylesheet that cannot be built, and a ProcessorPool directive that is missing, raises, or has modifiers and no filter pool; - a directive whose port the host did not wire;
- a directive only the filter itself renders: a
fooDirective()a module added, a stock directive a module put a plugin on, the CMS filter's own{{media}}, which returns a filesystem path for the admin's WYSIWYG preview, and{{widget}}in the newsletter filter, which renders each widget in an emulated frontend area; {{store}}through the backend URL model - a CMS filter built in the admin - whose route persists between calls, so legacy's output there depends on what it built last;- a template the filter reads differently: a
{{for}}loop, a construct legacy's lazy{{name(.*?)}}would end at a different}}(a{{{, a stray or quoted{{, a missing brace), a quote still open at that}}, and{{iframe}}-style names legacy reads as{{if}}. These are the "Quirks it does not reproduce" below; compatible mode keeps rendering them its own way forcheckanddiff, and the Magento layer declines them (LegacyReading), for the top-level template and for every include.
And it matches the filter where it can. {{widget}} gets what generateWidget gives a block -
type, the filter's store as store_id, and name as the block name - and {{store}} and
{{protocol store=}} answer for the filter's own URL model and the named store.
Where the filter renders nothing as well — a block class on its deny list, an adminhtml layout
handle (refused outright since 3.5.0), a {{config}} path not on Magento's list, a widget type
no widget.xml declares — this engine stays quiet, because falling back would only render the
same nothing twice. SilentDegradationTest pins both lists.
A rollout, one store view at a time:
bin/magento template:status # every store view's stage, and since when bin/magento template:diff --store=1 # what would Parser change, over every template? bin/magento config:set --scope=stores --scope-code=default system/template_engine/mode shadow # ... let real emails and pages render for a while ... bin/magento template:shadow:report --store=1 # exit 0: compared, and nothing diverged bin/magento config:set --scope=stores --scope-code=default system/template_engine/mode parser # ... and keep reading the report: Parser's sampled comparisons land in it too bin/magento template:shadow:report --store=1
A clean report — exit 0, with renders behind it — is the evidence for moving that store view
on to Parser. Moving back is the same config:set with legacy, and takes effect on the next
render. template:status shows the stage renders actually use, and says so when the value saved in
the database is not it: a stale config cache, or an override in app/etc/env.php.
Adoption goes through a plugin, not a preference. Emails render through
Magento\Email\Model\Template\Filter, CMS extends that, and Newsletter extends
Widget\Model\Template\FilterEmulate — all concrete classes DI instantiates directly, so a
preference for the framework base class never applies. The module's etc/di.xml declares the
plugin on the Email filter, and a plugin on a class applies to its subclasses, so that one
declaration covers every template filter a stock store renders with. Each render reads the
stage for its own store; under Legacy the plugin returns there, before any second render.
In Shadow, ShadowComparator renders the template through this engine as well and the plugin
returns the legacy result — so putting a store view in Shadow changes nothing a customer
sees. Each comparison is recorded in the cresset_template_shadow table, one row per store view
and template:
| Column | Holds |
|---|---|
template |
which template it was: email:sales_email_order_template, email:12, email:12/subject, newsletter:3 (and /subject) for a newsletter template, newsletter_queue:5 (and /subject) for a queued send, cms_block:7, cms_page:2, or unidentified:<filter class> |
agreed, diverged, refused, crashed |
how many renders had each outcome |
served, fell_back |
Parser mode: how many renders it served, and how many it handed to legacy |
first_seen, last_seen |
when it was first and last compared (UTC) |
last_divergence_at, renders_since_divergence |
when it last diverged or crashed, and how many renders have agreed or been refused since |
last_divergence, last_refusal, last_crash |
JSON describing the most recent of each |
A refusal is a construct this engine declines on purpose, a render the policy cut short
(a layout handle not allowed, say), or an exception the host raised while this engine
rendered; Parser mode falls back to legacy for it, so it counts as clean. A
crash is anything else the engine raised. Parser falls back for that too, but a crash is a
bug in this engine rather than a property of the template, so it counts against the template
like a divergence. "Clean since" is last_divergence_at, or first_seen for a template that
has never diverged.
Parser records into the same rows. A served render that was sampled counts as a comparison,
exactly like a Shadow one — so a divergence after the switch fails template:shadow:report the
way one before it did. A served render that was not sampled is only counted in served: it is
no evidence either way, so it leaves renders_since_divergence alone.
The template is named by where it came from — plugins on the email and CMS models register its identity as they hand its text to the filter — never by its content. No rendered output is stored either: a divergence is described by lengths, the first differing byte, and the policy violations and legacy incompatibilities behind it, because a rendered email holds a customer's name and address.
Comparisons are counted in memory and written in one statement at the end of the request, or every 100 templates or 60 seconds in a long-running process. A failed write is logged once as a warning and never affects the render.
One render is deliberately skipped, and one is adjusted before the diff. A child template —
anything the filter reaches through {{template}} — always goes to the filter, in every stage,
and is never compared. The filter only renders a child while rendering its parent (this engine
loads includes itself, so a parent it serves never reaches one), and it is skipped because the filter defers a directive it
cannot finish in a child by emitting a signed placeholder for the parent to resolve, and the
signature is random per render. This engine records that deferral structurally instead, so a
child's output can never be byte-equal to the filter's, whatever either engine does. The
parent's comparison covers the same content.
The adjustment: this engine's output goes through the subject's own applyInlineCss() before
the diff, because the legacy result it is being compared against is a finished document and this
engine defers that step to its host.
Measured on a stock store, rendering the 48 stock email templates through the model that sends them with the plugin live: that skip, that adjustment and the port wiring above take them from 203 engine failures and 118 reported divergences to zero of both.
Directive surface
The full stock surface is implemented. Directives needing nothing from the host are built in; the rest go through narrow ports, which is what keeps the engine free of a Magento dependency and unit-testable.
| Directive | Port | Magento implementation | Guard |
|---|---|---|---|
var, if, depend, for, else |
— | built in | — |
inlinecss |
— | built in | PathGuard; structured deferral, never emitted as text |
trans |
Translator (optional) |
PhraseTranslator |
the result is escaped, text included; a built-in handler renders it even with no port wired |
block |
BlockRenderer |
LayoutBlockRenderer |
type checked before instantiation, resolving DI preferences and virtual types; output= is an allowlist |
widget |
WidgetRenderer |
TypeCheckedWidgetRenderer |
same, against Widget\Block\BlockInterface; optional type allowlist |
template |
TemplateLoader |
ConfigTemplateLoader |
config-path allowlist; include cycles, depth and total count all bounded |
layout |
LayoutRenderer |
AllowlistedLayoutRenderer |
handle allowlist required, area restricted to frontend/adminhtml |
config |
ConfigReader |
AllowlistedConfigReader |
Magento's Variables::getAvailableVars() allowlist, failing closed |
customvar |
CustomVariableReader |
VariableCustomVariableReader |
identifier-shaped codes only |
store, media, view |
UrlBuilder |
StoreUrlBuilder |
PathGuard on the path and on forwarded parameters like _direct: no traversal, scheme, absolute or protocol-relative path, and no markup delimiter |
protocol |
UrlBuilder |
StoreUrlBuilder |
a host/path shape check, not PathGuard — it blocks schemes and protocol-relative URLs but permits .., which cannot escape a host |
css |
StylesheetLoader |
AssetStylesheetLoader |
PathGuard |
{{var this.getUrl(...)}} |
TemplateUrlBuilder (optional) |
TemplateModelUrlBuilder |
the receiver has to be a template model, and the store argument comes from the scope rather than the template; PathGuard on the route AND on _direct, which reaches the base URL unfiltered |
Three of these change behaviour for the plain-text part of an email, exactly as the
filter's own implementations do:
{{customvar}} reads a variable's text value rather than its HTML one, and {{css}} and
{{inlinecss}} render nothing at all. Tell the engine which it is with
Context's $plainText, or — behind Magento — with setPlainTemplateMode() on the adapter,
which is the name AbstractTemplate::getProcessedTemplate() already calls.
The last row is not a directive. Magento's StrictResolver maps every getFoo() to
getData('foo') except one: getUrl on an AbstractTemplate is really invoked, with its
arguments parsed and its $store argument overwritten by the scope's. That single exception is
where every "log into your account" link in every stock Magento email comes from, so it is
reproduced — as a port, so a host that does not want it simply does not wire it and gets
getData('url').
Magento's own extension points
Magento is extensible in two places the template language reaches. SimpleDirective\ProcessorPool
registers a named directive, so a module adding mydir makes {{mydir "v" p=1}}body{{/mydir}}
render on that store; DirectiveProcessor\Filter\FilterPool registers a modifier.
{{mydir}} is implemented. The engine asks the store's pool what it registered, teaches
those names to the parser, and renders them through CustomDirectiveRenderer — the value, the
parameters with $name resolved, the body already rendered, and the modifiers the template
named. Measured byte-identical to the filter across the void form, the paired form, $-valued
parameters, an escaped quote in the value, and a rendered body.
That needed a third directive kind. SimpleDirective's pattern ends
(?:(?P<content>.*?){{\/(?P=directiveName)}})? — an optional, lazily matched body — so one
registration gives a template both {{mydir "v"}} and {{mydir}}body{{/mydir}}, which
neither of the other kinds can express: {{if}} without its closer is an error and {{var}}
with one is a stray tag. A name registered this way is a block when it is closed and a void
directive when it is not.
The laziness matters: nesting one of these in itself is a legacy fatal, because the body ends at the inner closer and strands the outer one. This engine refuses that rather than rendering the structure as written.
Applying the modifiers is the host's job, because the rule belongs with the registry: a
template naming any modifier suppresses the processor's defaults, so {{mydir "v"|raw}}
applies nothing at all and comes out unfiltered, while {{mydir "v"}} goes through
getDefaultFilters().
Modifiers are not a gap, though the registry makes them look like one. A FilterPool
entry never reaches {{var}} on any surface this package replaces:
Email\Model\Template\Filter::varDirective uses its own $_modifiers map and skips a name
that is not in it, and the CMS and newsletter filters inherit that override. Measured on
a store registering foofilter, {{var x|foofilter}} renders ab<c> on both sides — the
modifier is skipped either way, which is the documented unknown modifiers quirk. A
FilterPool entry only ever reaches a SimpleDirective, and those this engine now renders
itself, applying the modifiers through the pool.
It would matter to a host rendering through a bare Framework\Filter\Template, whose
VarDirective does go through the pool. Nothing in this integration does.
The check and diff commands ask the store what its pool holds and report any directive
whose port a host has not wired.
There is a third, older way, and this engine does not support it: a module prefers or
subclasses a filter and adds a public fooDirective() method. The legacy filter dispatches
{{foo}} to it by reflection; this engine never dispatches by reflection, by design, so in
compatible mode an unhandled {{foo}} comes back as its own text. check finds these without
calling them — it resolves the store's filter classes through the ObjectManager and reports
any *Directive method a non-Magento class declares, including an override of a stock one —
and warns on every template that uses one.
Per-render capability policy
Capability belongs to the template, not the application. A stock transactional email and a merchant-edited CMS block reach the same filter and need different trust levels, which a DI-time allowlist cannot express because it is fixed for the whole install.
The default is restrictive. {{block}}, {{widget}} and {{layout}} turn template text
into a PHP class being loaded and constructed, so they are refused unless granted, even when
the host has wired their ports. The safe set is enumerated rather than derived, so a directive
added later defaults to denied.
// Grant one capability, narrowed to specific classes. $policy = RenderPolicy::restricted() ->alsoAllowing(['block']) ->withAllowedBlocks([\Magento\Sales\Block\Order\Email\Items::class]); // Cut the surface down further: substitution and conditionals, no reach into the host. $policy = RenderPolicy::allowing(['var', 'if', 'depend']); // Or opt out entirely, which is the legacy filter's posture. $policy = RenderPolicy::unrestricted(); $context = new Context($variables, $policy); $html = $engine->render($template, [], $context); foreach ($context->violations() as $v) { $logger->warning($v->describe()); // policy refused block "..." (line 4, column 12) }
The signature is
render(string $source, array $variables = [], ?Context $context = null, ?RenderPolicy $policy = null).
A Context carries its own variables and policy, so passing one alongside either of the
others raises rather than picking a winner — a caller tightening a render by adding a policy
argument would otherwise get no error and no policy.
The allowlist is checked before the port, so a refused class is never constructed.
{{widget}} shares the block allowlist, since a widget is a block by another name.
Wiring {{widget}} for an email surface adds a capability that surface did not have.
Email\Model\Template\Filter and the newsletter filter both extend
Framework\Filter\Template, which has no widgetDirective at all — so {{widget type="…"}}
in an email template renders as its own text today, and with the port wired it becomes block
instantiation chosen by template text. That may be exactly what you want; it is not something
to acquire by accident, so wire that port per surface rather than globally. diff says so
when it sees it rather than reporting the difference as the engine's.
The policy is consulted only after a handler is found. It can therefore remove a capability the host granted, but it never changes the output for a directive nobody wired up, which would break compatible mode's parity.
A violation renders nothing and is recorded rather than thrown: a policy violation should not
take down an order email, but it must not pass unnoticed either. For template validation or
CI, Options::withFailOnPolicyViolation(true) makes it fatal. A nested {{template}} inherits
the policy, so an include cannot widen it.
A policy also carries the nesting and include bounds, which are per-render for the same reason — see Nesting and include bounds.
Two further properties are deliberate.
- A directive that needs a port and has none stays unregistered, so the host grants
capabilities one at a time rather than inheriting the whole surface.
{{trans}}is the exception: it has a built-in handler and renders with or without aTranslator, since substitution needs nothing from the host. - The guards are in the handlers, not the ports.
PathGuardand the identifier checks run before a port is called rather than being left to each implementation, so a host cannot forget one;FullDirectiveSurfaceTestasserts the port is never reached for rejected input. Refusing after the fact is the mistake that madeBlockFactoryexploitable.
The legacy filter has no such guards: mediaDirective is literally
getBaseUrl(MEDIA) . $params['url'], and protocolDirective is
$protocol . '://' . $params['url'].
Compatible mode
TemplateEngine::compatible() reproduces the legacy filter's observable rendering, so it can
be switched on without changing what customers see.
Parity is measured against the real filter. tools/record-legacy.php runs an unpatched
Magento tree over a corpus and records what it produced; it calls Magento's own Escaper
rather than reimplementing it, because reimplementing the escaper once made the measurement
circular.
4788 cases recorded. 804 are constructs the legacy filter cannot render at all, 843 put the two engines on surfaces that cannot be compared, and 307 are shapes compatible mode refuses on purpose. Over the remaining 2834 cases, where both engines render, output is byte-identical.
That corpus is recorded from a filter built out of a handful of files and no application, so
the directives it can compare are the six the base Framework\Filter\Template implements:
var, if, depend, for, trans and else. The other twelve — store, media, view,
protocol, block, widget, layout, config, customvar, template, css, inlinecss
— are recorded separately against a real store by tools/record-store-ports.php, and what
is replayed for them is the tape: every question the engine asked its ports and the answer it
got. The port boundary is where this engine's responsibility ends, which makes the tape exactly
its observable decisions — what it let through, what it refused by never asking, what it
forwarded alongside. A tape needs no store, so it is a fixture rather than a manual check.
That split matters because it is where the bugs have been. Until those twelve were recorded,
six directives out of eighteen were actually compared, and every security defect adversarial
fuzzing has found in this package lived in the other twelve. Deleting the fix for the live
javascript: scheme now fails twelve store-tape cases.
Agreement with the filter is asserted, not merely noted: legacy is a recorded constant and
the candidate is recomputed from the tape each run, so the store cases that agreed when
recorded have to keep agreeing, offline, with no store
(StorePortParityTest::testTheAgreementSetHasNotShrunk). The count is pinned too — otherwise a
guard that starts refusing something the filter renders just leaves a smaller agreeing set and
every remaining assertion still passes.
Every directive but one now has an asserted comparison against the filter somewhere. The
exception is {{for}}, which is a declared divergence for the reason
given below. Two caveats:
{{layout}}'s corpus cases agree vacuously — the base filter has nolayoutDirectiveand that test engine has no port, so both sides emit the directive verbatim. Its real comparison is in the store recording, against a store with sample data and real orders in it: all five handles the stock sales emails use render between 289 bytes and 2.3KB of item table and agree byte for byte.{{widget}}cannot be compared on the email surface at all, that filter having nowidgetDirective; it is compared on the CMS surface, which does.
Take that as measured, not proven. Every round of adversarial fuzzing so far has found a new class of divergence, and the honest reading is that the corpus bounds what is known rather than what is true. Two properties are asserted absolutely:
- Nothing the legacy filter crashes on is rendered here. A construct the old filter died on is one nobody has ever seen the output of, so rendering it would be inventing behaviour, not reproducing it.
- Where both render, they agree byte for byte — for every case in the corpus except the declared divergences named below, each of which is recorded as a case rather than left out of one, so the disagreement is measured and pinned rather than avoided.
Everything else is a superset of refusals, enumerated below. Before switching a store over, put it in Shadow and let it compare your own templates; the corpus cannot contain them.
Quirks it reproduces:
| Quirk | Legacy behaviour |
|---|---|
| truthiness | resolve(...) == '', so on PHP 8 0, '0', 0.0 and [] are truthy — and false and null are not |
| partial paths | member access is only attempted on an array or DataObject parent, so a scalar parent yields itself ({{var store.frontend_name}} renders the store) |
| missing keys | an array parent with a missing key yields nothing, not the parent |
| arrays | cast to the literal string Array |
| no variables | directives pass through verbatim, which is the template-validation path |
| getter keys | getAddress1() reads address_1, because a run of digits is its own segment |
| member access | only through getData(); a real getter is never called, bar the one exception below |
| unknown modifiers | skipped, so {{var x|typo}} renders raw |
| unknown escape types | escape:none returns the value unescaped |
| percent decoding | variable paths and parameter blobs are rawurldecoded before being parsed, so {{var a%2Eb}} is {{var a.b}} and a%3D1 is a parameter |
| parameter values | a value is a literal unless it starts with $; a word with no = is dropped; key= at the very end of a directive has the value = |
| trans arguments | an integer key stands for the NEXT placeholder, so {{trans "%1" 1=$x}} fills in %2 and leaves %1 standing |
| trans escaping | the default modifier is escape and it applies to the whole result, translated text included; |raw turns it off |
| trans bodies | the body must be a quoted string with whitespace before its arguments, and the split on | happens first — so {{trans "a|b"}} renders nothing at all |
| unknown paired names | {{foo}}x{{/foo}} is one construction to the regex, and an unknown one comes back verbatim — any letter case, mismatched-case closers included. Reproduced when the body holds none of our directives |
Unknown modifiers and unknown escape types are reproduced only in compatible mode. Everywhere else they fail closed.
Quirks it does not reproduce:
-
A
{{that is not an opener is text, not a mangled construct..a{{{var color}}}— CSS with a directive pasted straight after the brace — renders.a{plus the resolved value here; legacy matches the whole span with an empty name, rescues it throughSimpleDirectiveat the inner offset, and prints{{var color}}}. Same for{{A{{var x}}: the stray braces are text and the real directive resolves. -
A name is the name that was written.
[a-z]{0,10}is greedy but backtracks to satisfy the closing backreference, so{{iframe}}re-reads as{{if}}with the conditionramethere and swallows everything to the next{{/if}}. Hereiframeis an unknown directive. -
{{else }}is a typo, and is reported. The legacy pattern spells the divider as the literal{{else}}, so a trailing space makes it text in the true branch and the{{if}}loses its false branch entirely. Accepting it as a divider silently flips which branch renders; reproducing legacy buries the typo. Neither is worth having, so it raises. -
A quoted parameter may contain
{{and}}.{{trans "a {{b}}"}}renders the text here. The legacy filter cannot express it — its lazy(.*?)}}stops at the first closer wherever it falls, so the directive gets a text it cannot parse and the remainder becomes literal output. A lexer has no reason to inherit that, so this is the one place the engine does more than the filter rather than less.{{trans "a }}b"}}is the same divergence from the other side:a }}bhere,b"}}there. Both are recorded as corpus cases with the equality dropped, rather than kept out of the corpus. -
Anything inside a
{{for}}body.ForDirectivedoes not render its body — itstr_replaces each construct with the variable resolution of that construct's parameter text — so nothing in there ever reaches a directive processor, and nothing in there can be a legacy fatal.{{}},{{/if}},{{var.a}}and{{var1 x}}are reads of'',/if,.aand1 x; every one resolves to nothing and renders. This engine parses the body properly instead, so those constructs come out as text. The exemption stops exactly where the filter's does: an unclosed{{for}}matches no loop pattern, anything after the close is outside it, and a nested loop strands the outer{{/for}}— which is why the filter cannot express a nested loop at all, and why this still refuses one. -
One missing brace, at top level.
Hi {{var name}, bye {{var name}}reads here as text, then the intact directive — the same reading as{{A{{var x}}. The legacy regex is lazier and less fussy:(.*?)}}swallows the broken opener, everything after it and the intact directive too, out to whatever}}it reaches first, so the line renders asHiand the rest is gone. Preserving the visible text and rendering the directive that is actually well-formed is the better answer, so the divergence is deliberate and the corpus records it as one. Where that swallowed stretch would cost the filter a paired directive — leaving{{if}}with no body, or{{/if}}with no opener — the filter raises a TypeError instead of rendering, and those are refused here rather than rendered, which is what keeps "nothing the legacy filter crashes on is rendered here" intact. -
A host that raises.
{{block class="No\Such\Klass"}},{{template config_path=""}}, a layout handle that cannot be built: the port raises and the exception comes out ofrender(). The engine does not catch it, because a host failing is not something the engine can meaningfully paper over — a misconfigured block that silently vanished from every email would be worse than one that says so.TemplateFilterAdapterthen degrades exactly asEmail\Model\Template\Filter::filter()does, catching\Exceptionand substitutingError filtering template: …, so a store behind the Magento integration sees what it sees today. Its own diagnostics are exempt and re-thrown, because those are the product. Callrender()directly and you get the exception; that is the seam where a host decides its own policy.This is a genuine exception to that same absolute: the filter dies where this raises, and a caller that catches broadly renders where the filter died. The absolute is asserted over the constructs the filter itself implements, which is what the corpus records; a port raising is the host's failure, not a construct.
-
A fatal in a branch that is discarded. The legacy filter runs every directive processor over the whole source and collects the results before applying any, so a construct inside a false
{{depend}}is still evaluated by another processor's independent pass — and if it is a fatal, the render dies. This engine walks a tree and short-circuits, so a discarded branch is never evaluated and the template renders. It takes two nested blocks of different names for a processor's pass to reach inside, plus a construct that is genuinely fatal for the values in scope, and it is the one accepted gap in "nothing the filter crashes on is rendered here". -
A value re-parsed as source. Never, in any mode. This one is structural rather than a mode setting, which is why it is not in the table above.
-
Reflection dispatch of arbitrary filter methods. There is none here; every directive reaches a named handler.
-
{{layout}}without an allowlist. A layout handle decides which blocks get built, so theLayoutRendererport takes the handles it may render and refuses the rest. The module allows the five the stock sales emails use (AllowlistedLayoutRenderer::STOCK_EMAIL_HANDLES, inetc/di.xml); add a module's own there. A refused handle is recorded as a policy violation rather than rendering an unexplained nothing, so Shadow reports it and Parser mode hands that render to the legacy filter. -
{{var x|modifier}}rendering empty. That is a defect inFramework\Filter\Template, whosevarDirectivehandsVarDirectivea legacy-shaped construction so the expression resolved is" x|raw".Email\Model\Template\FilteroverridesvarDirectiveand handles modifiers correctly, and that is the filter templates actually render through.
Which legacy filter?
Mage-OS shipped the StyleSmuggler hardening
in 3.5.0. Part of it, Template\DirectiveOutputNeutralizer, encodes {{ in resolved directive
output so it can never be re-parsed by a later pass — which changes observable rendering:
{{var a}} with a = '{{block class=Evil}}'
before the hardening: [{{block class=Evil}}]
after: [{{block class=Evil}}]
Both trees are in the field, so compatible mode targets either. It follows the current filter by default; for a tree from before the hardening:
TemplateEngine::withOptions(Options::compatible()->withOutputNeutralizer(false));
The corpus records both, and 751 cases carry a second expectation for the older behaviour. This engine needs none of it — a value is never re-parsed here whatever the setting — so the flag does nothing outside compatible mode.
What it refuses
This engine refuses a strict superset of what the legacy filter refuses. Both halves of that are asserted, and they are different claims.
Every construct the legacy filter cannot render is refused. Twelve conditions, each verified against the real filter:
| Condition | Example | Why legacy dies |
|---|---|---|
| same-name nesting | {{if}} in {{if}} |
lazy body hands on an unclosed inner directive |
| unclosed block | a{{if a}}b |
the per-directive re-match finds nothing, passes null on |
| stray closing tag | a{{/if}}b |
same |
| name not starting with a letter | {{100}}, {{ var x }}, {{}} |
no name captured, ProcessorPool::get(null) |
| name split by punctuation | {{if_a}} |
the name is a greedy [a-z]{0,10}, so this is if with the parameter _a |
| padded closing tag | {{/if }} |
CONSTRUCTION_IF_PATTERN allows the space, the closing backreference does not |
| modifier arguments | {{var a|nl2br:x}} |
passed through to nl2br(), a TypeError on $use_xhtml |
| member call on an array | {{var a.getB()}} where a is an array |
->getData() on an array |
|nl2br on a non-string |
{{var a|nl2br}}, a=0 |
nl2br() under strict_types |
|escape:htmlentities on a non-string |
{{var a|escape:htmlentities}}, a=0 |
htmlentities() under strict_types |
|escape:url on a non-string |
{{var a|escape:url}}, a=0 |
rawurlencode() under strict_types |
an array holding a non-Stringable object |
{{var a}}, a=['o'=>new stdClass] |
Escaper::escapeHtml recurses and casts each element — and escape is the default modifier, so no modifier need be written |
Nesting is bounded by repeated names, not depth. A directive cannot contain itself at any
distance, but three distinct names nest fine: all six orderings of {{if}}, {{depend}} and
{{for}} render three deep on the real filter — but only when {{for}}'s collection is a
list of arrays, since its body is scanned rather than rendered. Give it anything else and the
construct comes back verbatim, and nothing nests through it. LegacyNestingReportTest asserts
only that this engine reports no incompatibility for those shapes; it never runs the filter.
The {{100}} row is narrower than it looks. CONSTRUCTION_PATTERN is case-insensitive, so
{{Password}} and {{Forgot Your Password?}} do capture a name, fail to resolve, and come
back verbatim. Those render here too.
{{if}} nested inside {{if}} - the legacy filter cannot nest a directive in itself: its
lazy body match ends the outer construct at the INNER closing tag, so what renders there
is not the structure written here
on line 1, column 10:
1 | {{if a}}X{{if b}}Y{{/if}}{{/if}}
| ^
hint: this renders here but not on the legacy filter; unset
Options::$refuseLegacyIncompatible to allow it
(Wrapped here; the engine prints the summary and the hint each on one line.)
Four families of construct are refused that legacy does render, 13 spellings in all. Each is a place where legacy's regex does something by accident that this parser will not build in:
| Shape | What legacy does |
|---|---|
{{var.a}}, {{var_a}}, {{var2 a}}, {{depend.a}}Y{{/depend}}, {{VAR.a}} |
punctuation after a name is read as a parameter separator, which makes {{var.a}} a live variable read — case-insensitively, so {{VAR.a}} too. {{depend.a}} needs its body and closing tag to render; without them it is a TypeError there too |
{{if}}{{if}}{{/if}}, its {{depend}} twin, {{if}}{{depend}}x{{/if}} |
nesting collapses to '' by accident of the lazy body match |
{{var a}}Y{{/var}}, {{Wrap}}A{{if a}}B{{/if}}C{{/Wrap}} |
the optional closing group swallows a body — for {{var}} a body it silently drops, and for an unknown name one it hands back verbatim. What happens to a directive inside that verbatim span depends on which directive it is (an inner {{var}} is resolved, an inner {{if}} is not), so a span containing one is refused rather than guessed at |
[Hi {{var a}, bye {{var a}}], and two more like it |
the fourth family is not a decision of its own: one missing brace makes legacy's lazy match run on to the next construct's closer, so the directive it swallows is never evaluated — and a value this engine refuses on is one legacy never looked at |
LegacyParityTest asserts the two halves separately, because they are different claims:
testEveryLegacyFatalIsRefused allows no exceptions, and
testExtraRefusalsAreOnlyTheDocumentedShapes pins all thirteen against the observed set, so
the list cannot grow without a test failing.
Compatible means bug-for-bug. Use lenient or strict if you want the fixes. It is also what
keeps a rollback to the legacy filter possible. To render the
refused constructs anyway and log which templates did it, opt out:
$context = new Context($vars); $engine = TemplateEngine::withOptions( Options::compatible()->withRefuseLegacyIncompatible(false) ); $engine->render($template, [], $context); foreach ($context->incompatibilities() as $i) { $logger->info('no longer runnable on the legacy filter: ' . $i->describe()); }
{{for}} is a deliberate divergence
Legacy's ForDirective does not render its body. It runs preg_match_all over the raw text,
resolves each match as a variable name and str_replaces the result in. So the body is never
escaped, a nested {{if}} is resolved as if it were a variable name, |raw becomes part of a
property name, an item that is not an array is skipped, and a non-iterable collection makes the
whole construct come back verbatim.
Reproducing that faithfully would mean not escaping loop variables, which is the class of
defect this package exists to remove. {{for}} is therefore recorded and required to render
safely, but is not held to rendering-equality with legacy.
The one part of it that is a feature rather than a defect is kept: ForDirective injects a
loop variable carrying index, and so does this engine — zero-based, as it is there. A
template that prints {{var loop.index}} keeps working, and does not quietly start printing
nothing.
Strict mode
A template that cannot be parsed, names a directive that does not exist, or reads a variable that is not in scope is a mistake worth surfacing while the template is still being edited:
Unknown variable "custmer_name" in {{var custmer_name}}
on line 1, column 6:
1 | Dear {{var custmer_name}},
| ^
2 | your order is ready.
hint: did you mean {{var customer_name}}?
{{if}}, {{depend}} and {{for}} test truthiness, not existence, so a variable that does
not resolve at all is reported too. Silently taking the false branch is how a typo'd condition
goes unnoticed:
Unknown variable "custmer" in {{if custmer}}
on line 1, column 1:
1 | {{if custmer}}x{{/if}}
| ^
hint: did you mean {{var customer}}?
A variable that does resolve is never an error; it is tested for truthiness. {{for}} also
reports a collection that resolves to something non-iterable.
The engine uses standard PHP truthiness. The legacy filter tests resolve(...) == '', which
on PHP 8 makes 0, 0.0, '0' and [] all truthy — so {{if qty}} runs its true branch
for a zero quantity.
Only 0 and 0.0 are new. 0 == '' was true on PHP 7 and became false in PHP 8 — the
"Saner string to number comparisons"
RFC — so those two silently flipped on upgrade; '0' and [] never equalled '' on either
version and have always been truthy here. false and null do equal '', so the filter
takes the false branch for them, which is what this engine does anyway.
The live half of that is pinned rather than asserted in prose:
KnownDivergenceTest::testTheComparisonLegacyTruthinessRestsOn checks the comparison itself on
every supported version, and LegacyParityTest checks that the branch it predicts is the
branch the filter was recorded taking. If PHP changes loose comparison again, a test says so.
The PHP 7 half is history — this project supports 8.3 and up, so it is not re-measurable here.
Nesting and include bounds
The grammar nests to any depth, unlike the legacy filter, where each directive has its own
regex and so cannot contain itself. {{if}} inside {{if}} is a fatal TypeError in stock
Magento; only distinct names nest, which is why core templates pair {{depend}} with
{{if}} and cap out at two levels.
Depth is bounded by policy rather than by accident, defaulting to 3 and settable per render:
// Engine-wide default. TemplateEngine::withOptions(Options::strict()->withMaxNestingDepth(5)); // Or for one render, since depth is a property of the content, not the installation. $policy = RenderPolicy::restricted()->withMaxNestingDepth(4); $engine->render($template, $variables, null, $policy);
Nesting limit exceeded: {{if}} would be 4 levels deep, limit is 3
on line 1, column 29:
1 | {{depend a}}{{if b}}{{if c}}{{if d}}X{{/if}}{{/if}}{{/if}}{{/depend}}
| ^
hint: enclosing directives are {{depend}} > {{if}} > {{if}}; raise it with
Options::withMaxNestingDepth() if intentional
An included {{template}} inherits the render's bound rather than the engine default, so a
nested template cannot buy itself more depth than its caller had. The bound applies in lenient
mode too: it limits input complexity rather than syntax tolerance, so deeply nested input is
refused rather than recovered. Includes are separately bounded against cycles, against depth,
and against total count, since five levels of fan-out is not five renders.
The command line tool
vendor/bin/template-parser repl # try directives interactively vendor/bin/template-parser check # will these templates render? vendor/bin/template-parser diff # do they render the same as today? vendor/bin/template-parser shadow:report # what did Shadow mode measure? vendor/bin/template-parser shadow:clear # forget it, for one template or store view vendor/bin/template-parser status # which stage is each store view at?
With the module installed the same commands are part of bin/magento, under template: —
bin/magento template:check, template:shadow:report and so on — and take the application
bin/magento already booted.
Run from inside a store it finds app/etc/env.php, boots Magento and wires every port it
can, so {{block}}, {{media}}, {{config}} and the rest resolve against the real
application. Run anywhere else it degrades to the built-in directives and still checks syntax.
$ template-parser repl
posture compatible - reproduces the legacy filter, refuses what it could not render
store connected
18 directives wired
compatible> {{media url="wysiwyg/banner.jpg"}}
http://shop.example/media/wysiwyg/banner.jpg
compatible> {{media url="../../../app/etc/env.php"}}
(empty)
compatible> :set customer_name=Ada
customer_name = string 'Ada'
compatible> :posture strict
strict - unknown directives and variables are errors
strict> {{var custmer_name}}
Unknown variable "custmer_name" in {{var custmer_name}}
on line 1, column 1:
1 | {{var custmer_name}}
| ^
hint: did you mean {{var customer_name}}?
:help lists the rest — :set, :vars, :store, :stores, :directives, :posture.
Values are typed, which matters more here than it might elsewhere:
compatible> :set qty=0 int 0
compatible> :set label="0" string "0" quoting forces a string
compatible> :set xs=[1,2] array JSON
compatible> :set flag=true bool also false, null, 1.5, bare words
{{if qty}} answers differently for int 0 under each mode — compatible reproduces the
legacy filter's == '' test and calls it truthy, strict uses standard PHP truthiness and
calls it falsy. :types explains it in the REPL.
Checking templates
check renders everything it can find and says what stops it, with advice rather than just a
diagnostic. --source picks where to look: codebase (files in app/code, vendor, app/design),
email, cms, newsletter, or all.
template-parser check --source=codebase --posture=strict --fail-on=error
template-parser check --source=all --format=json > findings.json
The exit code is what makes it useful in CI: non-zero at or above --fail-on, which defaults
to error so a first run over a decade of templates is not a wall of red.
--posture picks how strictly the engine reads (see Modes); it defaults to
compatible, the posture Parser mode runs. --mode is the old spelling and still works for one
release, with a warning on stderr.
Diffing against the filter you run today
diff renders each template through both engines and reports the ones whose output
differs. This is the number that decides whether a migration is safe, and it needs a store —
the templates that matter are in a merchant's database, not the repository.
template-parser diff --source=email --store=1 template-parser diff --source=all --format=json --fail-on-divergence
With --store=N and the default posture, this answers "what would switching this store view to
Parser change?" — over every template now, where Shadow mode measures it on live renders as
they happen.
--store sets the store context, so {{trans}} resolves in that store view's language and
{{config}} in its scope. It emulates rather than just switching the store id, because
translations and design follow the emulation and not the id. Left out, the current store is
emulated anyway: a CLI process has a store but no theme, and without one {{css}} comes back
as a LESS compilation error on both sides.
Both sides are rendered the way Magento renders them — through the email template model for
email and newsletter templates, through the CMS filter provider for CMS content — so the
comparison is of the two engines and not of two harnesses. The variables Magento builds for
the legacy render (store, logo_url, this and the rest) are the variables this engine is
given, and the CSS inlining that runs after a render runs after both.
{{layout}} is the exception, because a layout handle decides which blocks get built and
template text is not a trustworthy source for one. Outside a store, nothing is allowed by
default, which makes every stock sales email report as a difference. --allow-layout-handle
names the ones a run may render, and stock-email is shorthand for the five the stock sales
emails use - the five the module allows in a store:
template-parser diff --source=codebase --allow-layout-handle=stock-email
Reading what Shadow measured
shadow:report summarises the cresset_template_shadow table per store view: templates and
renders compared, how many diverged, were refused or crashed, and since when the store view has
been clean. Each diverging template is listed with its causes and the diff command that
reproduces it; refusals are listed too, but never fail the report, because Parser mode falls
back to legacy for them.
bin/magento template:shadow:report --store=1 bin/magento template:shadow:report --template="cms_block:*" --since="-7 days" bin/magento template:shadow:report --format=json
The exit code is the gate for moving a store view on:
| Exit | Meaning |
|---|---|
| 0 | compared, and nothing diverged or crashed (since --since, when given) |
| 1 | something diverged or crashed |
| 2 | nothing in scope was compared — Shadow is off, or nothing has rendered yet |
2 is separate because "no divergences" and "no data" look the same in a count of failures, and
only one of them is evidence. After fixing a template, --since counts only what diverged after
the fix; shadow:clear --template=cms_block:7 deletes its history instead, and needs
--store, --template or an explicit --all.
Inside n98-magerun2
magerun lists Magento's own commands, so once the module is enabled the template:* commands
are there too, with nothing to register:
n98-magerun2 template:check --source=email
Before the module is enabled — sizing a migration on a store that has not installed it — use
the standalone vendor/bin/template-parser instead, which finds the store from the working
directory. (0.2 shipped an n98-magerun2.yaml for this; with the module installed it listed
every command twice, so it is gone.)
With bougie
bougie runs a project's PHP toolchain in a pinned environment, and this package's own CI uses it. If you do too:
bougie tool run cresset/module-template-parser check --source=codebase bougie run -- vendor/bin/n98-magerun2 template:diff --source=email
Speed
Faster than the legacy filter where it matters and slower where it does not, neither of which was a goal.
tools/benchmark.php renders the same templates through both engines, each constructed once
outside the timing loop, since in Magento both are DI instances reused across a request. It
times only templates where the two produce byte-identical output — a speed number over
templates where one side is doing less work is not a speed number. Its 48 are the 45 harvested
corpus templates plus four synthetic ones, less the one the filter crashes on; not the 48 stock
email templates measured further up, which are a different set that happens to be the same size.
iterations per template: 200
PHP 8.4.24
template legacy compatible lenient ratio
------------------------------------------------------------------------------------------------
Wishlist__view__frontend__email__share_notification 4.52ms 6.46ms 5.26ms 1.43x
SendFriend__view__frontend__email__product_share 4.71ms 5.82ms 4.77ms 1.24x
ProductAlert__view__frontend__email__price_alert 2.09ms 2.43ms 2.30ms 1.16x
Customer__view__frontend__email__account_new_confirm 9.57ms 5.59ms 5.10ms 0.58x
Customer__view__frontend__email__password_reset_conf 9.76ms 5.35ms 5.26ms 0.55x
Customer__view__frontend__email__password_new 9.50ms 5.20ms 5.02ms 0.55x
synthetic: variables 68.24ms 60.57ms 58.42ms 0.89x
synthetic: loop 127.01ms 110.28ms 104.13ms 0.87x
synthetic: conditionals 89.56ms 69.94ms 64.32ms 0.78x
synthetic: plain text 0.40ms 0.31ms 0.21ms 0.77x
TOTAL (48 templates) 733.44ms 578.30ms 507.99ms 0.79x
same output as legacy: 48 of 48 timed templates
per render: legacy 76.4us, compatible 60.2us (-16.2us)
of which parsing: 271.42ms of 507.99ms lenient (53%), evaluation 236.56ms
peak memory: 4.0 MB
excluded from timing:
one side raises 1
The ratio is stable across runs at 0.79x. The three flat templates are slower here, by 15%
to 43%: on a template with no nesting the legacy filter's regex pass is cheaper than a lex,
a parse and a tree walk, and there is nothing to win back. The win is the nested ones —
password_new at 0.55x — and it is structural rather than clever: the legacy filter runs a
regex pass per directive processor over the whole string and then re-runs the entire engine
over substrings to handle nesting, so a nested template is scanned several times. This lexes
and parses once.
About 1.3x faster overall, and the spread is the tell. Half the remaining time is parsing, and that half is cacheable — an AST keyed by template hash would remove it. The legacy filter's regex work is not cacheable the same way, since it interleaves matching with resolution.
This section has read 0.56x, then 0.64x, and now 0.79x. The first change was a broken tool:
tools/benchmark.php referenced an unqualified Escaper that resolved to nothing, so every
template using |escape raised and was silently excluded — 27 templates timed instead of 48.
The second is the engine genuinely getting slower, by about 22% per render, which is the price
of the fidelity work: two hand-written scanners replaced by faithful ports of Magento's
tokenizers, $name parameter resolution on every directive rather than one, percent-decoding
on every variable path, and a guard on every route parameter. Legacy's own per-render figure
has not moved across any of it, at 76µs.
Three caveats. Both engines are timed on the same machine, same PHP, same run. One corpus
template is excluded because the legacy filter crashes on it — that is the one side raises
line. And neither side resolves {{template}} includes: legacy needs Magento's config and
this engine needs a TemplateLoader port, so legacy's include processor is stubbed to leave
the construct alone, matching an unregistered directive here. Without that the two fail
differently and 35 of the 48 templates, every Sales order and invoice email among them, drop
out of the comparison. Reproduce with MAGENTO_ROOT=/path/to/magento php tools/benchmark.php.
Status
Pre-1.0, not yet used in production, and the API may change.
Merchant templates live in databases and cannot be audited ahead of time, and the parity corpus bounds what is known rather than what is true — each round of adversarial fuzzing has found a further class of divergence. Run shadow mode over your own content before switching anything, and read what compatible mode refuses first: it is a superset, and constructs that render on the legacy filter are refused here by design.
Testing
composer install vendor/bin/phpunit
13010 tests. The parity corpus and the StyleSmuggler differential are the two that carry the argument:
LegacyParityTestreplays the 4788 recorded cases, so the differential runs anywhere with no Magento installation, and drift in compatible mode shows up as a failing case rather than a surprise in production.StyleSmugglerDifferentialTestasserts both halves of the vulnerability: that the recording really is the vulnerable behaviour, and that this engine executes nothing given the identical input. Without the first half, "nothing executed" could mean the engine is sound or that the payload was malformed.
See CONTRIBUTING.md for the corpus layout, how to re-record fixtures, and what the rest of the suite covers. CHANGELOG.md records what has changed and why it mattered; nothing has been released yet, so the public API may still move.
Repository layout
src/Lexer/ source -> tokens (conservative: prose stays prose)
src/Ast/ TextNode, DirectiveNode, RootNode
src/Parser.php tokens -> AST, lenient (recover) or strict (reject)
src/Evaluator.php AST -> string, explicit handler table
src/Context.php scope, policy and structured deferral
src/Magento/ adapters binding the ports to Magento
src/Console/ the CLI: commands, sources, and the Magento bridge
bin/ template-parser entrypoint
docs/ reference for the template language, generated from a live filter
tools/ differential, benchmark and fixture-recording scripts
License
OSL 3.0. See LICENSE.txt, and COPYING.txt for the notice.
The templates under tests/fixtures/corpus/ are not this package's source. They are copied
from Magento Open Source, remain copyright Magento, Inc., and are licensed OSL 3.0 and AFL
3.0; see LICENSE_AFL.txt.