feature #4917 Template runtime and block composition (fabpot)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Template runtime and block composition

This PR addresses the Symfony compatibility break from #4910

It introduces runtime composition of templates used as collections of named block renderers: The renderer provides an ordered set of unrelated templates. The first matching block wins, nested `block()` calls see the complete composed set, and `parent()` remains within the block’s own inheritance or `use` hierarchy.

This feature is going to be useful for more than just Symfony.

## Strong non-Symfony use cases

### Ibexa Core

**Project:** `ibexa/core`
**Feature:** CMS field rendering through `FieldBlockRenderer`

Ibexa maintains prioritized field templates, selects blocks such as `ibexa_string_field`, walks parent templates, constructs a block map and passes it to `renderBlock()`.

This is the strongest independent fit for `BlockChain`:

```php
$blocks = new BlockChain($twig, [
    $localTemplate,
    ...$projectFieldThemes,
    ...$vendorFieldThemes,
]);

return $blocks->renderBlock($fieldType.'_field', $context);
```

### Data-grid and listing renderers

The audit found the same broad mechanism in:

- `Prezent/prezent-grid`, `src/Twig/GridRenderer.php`
- `pawellen/listing`, `Renderer/ListingRenderer.php`
- `Braunstetter/data-grid-bundle`, `src/GridRendererEngine.php`
- `AnoDataGrid`, `DataGridExtension.php`

Their common feature is **layered grid themes**:

1. Configure default grid templates.
2. Add per-grid or per-view overrides.
3. Map a column type to a block name.
4. Walk template inheritance.
5. Merge or cache available blocks.
6. Render the selected cell, header or filter block.

Several accessed `unwrap()`, `getBlocks()` or `getParent()` directly; others passed manually assembled block maps into `renderBlock()` or `displayBlock()`.

## Adjacent use cases

The audit also found block-library patterns that could benefit if they grow into multi-template composition:

- **iTop:** plugin-contributed login blocks such as `login_input`, `login_submit`, `login_form_footer` and `login_links`; independently renders `body`, `script`, `ready_script` and `css`.
- **Email renderers:** independently render `subject`, `body_text` and `body_html` blocks.
- **Runtime theme overlays:** tenant branding, application skins, email themes, reports and configurable admin interfaces.
- **Extension-provided block libraries:** enabled modules contribute blocks such as `toolbar`, `field_text`, `dashboard_metric` or `login_footer`.
- **Testing and preview tooling:** render a block against an exact theme stack without generating a synthetic host template.

## Important negative finding

Shopware-style plugin inheritance, and similar Drupal or Sylius layering, are **not** considered a direct fit. Those systems expect `parent()` to call the next plugin override. `BlockChain` deliberately keeps `parent()` inside the defining template’s normal lineage.

Commits
-------

