Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* Fix `html_attr` dropping `style` declarations whose value is `0`, `0.0` or `'0'`
* Fix the `default` filter fallback emitting an undefined variable warning when it uses the null-safe operator
* Fix the `matches` operator silently treating PCRE execution errors as non-matches
* Fix `streamBlock()` omitting environment globals
* 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)
Expand Down
36 changes: 36 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,42 @@ If a template defines blocks, they can be rendered individually via the

echo $template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']);

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. As with direct block rendering, module-level
imports are available after the defining template body has run.

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
-------------------

Expand Down
4 changes: 2 additions & 2 deletions extra/cache-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new CacheExtension(),
];
}

protected function getRuntimeLoaders()
protected function getRuntimeLoaders(): array
{
return [
new class implements RuntimeLoaderInterface {
Expand Down
2 changes: 1 addition & 1 deletion extra/cssinliner-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new CssInlinerExtension(),
Expand Down
2 changes: 1 addition & 1 deletion extra/html-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new HtmlExtension(),
Expand Down
2 changes: 1 addition & 1 deletion extra/inky-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new InkyExtension(),
Expand Down
2 changes: 1 addition & 1 deletion extra/intl-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new IntlExtension(),
Expand Down
2 changes: 1 addition & 1 deletion extra/markdown-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new MarkdownExtension(),
Expand Down
2 changes: 1 addition & 1 deletion extra/string-extra/Tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
public function getExtensions(): array
{
return [
new StringExtension(),
Expand Down
107 changes: 107 additions & 0 deletions src/BlockChain.php
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning — Chain construction loses template/line context and bypasses Twig's error contract.

Parent resolution during construction runs user expressions ({% extends parent|filter %}, dynamic parent names) outside of any Twig error handling, so a non-Twig\Error\Error exception escapes new BlockChain(...) raw. Verified on this checkout: with 'theme' => '{% extends parent|boom %}...' where boom throws, $twig->render('theme') yields Twig\Error\RuntimeError: An exception has been thrown during the rendering of a template ("kaboom") in "theme" at line 1., while new BlockChain($twig, ['theme'], ['parent' => 'parent']) propagates a bare DomainException: kaboom with no template name or line. The same happens for a dynamic parent expression that evaluates to null (Template::load(): Argument #1 ... null given TypeError escapes construction, where rendering reports it as a RuntimeError in "theme" at line 1). Callers that catch Twig\Error\Error around chain construction will not catch these, and the failing template is not identified.

$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());
}
}
86 changes: 86 additions & 0 deletions src/BlockResolutionContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?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 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);
}
}
2 changes: 1 addition & 1 deletion src/Node/BlockNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public function compile(Compiler $compiler): void
->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
Expand Down
2 changes: 1 addition & 1 deletion src/Node/MacroNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public function compile(Compiler $compiler): void
->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()
;
Expand Down
Loading
Loading