mirror of
https://github.com/twigphp/Twig.git
synced 2026-10-02 18:07:35 +00:00
Allow block chains to be composed of other block chains
This commit is contained in:
+12
@@ -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
@@ -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
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user