49f814ea26 Template runtime and block composition
This commit is contained in:
Fabien Potencier
2026-09-11 04:57:43 -07:00
12 changed files with 1013 additions and 7 deletions
+2
View File
@@ -6,6 +6,8 @@
* Fix the `matches` operator silently treating PCRE execution errors as non-matches
* Fix `TemplateWrapper::streamBlock()`, `TemplateWrapper::hasBlock()`, and `TemplateWrapper::getBlockNames()` omitting environment globals
* Fix exceptions from dynamic parent expressions escaping without template context
* Add the `BlockChain` class to compose blocks from multiple templates without using template internals
* Fix an output buffer leak when a parent block rendered in an expression throws in non-yield mode
* Add the `HtmlExtension::htmlAttrValue()` method to resolve a single HTML attribute value the way the `html_attr` function renders it
* Fix `html_attr` JSON encoding a `Stringable` value in a `data-*` attribute instead of using its string representation
* Add documentation comments to attach metadata to nodes (experimental)
+44
View File
@@ -68,6 +68,50 @@ If a template defines blocks, they can be rendered individually via the
echo $template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']);
.. caution::
Rendering a block on its own does not run the template body, so macros
imported at the top level of the template are missing and the block fails
when it calls one. Move the ``import`` or ``from`` tag inside the block.
Rendering the whole template first also makes them available, but they then
keep the value they resolved to during that render.
Composing Blocks
----------------
.. versionadded:: 3.29
The ``BlockChain`` class was introduced in Twig 3.29.
Use ``BlockChain`` when a form, CMS field, data-grid or similar renderer needs
to select blocks from several templates at runtime. Pass templates from the
highest to the lowest precedence::
use Twig\BlockChain;
$blocks = new BlockChain($twig, [
'admin_theme.html.twig',
$twig->load('application_theme.html.twig'),
'base_theme.html.twig',
]);
echo $blocks->renderBlock('field_row', ['field' => $field]);
Twig considers each template's blocks, blocks imported with ``use`` and parents
before moving to the next template. The first definition of each block wins.
``parent()`` follows the local inheritance or ``use`` hierarchy of the block,
while nested ``block()`` calls use the composed block set unless they name
another template.
``BlockChain`` composes blocks, not template bodies or macros. Because chained
templates share the composed block set, only combine templates that are trusted
to call one another's blocks. Chained blocks have the same top-level macro
import limitation as blocks rendered directly.
The optional third constructor argument provides variables used to resolve
dynamic parent expressions. Parent hierarchies are fixed when the chain is
created; create another chain when these variables change.
Streaming Templates
-------------------
+107
View File
@@ -0,0 +1,107 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig;
use Twig\Error\RuntimeError;
/**
* Composes blocks from several templates without composing their bodies or macros.
*/
final class BlockChain
{
/** @var array<string, array{Template, string}> */
private array $blocks;
private Template $template;
/**
* @param iterable<string|TemplateWrapper> $templates Templates ordered from highest to lowest precedence
*/
public function __construct(
private Environment $env,
iterable $templates,
array $context = [],
) {
$resolution = new BlockResolutionContext($env, $context + $env->getGlobals());
$blocks = [];
foreach ($templates as $template) {
if (\is_string($template)) {
$template = $env->load($template);
}
if (!$template instanceof TemplateWrapper) {
throw new \TypeError(\sprintf('Block chain templates must be strings or "%s" instances, "%s" given.', TemplateWrapper::class, get_debug_type($template)));
}
$current = $template->unwrap()->freezeLineage($resolution);
$this->template ??= $current;
do {
foreach ($current->getBlocks() as $name => $block) {
if (isset($blocks[$name])) {
continue;
}
if (!\is_array($block) || !isset($block[0], $block[1]) || !$block[0] instanceof Template || !\is_string($block[1])) {
throw new \LogicException('A block must be a method on a \Twig\Template instance.');
}
$resolution->assertOwns($block[0]);
$blocks[$name] = $block;
}
} while (false !== $current = $resolution->getParent($current));
}
if (!isset($this->template)) {
throw new \InvalidArgumentException('A block chain requires at least one template.');
}
$this->blocks = $blocks;
}
public function hasBlock(string $name): bool
{
return isset($this->blocks[$name]);
}
/**
* @return string[]
*/
public function getBlockNames(): array
{
return array_keys($this->blocks);
}
/**
* @return iterable<scalar|\Stringable|null>
*/
public function streamBlock(string $name, array $context = []): iterable
{
yield from $this->getBlock($name)->yieldBlock($name, $context + $this->env->getGlobals(), $this->blocks);
}
public function renderBlock(string $name, array $context = []): string
{
return $this->getBlock($name)->renderBlock($name, $context + $this->env->getGlobals(), $this->blocks);
}
public function displayBlock(string $name, array $context = []): void
{
$this->getBlock($name)->displayBlock($name, $context + $this->env->getGlobals(), $this->blocks);
}
private function getBlock(string $name): Template
{
if (isset($this->blocks[$name])) {
return $this->blocks[$name][0];
}
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->template->getTemplateName()), -1, $this->template->getSourceContext());
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig;
/**
* @internal
*/
final class BlockResolutionContext
{
/** @var \SplObjectStorage<Template, Template|false> */
private \SplObjectStorage $parents;
/** @var \SplObjectStorage<Template, Template> */
private \SplObjectStorage $frozen;
/** @var \SplObjectStorage<Template, true> */
private \SplObjectStorage $freezing;
public function __construct(
private Environment $env,
private array $context,
) {
$this->parents = new \SplObjectStorage();
$this->frozen = new \SplObjectStorage();
$this->freezing = new \SplObjectStorage();
}
public function getParent(Template $template): Template|false
{
if ($this->parents->offsetExists($template)) {
return $this->parents[$template];
}
$parent = $template->getParent($this->context);
if ($parent instanceof TemplateWrapper) {
$parent = $parent->unwrap();
}
return $this->parents[$template] = $parent;
}
public function setParent(Template $template, Template|false $parent): void
{
$this->parents[$template] = $parent;
}
public function assertOwns(Template $template): void
{
if (!$template->isOwnedBy($this->env)) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
}
public function isFrozen(Template $template): bool
{
return $this->frozen->offsetExists($template);
}
public function getFrozen(Template $template): Template
{
return $this->frozen[$template];
}
public function setFrozen(Template $template, Template $frozen): void
{
$this->frozen[$template] = $frozen;
}
public function beginFreeze(Template $template): void
{
if ($this->freezing->offsetExists($template)) {
throw new \LogicException(\sprintf('Circular template inheritance detected while building a block chain from "%s".', $template->getTemplateName()));
}
$this->freezing[$template] = true;
}
public function endFreeze(Template $template): void
{
$this->freezing->offsetUnset($template);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ class BlockNode extends Node
->write(" */\n")
->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n")
->indent()
->write("\$macros = \$this->macros;\n")
->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n")
;
$compiler
+1 -1
View File
@@ -115,7 +115,7 @@ class MacroNode extends Node
->raw("): string|Markup {\n")
->indent()
->addDebugInfo($this)
->write("\$macros = \$this->macros;\n")
->write("\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);\n")
->write("\$context = [\n")
->indent()
;
+84 -1
View File
@@ -39,6 +39,7 @@ abstract class Template
protected $traitAliases = [];
protected $extensions = [];
protected $sandbox;
protected ?self $macroImportSource = null;
private $useYield;
private ?MacroNamespace $macroNamespace = null;
@@ -78,6 +79,11 @@ abstract class Template
public function getParent(array $context): self|TemplateWrapper|false
{
if (null !== $this->parent) {
// only block chain clones set macroImportSource; they never run yield(), where the sandbox check normally happens
if (null !== $this->macroImportSource) {
$this->ensureSecurityChecked();
}
return $this->parent;
}
@@ -170,12 +176,21 @@ abstract class Template
public function renderParentBlock($name, array $context, array $blocks = []): string
{
if (!$this->useYield) {
$level = ob_get_level();
if ($this->env->isDebug()) {
ob_start();
} else {
ob_start(static function () { return ''; });
}
$this->displayParentBlock($name, $context, $blocks);
try {
$this->displayParentBlock($name, $context, $blocks);
} catch (\Throwable $e) {
while (ob_get_level() > $level) {
ob_end_clean();
}
throw $e;
}
return ob_get_clean();
}
@@ -355,6 +370,53 @@ abstract class Template
return $this;
}
/**
* @internal
*/
public function isOwnedBy(Environment $env): bool
{
return $this->env === $env;
}
/**
* @internal
*/
public function freezeLineage(BlockResolutionContext $resolution): self
{
$resolution->assertOwns($this);
if ($resolution->isFrozen($this)) {
return $resolution->getFrozen($this);
}
$resolution->beginFreeze($this);
try {
if (false === $parent = $resolution->getParent($this)) {
$resolution->setFrozen($this, $this);
return $this;
}
$template = clone $this;
foreach ($template->blocks as &$block) {
if ($block[0] === $this) {
$block[0] = $template;
}
}
unset($block);
$template->macroNamespace = null;
// Keep module-level imports live while rebinding self imports to the clone.
$template->macroImportSource = $this;
$template->parent = $frozenParent = $parent->freezeLineage($resolution);
$resolution->setParent($template, $frozenParent);
$resolution->setFrozen($this, $template);
return $template;
} finally {
$resolution->endFreeze($this);
}
}
/**
* Returns all blocks.
*
@@ -499,6 +561,27 @@ abstract class Template
return [];
}
/**
* @param array<string, MacroNamespace> $macros
*
* @return array<string, MacroNamespace>
*/
protected function rebindMacroImports(array $macros): array
{
if (null === $this->macroImportSource) {
return $macros;
}
$imported = $this->macroImportSource->getMacroNamespace();
foreach ($macros as $name => $namespace) {
if ($namespace === $imported) {
$macros[$name] = $this->getMacroNamespace();
}
}
return $macros;
}
/**
* Runs the sandbox security check against the current sandbox state.
*
+646
View File
@@ -0,0 +1,646 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\BlockChain;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\ProfilerExtension;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Profiler\Profile;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Source;
use Twig\Template;
use Twig\TemplateWrapper;
class BlockChainTest extends TestCase
{
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testComposesCompleteTemplateLineagesInPrecedenceOrder(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme1' => '{% extends "parent1" %}{% block first %}theme1{% endblock %}',
'parent1' => '{% block shared %}parent1{% endblock %}{% block parent1 %}parent1{% endblock %}',
'theme2' => '{% extends "parent2" %}{% block shared %}theme2{% endblock %}{% block second %}theme2{% endblock %}',
'parent2' => '{% block parent2 %}parent2{% endblock %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme1', $twig->load('theme2')]);
$this->assertSame(['first', 'shared', 'parent1', 'second', 'parent2'], $chain->getBlockNames());
$this->assertTrue($chain->hasBlock('shared'));
$this->assertFalse($chain->hasBlock('missing'));
$this->assertSame('parent1', $chain->renderBlock('shared'));
$this->assertSame('theme2', $chain->renderBlock('second'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testNestedBlocksUseTheEffectiveNamespaceAndParentUsesTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}theme/{{ parent() }}/{{ block("suffix") }}{% endblock %}',
'parent1' => '{% block field %}parent1{% endblock %}',
'parent2' => '{% block field %}parent2{% endblock %}',
'suffix' => '{% block suffix %}suffix{% endblock %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme', 'suffix'], ['parent' => 'parent1']);
$this->assertSame('theme/parent1/suffix', $chain->renderBlock('field', ['parent' => 'parent2']));
}
public function testExplicitTemplateBlockCallsResolveOutsideTheChainNamespace(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% block field %}{{ block("suffix", "explicit") }}{% endblock %}',
'chain' => '{% block suffix %}chain{% endblock %}',
'explicit' => '{% block suffix %}explicit{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme', 'chain']);
$this->assertSame('explicit', $chain->renderBlock('field'));
}
public function testFreezingAChainDoesNotChangeTheLoadedTemplate(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}{{ parent() }}{% endblock %}',
'parent1' => '{% block field %}one{% endblock %}',
'parent2' => '{% block field %}two{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$template = $twig->load('theme');
$chain = new BlockChain($twig, [$template], ['parent' => 'parent1']);
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('two', $template->renderBlock('field', ['parent' => 'parent2']));
}
public function testStructuralContextIncludesEnvironmentGlobals(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends layout %}{% block field %}{{ parent() }}{% endblock %}',
'parent' => '{% block field %}parent{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$twig->addGlobal('layout', 'parent');
$chain = new BlockChain($twig, ['theme']);
$this->assertSame('parent', $chain->renderBlock('field'));
}
public function testTraitAliasesKeepTheirLocalParentLineage(): void
{
$twig = new Environment(new ArrayLoader([
'base_trait' => '{% block field %}base{% endblock %}',
'trait' => '{% use "base_trait" %}{% block field %}trait/{{ parent() }}{% endblock %}',
'theme' => '{% use "trait" with field as aliased %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme']);
$this->assertSame(['aliased'], $chain->getBlockNames());
$this->assertSame('trait/base', $chain->renderBlock('aliased'));
}
public function testRenderCapturesLegacyEchoingBlocks(): void
{
$twig = new Environment(new ArrayLoader(), ['use_yield' => false]);
$chain = new BlockChain($twig, [new TemplateWrapper($twig, new EchoingBlockChainTemplate($twig))]);
$this->assertSame('echo/yield', $chain->renderBlock('field'));
}
public function testRenderRestoresOutputBuffersOnError(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% block field %}{% set captured %}{{ missing.value }}{% endset %}{% endblock %}',
]), ['strict_variables' => true, 'use_yield' => false]);
$chain = new BlockChain($twig, ['theme']);
$level = ob_get_level();
try {
$chain->renderBlock('field');
$this->fail('Rendering the block must fail.');
} catch (RuntimeError) {
$actualLevel = ob_get_level();
} finally {
while (ob_get_level() > $level) {
ob_end_clean();
}
}
$this->assertSame($level, $actualLevel);
}
public function testRenderingDisplayingAndStreamingAddGlobals(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% block field %}{{ local }}:{{ global|default("none") }}{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$twig->addGlobal('global', 'GLOBAL');
$chain = new BlockChain($twig, ['theme']);
$this->assertSame('LOCAL:GLOBAL', $chain->renderBlock('field', ['local' => 'LOCAL']));
ob_start();
$chain->displayBlock('field', ['local' => 'LOCAL']);
$this->assertSame('LOCAL:GLOBAL', ob_get_clean());
$streamed = '';
foreach ($chain->streamBlock('field', ['local' => 'LOCAL']) as $data) {
$streamed .= $data;
}
$this->assertSame('LOCAL:GLOBAL', $streamed);
}
public function testRejectsInvalidBlockDefinitions(): void
{
$twig = new Environment(new ArrayLoader());
$template = new class($twig) extends EchoingBlockChainTemplate {
public function __construct(Environment $env)
{
parent::__construct($env);
$this->blocks = ['field' => [new \stdClass(), 'block_field']];
}
};
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block must be a method on a \Twig\Template instance.');
new BlockChain($twig, [new TemplateWrapper($twig, $template)]);
}
public function testRejectsTemplatesThatAreNotStringsOrWrappers(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
$this->expectException(\TypeError::class);
$this->expectExceptionMessage('Block chain templates must be strings or "Twig\TemplateWrapper" instances, "stdClass" given.');
new BlockChain($twig, [new \stdClass()]);
}
public function testRejectsWrappersFromAnotherEnvironment(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
$other = new Environment(new ArrayLoader(['theme' => '']));
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
new BlockChain($twig, [$other->load('theme')]);
}
public function testRejectsWrappersThatHideATemplateFromAnotherEnvironment(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
$other = new Environment(new ArrayLoader(['theme' => '']));
$wrapper = new TemplateWrapper($twig, $other->load('theme')->unwrap());
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
new BlockChain($twig, [$wrapper]);
}
public function testRejectsDynamicParentsFromAnotherEnvironment(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '{% extends parent %}']));
$other = new Environment(new ArrayLoader(['parent' => '']));
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]);
}
public function testDynamicParentSecurityIsCheckedDuringConstruction(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent|upper %}',
'PARENT' => '',
]));
$twig->addExtension(new SandboxExtension(new SecurityPolicy(['extends']), true));
$this->expectException(SecurityNotAllowedFilterError::class);
new BlockChain($twig, ['theme'], ['parent' => 'parent']);
}
public function testDefiningTemplateSecurityIsCheckedDuringRendering(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% block field %}{{ value|upper }}{% endblock %}',
]));
$twig->addExtension(new SandboxExtension(new SecurityPolicy(['block']), true));
$chain = new BlockChain($twig, ['theme']);
$this->expectException(SecurityNotAllowedFilterError::class);
$chain->renderBlock('field', ['value' => 'value']);
}
public function testSandboxPolicyChangesAreObservedAfterConstruction(): void
{
$twig = new Environment(new ArrayLoader([
'policy_theme' => '{% extends "parent" %}{% block field %}{{ value|upper }}{% endblock %}',
'parent' => '',
]), ['autoescape' => false, 'use_yield' => true]);
$sandbox = new SandboxExtension(new SecurityPolicy(['extends', 'block'], ['upper']), true);
$twig->addExtension($sandbox);
$chain = new BlockChain($twig, ['policy_theme']);
$this->assertSame('VALUE', $chain->renderBlock('field', ['value' => 'value']));
$sandbox->setSecurityPolicy(new SecurityPolicy(['extends', 'block']));
$this->expectException(SecurityNotAllowedFilterError::class);
$chain->renderBlock('field', ['value' => 'value']);
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testSandboxPolicyChangesAreCheckedOnFrozenIntermediateParents(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "middle" %}{% block field %}{{ parent() }}{% endblock %}',
'middle' => '{% extends parent|upper %}',
'GRANDPARENT' => '{% block field %}safe{% endblock %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$sandbox = new SandboxExtension(new SecurityPolicy(['extends', 'block'], ['upper'], allowedFunctions: ['parent']), true);
$twig->addExtension($sandbox);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'grandparent']);
$this->assertSame('safe', $chain->renderBlock('field'));
$sandbox->setSecurityPolicy(new SecurityPolicy(['extends', 'block'], allowedFunctions: ['parent']));
$this->expectException(SecurityNotAllowedFilterError::class);
$chain->renderBlock('field');
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testImportedMacroNamespacesObserveSandboxPolicyChanges(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "layout" %}{% import "macros" as macros %}{% block field %}{{ macros.label(value) }}{% endblock %}',
'layout' => '{{ block("field") }}',
'macros' => '{% macro label(value) %}{{ value|upper }}{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$sandbox = new SandboxExtension(new SecurityPolicy(['extends', 'import', 'block', 'macro'], ['upper'], allowedFunctions: ['block']), true);
$twig->addExtension($sandbox);
$chain = new BlockChain($twig, ['theme']);
$this->assertSame('VALUE', $twig->render('theme', ['value' => 'value']));
$this->assertSame('VALUE', $chain->renderBlock('field', ['value' => 'value']));
$sandbox->setSecurityPolicy(new SecurityPolicy(['extends', 'import', 'block', 'macro'], allowedFunctions: ['block']));
$this->expectException(SecurityNotAllowedFilterError::class);
$chain->renderBlock('field', ['value' => 'value']);
}
public function testProfilerKeepsTheDefiningTemplateAndBlockAttribution(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "parent" %}{% block field %}field{% endblock %}',
'parent' => '',
]), ['use_yield' => true]);
$profile = new Profile();
$twig->addExtension(new ProfilerExtension($profile));
$chain = new BlockChain($twig, ['theme']);
$chain->renderBlock('field');
$profiles = $profile->getProfiles();
$this->assertCount(1, $profiles);
$this->assertSame('theme', $profiles[0]->getTemplate());
$this->assertSame(Profile::BLOCK, $profiles[0]->getType());
$this->assertSame('field', $profiles[0]->getName());
}
public function testMacrosStayOwnedByTheirDefiningTemplate(): void
{
$twig = new Environment(new ArrayLoader([
'theme1' => '{% macro label() %}one{% endmacro %}{% block field %}{{ _self.label() }}{% endblock %}',
'theme2' => '{% macro label() %}two{% endmacro %}{% block field %}{{ _self.label() }}{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme1', 'theme2']);
$this->assertSame('one', $chain->renderBlock('field'));
}
public function testInheritedMacroLookupUsesTheFrozenLineage(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}{{ _self.label() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}',
'parent2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testSelfMacroImportsInitializedAfterConstructionUseTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import _self as own %}{% block field %}{{ own.label() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
'parent2' => '{% macro label() %}two{% endmacro %}{{ block("field") }}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testPreWarmedSelfMacroImportsUseTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import _self as own %}{% block field %}{{ own.label() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
'parent2' => '{% macro label() %}two{% endmacro %}{{ block("field") }}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testFromSelfImportsUseTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% from _self import label %}{% macro wrapped() %}{{ label() }}{% endmacro %}{% block field %}{{ label() }}/{{ _self.wrapped() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}',
'parent2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$this->assertSame('', $twig->render('theme', ['parent' => 'parent2']));
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('one/one', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testImportedMacroNamespacesStayOutsideTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import parent as inherited %}{% block field %}{{ inherited.label() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}{{ block("field") }}',
'parent2' => '{% macro label() %}two{% endmacro %}{{ block("field") }}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$this->assertSame('two', $twig->render('theme', ['parent' => 'parent2']));
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('two', $chain->renderBlock('field'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testModuleImportsFollowTheDefiningTemplateBodyState(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "layout" %}{% import helper as macros %}{% block field %}{{ macros.label() }}{% endblock %}',
'layout' => '{{ block("field") }}',
'macros1' => '{% macro label() %}one{% endmacro %}',
'macros2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme']);
try {
$chain->renderBlock('field', ['helper' => 'macros1']);
$this->fail('Rendering an uninitialized import must fail.');
} catch (RuntimeError) {
}
$this->assertSame('one', $twig->render('theme', ['helper' => 'macros1']));
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $twig->render('theme', ['helper' => 'macros2']));
$this->assertSame('two', $chain->renderBlock('field'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testSelfMacroImportsInMacroBodiesUseTheFrozenLineage(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% import _self as own %}{% macro wrapped() %}{{ own.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
'parent1' => '{% macro label() %}one{% endmacro %}',
'parent2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$this->assertSame('', $twig->render('theme', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field', ['parent' => 'parent2']));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testMacroBodiesObserveImportUpdatesAfterConstruction(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "layout" %}{% import helper as macros %}{% macro wrapped() %}{{ macros.label() }}{% endmacro %}{% block field %}{{ _self.wrapped() }}{% endblock %}',
'layout' => '{{ block("field") }}',
'macros1' => '{% macro label() %}one{% endmacro %}',
'macros2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['theme']);
$this->assertSame('one', $twig->render('theme', ['helper' => 'macros1']));
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $twig->render('theme', ['helper' => 'macros2']));
$this->assertSame('two', $chain->renderBlock('field'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testPreWarmedExternalMacroImportsAreNotReboundByChainOrder(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends "layout" %}{% import "macros" as macros %}{% block field %}{{ macros.label() }}{% endblock %}',
'layout' => '{{ block("field") }}',
'macros' => '{% extends macro_parent %}',
'macros1' => '{% macro label() %}one{% endmacro %}',
'macros2' => '{% macro label() %}two{% endmacro %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$this->assertSame('two', $twig->render('theme', ['macro_parent' => 'macros2']));
$context = ['macro_parent' => 'macros1'];
$renderContext = ['macro_parent' => 'macros2'];
$this->assertSame('two', (new BlockChain($twig, ['macros', 'theme'], $context))->renderBlock('field', $renderContext));
$this->assertSame('two', (new BlockChain($twig, ['theme', 'macros'], $context))->renderBlock('field', $renderContext));
}
public function testChainsWithDifferentDynamicParentsCanBeStreamedInterleaved(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}{% block field %}before/{{ parent() }}/after{% endblock %}',
'parent1' => '{% block field %}one{% endblock %}',
'parent2' => '{% block field %}two{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$stream1 = (new BlockChain($twig, ['theme'], ['parent' => 'parent1']))->streamBlock('field');
$stream2 = (new BlockChain($twig, ['theme'], ['parent' => 'parent2']))->streamBlock('field');
$output1 = $output2 = '';
$stream1->rewind();
$stream2->rewind();
while ($stream1->valid() || $stream2->valid()) {
if ($stream1->valid()) {
$output1 .= $stream1->current();
$stream1->next();
}
if ($stream2->valid()) {
$output2 .= $stream2->current();
$stream2->next();
}
}
$this->assertSame('before/one/after', $output1);
$this->assertSame('before/two/after', $output2);
}
public function testErrorsKeepTheDefiningSourceAndLine(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => "{% block field %}\n{{ missing.value }}\n{% endblock %}",
]), ['strict_variables' => true, 'use_yield' => true]);
$chain = new BlockChain($twig, ['theme']);
try {
$chain->renderBlock('field');
$this->fail('Rendering must fail.');
} catch (RuntimeError $e) {
$this->assertSame('theme', $e->getSourceContext()->getName());
$this->assertSame(2, $e->getTemplateLine());
}
}
public function testUnknownBlockUsesTheFirstTemplateAsErrorContext(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
$chain = new BlockChain($twig, ['theme']);
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('Block "missing" on template "theme" does not exist in "theme".');
$chain->renderBlock('missing');
}
public function testCircularInheritanceIsRejectedDuringConstruction(): void
{
$twig = new Environment(new ArrayLoader([
'one' => '{% extends "two" %}',
'two' => '{% extends "one" %}',
]));
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Circular template inheritance detected while building a block chain from "one".');
new BlockChain($twig, ['one']);
}
public function testRequiresAtLeastOneTemplate(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('A block chain requires at least one template.');
new BlockChain(new Environment(new ArrayLoader()), []);
}
public static function yieldModes(): iterable
{
yield 'echo and yield' => [false];
yield 'yield only' => [true];
}
}
class EchoingBlockChainTemplate extends Template
{
public function __construct(Environment $env)
{
parent::__construct($env);
$this->parent = false;
$this->blocks = ['field' => [$this, 'block_field']];
}
public function block_field(array $context, array $blocks = []): iterable
{
echo 'echo/';
yield 'yield';
}
public function getTemplateName(): string
{
return 'echoing';
}
public function getDebugInfo(): array
{
return [];
}
public function getSourceContext(): Source
{
return new Source('', 'echoing');
}
protected function doDisplay(array $context, array $blocks = []): iterable
{
yield from [];
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ class BlockTest extends NodeTestCase
*/
public function block_foo(array \$context, array \$blocks = []): iterable
{
\$macros = \$this->macros;
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
yield "foo";
yield from [];
}
+2 -2
View File
@@ -65,7 +65,7 @@ class MacroTest extends NodeTestCase
yield 'with use_yield = true' => [self::createNode(), <<<EOF
new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscore = null, ...\$varargs): string|Markup {
// line 1
\$macros = \$this->macros;
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$context = [
"foo" => \$foo,
"bar" => \$bar,
@@ -86,7 +86,7 @@ EOF, new Environment(new ArrayLoader(), ['use_yield' => true]),
yield 'with use_yield = false' => [self::createNode(), <<<EOF
new \\Twig\\TwigMacro("foo", function (\$foo = null, \$bar = "Foo", \$_underscore = null, ...\$varargs): string|Markup {
// line 1
\$macros = \$this->macros;
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$context = [
"foo" => \$foo,
"bar" => \$bar,
+1 -1
View File
@@ -64,7 +64,7 @@ protected function loadDeclaredMacros(): array
return [
"foo" => new \\Twig\\TwigMacro("foo", function (\$foo = null, ...\$varargs): string|Markup {
// line 1
\$macros = \$this->macros;
\$macros = null === \$this->macroImportSource ? \$this->macros : \$this->rebindMacroImports(\$this->macroImportSource->macros);
\$context = [
"foo" => \$foo,
"varargs" => \$varargs,
+33
View File
@@ -226,6 +226,33 @@ class TemplateTest extends TestCase
$template->displayBlock('foo', [], ['foo' => [new TemplateForTest($twig, 'index.twig'), 'block_foo']], false);
}
/**
* @dataProvider debugModes
*/
#[DataProvider('debugModes')]
public function testRenderParentBlockRestoresOutputBuffersOnError(bool $debug): void
{
$twig = new Environment(new ArrayLoader([
'parent' => '{% block content %}{{ missing.value }}{% endblock %}',
'child' => '{% extends "parent" %}',
]), ['debug' => $debug, 'strict_variables' => true, 'use_yield' => false]);
$template = $twig->load('child')->unwrap();
$level = ob_get_level();
try {
$template->renderParentBlock('content', []);
$this->fail('Rendering the parent block must fail.');
} catch (RuntimeError) {
$actualLevel = ob_get_level();
} finally {
while (ob_get_level() > $level) {
ob_end_clean();
}
}
$this->assertSame($level, $actualLevel);
}
public function testGetAttributeOnArrayWithConfusableKey(): void
{
$twig = new Environment(new ArrayLoader());
@@ -462,6 +489,12 @@ class TemplateTest extends TestCase
$this->assertSame(0, $key->toStringCalls);
}
public static function debugModes(): iterable
{
yield 'debug disabled' => [false];
yield 'debug enabled' => [true];
}
public static function getStrictVariablesModes(): iterable
{
yield 'lax' => [false];