Allow block chains to be composed of other block chains

This commit is contained in:
Fabien Potencier
2026-09-14 11:08:39 +02:00
parent a414c3a491
commit d6b81f9074
3 changed files with 181 additions and 14 deletions
+12
View File
@@ -126,6 +126,18 @@ default variables for that resolution, which the render context can override::
Pass the same context to ``hasBlock()``, ``getBlockNames()`` and
``renderBlock()`` to keep them consistent.
A chain also accepts other chains::
$base = new BlockChain($twig, ['base_theme.html.twig']);
$application = new BlockChain($twig, ['application_theme.html.twig', $base]);
$admin = new BlockChain($twig, ['admin_theme.html.twig', $application]);
A chained chain keeps its position in the precedence order: templates listed
before it win, and it wins over templates listed after it. It contributes the
blocks it composes itself, resolved with its own default variables. Chaining a
chain never modifies it, so the same chain can be reused in as many others as
needed.
Streaming Templates
-------------------
+54 -12
View File
@@ -18,10 +18,10 @@ use Twig\Error\RuntimeError;
*/
final class BlockChain
{
/** @var list<Template> */
/** @var list<Template|self> */
private array $templates = [];
/** @var list<Template> */
/** @var list<Template|self> */
private array $lineage = [];
/** @var array<string, array{Template, string}> */
@@ -33,8 +33,8 @@ final class BlockChain
private bool $fixed = false;
/**
* @param iterable<string|TemplateWrapper> $templates Templates ordered from highest to lowest precedence
* @param array<string, mixed> $context Default variables used to resolve dynamic parent expressions
* @param iterable<string|TemplateWrapper|self> $templates Templates and nested chains ordered from highest to lowest precedence
* @param array<string, mixed> $context Default variables used to resolve dynamic parent expressions
*/
public function __construct(
private Environment $env,
@@ -42,11 +42,20 @@ final class BlockChain
private array $context = [],
) {
foreach ($templates as $template) {
if ($template instanceof self) {
if ($env !== $template->env) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
$this->templates[] = $template;
continue;
}
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)));
throw new \TypeError(\sprintf('Block chain templates must be strings, "%s" or "%s" instances, "%s" given.', TemplateWrapper::class, self::class, get_debug_type($template)));
}
$template = $template->unwrap();
@@ -95,7 +104,7 @@ final class BlockChain
$this->throwUnknownBlock($name);
}
yield from $this->templates[0]->yieldBlock($name, $context, $blocks);
yield from $this->entryTemplate()->yieldBlock($name, $context, $blocks);
}
/**
@@ -109,7 +118,7 @@ final class BlockChain
$this->throwUnknownBlock($name);
}
return $this->templates[0]->renderBlock($name, $context, $blocks);
return $this->entryTemplate()->renderBlock($name, $context, $blocks);
}
/**
@@ -123,7 +132,7 @@ final class BlockChain
$this->throwUnknownBlock($name);
}
$this->templates[0]->displayBlock($name, $context, $blocks);
$this->entryTemplate()->displayBlock($name, $context, $blocks);
}
/**
@@ -133,14 +142,37 @@ final class BlockChain
*/
private function resolveBlocks(array $context): array
{
[$lineage, $this->fixed] = $this->resolveLineage($context);
[$lineage, $fixed] = $this->resolveLineage($context);
if ($lineage === $this->lineage) {
$stale = $lineage !== $this->lineage;
foreach ($lineage as $template) {
if (!$template instanceof self) {
continue;
}
if (!$template->fixed) {
// a nested chain that can still move may resolve to other blocks on every call
$template->resolveBlocks($context + $template->context);
$stale = true;
}
$fixed = $fixed && $template->fixed;
}
$this->fixed = $fixed;
if (!$stale) {
return $this->blocks;
}
$blocks = [];
foreach ($lineage as $template) {
if ($template instanceof self) {
$blocks += $template->blocks;
continue;
}
foreach ($template->getBlocks() as $name => $block) {
$blocks[$name] ??= $block;
}
@@ -154,7 +186,7 @@ final class BlockChain
/**
* @param array<string, mixed> $context
*
* @return array{list<Template>, bool}
* @return array{list<Template|self>, bool}
*/
private function resolveLineage(array $context): array
{
@@ -162,6 +194,11 @@ final class BlockChain
$fixed = true;
foreach ($this->templates as $template) {
if ($template instanceof self) {
$lineage[] = $template;
continue;
}
$seen = [];
do {
if (isset($seen[$id = spl_object_id($template)])) {
@@ -184,8 +221,13 @@ final class BlockChain
return [$lineage, $fixed];
}
private function entryTemplate(): Template
{
return ($template = $this->templates[0]) instanceof self ? $template->entryTemplate() : $template;
}
private function throwUnknownBlock(string $name): never
{
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->templates[0]->getTemplateName()), -1, $this->templates[0]->getSourceContext());
throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->entryTemplate()->getTemplateName()), -1, $this->entryTemplate()->getSourceContext());
}
}
+115 -2
View File
@@ -228,16 +228,118 @@ class BlockChainTest extends TestCase
$chain->renderBlock('field');
}
public function testRejectsTemplatesThatAreNotStringsOrWrappers(): void
public function testRejectsTemplatesThatAreNotStringsWrappersOrChains(): 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.');
$this->expectExceptionMessage('Block chain templates must be strings, "Twig\TemplateWrapper" or "Twig\BlockChain" instances, "stdClass" given.');
new BlockChain($twig, [new \stdClass()]);
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testAChainedChainKeepsItsPositionInThePrecedenceOrder(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'override' => '{% block field %}override{% endblock %}',
'base' => '{% block field %}base{% endblock %}{% block shared %}base{% endblock %}',
'fallback' => '{% block shared %}fallback{% endblock %}{% block last %}fallback{% endblock %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$base = new BlockChain($twig, ['base']);
$chain = new BlockChain($twig, ['override', $base, 'fallback']);
$this->assertSame(['field', 'shared', 'last'], $chain->getBlockNames());
$this->assertSame('override', $chain->renderBlock('field'));
$this->assertSame('base', $chain->renderBlock('shared'));
$this->assertSame('fallback', $chain->renderBlock('last'));
// the chained chain is left untouched
$this->assertSame(['field', 'shared'], $base->getBlockNames());
$this->assertSame('base', $base->renderBlock('field'));
}
/**
* @dataProvider yieldModes
*/
#[DataProvider('yieldModes')]
public function testNestedBlockCallsSeeTheBlocksOfAChainedChain(bool $useYield): void
{
$twig = new Environment(new ArrayLoader([
'override' => '{% block field %}override/{{ block("widget") }}{% endblock %}',
'base' => '{% block field %}base{% endblock %}{% block widget %}base-widget{% endblock %}',
]), ['autoescape' => false, 'use_yield' => $useYield]);
$chain = new BlockChain($twig, ['override', new BlockChain($twig, ['base'])]);
$this->assertSame('override/base-widget', $chain->renderBlock('field'));
}
public function testAChainedChainIsComposedThroughItsWholeLineage(): void
{
$twig = new Environment(new ArrayLoader([
'override' => '{% block field %}override{% endblock %}',
'base' => '{% extends "layout" %}{% block widget %}base-widget{% endblock %}',
'layout' => '{% block field %}layout{% endblock %}{% block row %}layout-row{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, ['override', new BlockChain($twig, ['base'])]);
$this->assertSame(['field', 'widget', 'row'], $chain->getBlockNames());
$this->assertSame('override', $chain->renderBlock('field'));
$this->assertSame('layout-row', $chain->renderBlock('row'));
}
public function testAChainedChainKeepsItsOwnDefaultContext(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}',
'parent1' => '{% block field %}one{% endblock %}',
'parent2' => '{% block field %}two{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$base = new BlockChain($twig, ['theme'], ['parent' => 'parent1']);
$chain = new BlockChain($twig, [$base]);
$this->assertSame('one', $chain->renderBlock('field'));
$this->assertSame('two', $chain->renderBlock('field', ['parent' => 'parent2']));
$this->assertSame('one', $chain->renderBlock('field'));
}
public function testAChainedChainWithADynamicParentIsResolvedAgainstTheRenderContext(): void
{
$twig = new Environment(new ArrayLoader([
'theme' => '{% extends parent %}',
'parent1' => '{% block field %}one{% endblock %}{% block only1 %}{% endblock %}',
'parent2' => '{% block field %}two{% endblock %}{% block only2 %}{% endblock %}',
]), ['autoescape' => false, 'use_yield' => true]);
$chain = new BlockChain($twig, [new BlockChain($twig, ['theme'])]);
$context = ['parent' => 'parent1'];
$this->assertSame(['field', 'only1'], $chain->getBlockNames($context));
$this->assertSame('one', $chain->renderBlock('field', $context));
$context = ['parent' => 'parent2'];
$this->assertSame(['field', 'only2'], $chain->getBlockNames($context));
$this->assertSame('two', $chain->renderBlock('field', $context));
}
public function testRejectsChainsFromAnotherEnvironment(): 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, [new BlockChain($other, ['theme'])]);
}
public function testRejectsWrappersFromAnotherEnvironment(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
@@ -627,6 +729,17 @@ class BlockChainTest extends TestCase
$chain->renderBlock('missing');
}
public function testUnknownBlockUsesTheFirstTemplateOfAChainedChainAsErrorContext(): void
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
$chain = new BlockChain($twig, [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 testCircularInheritanceIsRejected(): void
{
$twig = new Environment(new ArrayLoader([