mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-15 20:06:31 +00:00
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:
@@ -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
@@ -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
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user