diff --git a/doc/api.rst b/doc/api.rst index ca1883280..20da66ff1 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -68,6 +68,64 @@ 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. + +Every method takes the render context, and the whole chain is resolved against +it, so a template using a dynamic ``{% extends %}`` behaves exactly as it would +when rendered directly. The optional third constructor argument provides +default variables for that resolution, which the render context can override:: + + $blocks = new BlockChain($twig, ['application_theme.html.twig'], [ + 'layout' => 'wide_layout.html.twig', + ]); + + // resolved against wide_layout.html.twig + $blocks->getBlockNames(); + + // resolved against narrow_layout.html.twig + $blocks->getBlockNames(['layout' => 'narrow_layout.html.twig']); + +Pass the same context to ``hasBlock()``, ``getBlockNames()`` and +``renderBlock()`` to keep them consistent. + Streaming Templates ------------------- diff --git a/extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php b/extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php new file mode 100644 index 000000000..a5bb216fd --- /dev/null +++ b/extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php @@ -0,0 +1,57 @@ +hasDefinition('twig.cache') || !$container->hasDefinition('.twig.cache.inner')) { + return; + } + + $definition = $container->getDefinition('.twig.cache.inner'); + $class = $definition->getClass(); + + while (null === $class && $definition instanceof ChildDefinition) { + $parent = $definition->getParent(); + + if (!$container->hasDefinition($parent) && !$container->hasAlias($parent)) { + return; + } + + $definition = $container->findDefinition($parent); + $class = $definition->getClass(); + } + + if (!is_a($class ?? '', TagAwareAdapterInterface::class, true)) { + return; + } + + $container->removeDefinition('twig.cache'); + $container->setAlias('twig.cache', '.twig.cache.inner'); + } +} diff --git a/extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php b/extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php new file mode 100644 index 000000000..559215ab1 --- /dev/null +++ b/extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php @@ -0,0 +1,60 @@ +kernels as $kernel) { + (new Filesystem())->remove($kernel->getTempDir()); + } + + $this->kernels = []; + } + + public function testThePoolIsDecoratedWhenTheAppAdapterIsNotTagAware(): void + { + $pools = $this->compileFor(null); + + $this->assertSame(TagAwareAdapter::class, $pools['twig.cache']); + $this->assertSame(FilesystemAdapter::class, $pools['.twig.cache.inner']); + $this->assertNotSame($pools['cache.app.namespace'], $pools['.twig.cache.inner.namespace']); + } + + public function testThePoolIsNotDecoratedWhenTheAppAdapterIsTagAware(): void + { + $pools = $this->compileFor('cache.adapter.redis_tag_aware'); + + $this->assertSame('@.twig.cache.inner', $pools['twig.cache']); + $this->assertSame(RedisTagAwareAdapter::class, $pools['.twig.cache.inner']); + $this->assertNotSame($pools['cache.app.namespace'], $pools['.twig.cache.inner.namespace']); + } + + private function compileFor(?string $cacheAdapter): array + { + $this->kernels[] = $kernel = new CacheKernel($cacheAdapter); + $kernel->boot(); + + return $kernel->getContainer()->getParameter('twig_extra.test.cache_pools'); + } +} diff --git a/extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php b/extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php new file mode 100644 index 000000000..18dc54c86 --- /dev/null +++ b/extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php @@ -0,0 +1,129 @@ +dir = sys_get_temp_dir().'/twig-extra-bundle/'.bin2hex(random_bytes(6)); + + parent::__construct('test', false); + } + + public function getCacheDir(): string + { + return $this->dir.'/cache'; + } + + public function getLogDir(): string + { + return $this->dir.'/log'; + } + + public function getProjectDir(): string + { + return __DIR__; + } + + public function getTempDir(): string + { + return $this->dir; + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new TwigExtraBundle(); + } + + protected function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + $pools = []; + foreach (['twig.cache', '.twig.cache.inner', 'cache.app'] as $id) { + if ($container->hasAlias($id)) { + $pools[$id] = '@'.$container->getAlias($id); + + continue; + } + + $definition = $container->getDefinition($id); + $pools[$id] = $definition->getClass(); + + // cache adapters take their namespace as their first string argument + foreach ($definition->getArguments() as $argument) { + if (\is_string($argument)) { + $pools[$id.'.namespace'] = $argument; + + break; + } + } + } + + $container->setParameter('twig_extra.test.cache_pools', $pools); + } + }, PassConfig::TYPE_BEFORE_REMOVING, -1000); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + $config = [ + 'secret' => 'S3CRET', + 'router' => ['utf8' => true], + 'http_method_override' => false, + 'php_errors' => [ + 'log' => true, + ], + ]; + + // the "handle_all_throwables" option was introduced in FrameworkBundle 6.2 (and so was the NotificationAssertionsTrait) + if (trait_exists(NotificationAssertionsTrait::class)) { + $config['handle_all_throwables'] = true; + } + + if (null !== $this->cacheAdapter) { + $config['cache'] = ['app' => $this->cacheAdapter]; + } + + $c->loadFromExtension('framework', $config); + $c->loadFromExtension('twig', [ + 'default_path' => __DIR__.'/views', + ]); + } + + protected function configureRoutes($routes): void + { + } +} diff --git a/extra/twig-extra-bundle/TwigExtraBundle.php b/extra/twig-extra-bundle/TwigExtraBundle.php index ee65985b6..5b08586ce 100644 --- a/extra/twig-extra-bundle/TwigExtraBundle.php +++ b/extra/twig-extra-bundle/TwigExtraBundle.php @@ -11,9 +11,11 @@ namespace Twig\Extra\TwigExtraBundle; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; use Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler\MissingExtensionSuggestorPass; +use Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler\TwigCachePoolPass; class TwigExtraBundle extends Bundle { @@ -22,5 +24,7 @@ class TwigExtraBundle extends Bundle parent::build($container); $container->addCompilerPass(new MissingExtensionSuggestorPass()); + // priority 64 so that it runs before Symfony's CachePoolPass (priority 32) + $container->addCompilerPass(new TwigCachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 64); } } diff --git a/src/BlockChain.php b/src/BlockChain.php new file mode 100644 index 000000000..e68f145da --- /dev/null +++ b/src/BlockChain.php @@ -0,0 +1,191 @@ + */ + private array $templates = []; + + /** @var list