core/core.services.yml | 1 + .../Core/Template/ThemeRegistryNodeVisitor.php | 190 +++++++++++++++++++++ core/lib/Drupal/Core/Template/TwigExtension.php | 24 ++- core/lib/Drupal/Core/Theme/Registry.php | 14 ++ core/modules/system/src/SystemConfigSubscriber.php | 5 + .../system/src/Tests/Theme/TwigExtensionTest.php | 2 +- .../src/Tests/Theme/TwigRegistryLoaderTest.php | 11 +- .../twig_extension_test.services.yml | 2 + ...registry-loader-test-extend-same-name.html.twig | 2 + .../templates/twig-registry-loader-test.html.twig | 1 + .../modules/twig_theme_test/twig_theme_test.module | 3 + ...registry-loader-test-extend-same-name.html.twig | 2 + ...registry-loader-test-extend-same-name.html.twig | 2 + .../Tests/Core/Template/TwigExtensionTest.php | 10 +- .../templates/block--search-form-block.html.twig | 2 +- .../templates/block--system-menu-block.html.twig | 2 +- core/themes/bartik/templates/page-title.html.twig | 2 +- .../bartik/templates/status-messages.html.twig | 2 +- .../templates/block--local-actions-block.html.twig | 2 +- 19 files changed, 268 insertions(+), 11 deletions(-) diff --git a/core/core.services.yml b/core/core.services.yml index 5877a38..e89175e 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1523,6 +1523,7 @@ services: calls: - [setUrlGenerator, ['@url_generator']] - [setThemeManager, ['@theme.manager']] + - [setThemeRegistry, ['@theme.registry']] - [setDateFormatter, ['@date.formatter']] # @todo Figure out what to do about debugging functions. # @see https://www.drupal.org/node/1804998 diff --git a/core/lib/Drupal/Core/Template/ThemeRegistryNodeVisitor.php b/core/lib/Drupal/Core/Template/ThemeRegistryNodeVisitor.php new file mode 100644 index 0000000..524df25 --- /dev/null +++ b/core/lib/Drupal/Core/Template/ThemeRegistryNodeVisitor.php @@ -0,0 +1,190 @@ +themeRegistry = $theme_registry; + } + + /** + * {@inheritdoc} + */ + protected function doEnterNode(\Twig_Node $node, \Twig_Environment $env) { + if ($node instanceof \Twig_Node_Module && $node->hasNode('parent')) { + $parent = $node->getNode('parent'); + if ($parent && $this->expressionQualifies($parent)) { + $current_filename = basename($node->getAttribute('filename')); + $extended_filename = $parent->getAttribute('value'); + assert('strpos($current_filename, "/") === FALSE'); + assert('strpos($extended_filename, "/") === FALSE'); + $path = dirname($node->getAttribute('filename')); + $candidates = ($current_filename === $extended_filename) + ? $this->getCandidateParentTemplates($current_filename, $path) + : $this->getCandidateTemplates($extended_filename); + if ($candidates === FALSE) { + throw new \Twig_Error(sprintf('Template "%s" extends "%s", but no such parent templates exist.', $node->getAttribute('filename'), $parent->getAttribute('value'))); + } + $node->setNode('parent', $this->getReplacementNode($parent, $candidates)); + } + } + return $node; + } + + /** + * Whether the given expression qualifies for theme registry-based expansion. + * + * @param \Twig_Node $node + * The Twig node to evaluate. + * + * @return bool + * Whether the expression node qualifies or not. + */ + protected function expressionQualifies(\Twig_Node $node) { + $extended_file = $node->getAttribute('value'); + return + // Only override when extending a single template. + $node instanceof \Twig_Node_Expression_Constant + // Only override when the extended template is pointing to a file, i.e + // when it doesn't include either a path or a namespace. + && $extended_file === basename($extended_file); + } + + /** + * Replace the existing constant template with a list of templates. + * + * @param \Twig_Node_Expression_Constant $node + * The Twig constant expression node to replace. + * @param string[] $candidates + * The candidate templates that Twig should attempt to load. + * + * @return \Twig_Node_Expression_Array + * The replacing Twig array expression node. + * + * @see http://twig.sensiolabs.org/doc/tags/extends.html#dynamic-inheritance + */ + protected function getReplacementNode(\Twig_Node_Expression_Constant $node, array $candidates) { + $line = $node->getLine(); + $replacement_node = new \Twig_Node_Expression_Array([], $line); + foreach ($candidates as $candidate) { + $replacement_node->addElement(new \Twig_Node_Expression_Constant("{$candidate['path']}/{$candidate['template']}.html.twig", $line)); + } + return $replacement_node; + } + + /** + * Returns candidate templates based on the theme registry. + * + * @param string $template_filename + * The template file name. + * + * @return string[] + * The candidate templates, including the current template. + */ + protected function getCandidateTemplates($template_filename) { + assert('strpos($template_filename, "/") === FALSE'); + $theme_hook = str_replace('.html.twig', '', strtr($template_filename, '-', '_')); + $info = $this->themeRegistry->getRuntime()->get($theme_hook); + + // Put the candidates in the right order. + $candidates = array_reverse($info['template lineage']); + + return $candidates; + } + + /** + * Returns candidate parent templates based on the theme registry. + * + * @param string $template_filename + * The template file name. + * @param string $template_path + * The path relative to the Drupal root for the directory that contains this + * template. + * + * @return string[]|false + * The candidate parent templates, or FALSE when none exist. + */ + protected function getCandidateParentTemplates($template_filename, $template_path) { + assert('strpos($template_filename, "/") === FALSE'); + $candidates = $this->getCandidateTemplates($template_filename); + + // The candidates include the current template too, so there must be >=2. + if (count($candidates) < 2) { + return FALSE; + } + + // Remove the current template (and its descendants) from the candidates: + // only return ancestors. + $reversed_candidates = array_reverse($candidates); + for ($i = 0; $i < count($reversed_candidates); $i++) { + if ($reversed_candidates[$i]['path'] === $template_path) { + array_splice($reversed_candidates, $i); + break; + } + } + $candidates = array_reverse($reversed_candidates); + + return $candidates; + } + + /** + * {@inheritdoc} + */ + protected function doLeaveNode(\Twig_Node $node, \Twig_Environment $env) { + return $node; + } + + /** + * {@inheritdoc} + */ + public function getPriority() { + // Just above the Optimizer, which is the normal last one. + return 256; + } + +} diff --git a/core/lib/Drupal/Core/Template/TwigExtension.php b/core/lib/Drupal/Core/Template/TwigExtension.php index 715fd1e..40c6aa8 100644 --- a/core/lib/Drupal/Core/Template/TwigExtension.php +++ b/core/lib/Drupal/Core/Template/TwigExtension.php @@ -11,6 +11,7 @@ use Drupal\Core\Render\RenderableInterface; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\UrlGeneratorInterface; +use Drupal\Core\Theme\Registry; use Drupal\Core\Theme\ThemeManagerInterface; use Drupal\Core\Url; @@ -45,6 +46,13 @@ class TwigExtension extends \Twig_Extension { */ protected $themeManager; + /** + * The theme registry. + * + * @var \Drupal\Core\Theme\Registry + */ + protected $themeRegistry; + /** * The date formatter. * @@ -103,6 +111,19 @@ public function setThemeManager(ThemeManagerInterface $theme_manager) { return $this; } + /** + * Sets the theme registry. + * + * @param \Drupal\Core\Theme\Registry $theme_registry + * The theme registry. + * + * @return $this + */ + public function setThemeRegistry(Registry $theme_registry) { + $this->themeRegistry = $theme_registry; + return $this; + } + /** * Sets the date formatter. * @@ -176,10 +197,9 @@ public function getFilters() { * {@inheritdoc} */ public function getNodeVisitors() { - // The node visitor is needed to wrap all variables with - // render_var -> TwigExtension->renderVar() function. return array( new TwigNodeVisitor(), + new ThemeRegistryNodeVisitor($this->themeRegistry), ); } diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php index d3f0455..46e49e2 100644 --- a/core/lib/Drupal/Core/Theme/Registry.php +++ b/core/lib/Drupal/Core/Theme/Registry.php @@ -500,6 +500,20 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) $result[$hook]['path'] = $path . '/templates'; } + if (isset($cache[$hook]['template lineage'])) { + $result[$hook]['template lineage'] = $cache[$hook]['template lineage']; + } + else { + $result[$hook]['template lineage'] = array(); + } + if (isset($result[$hook]['template'])) { + $result[$hook]['template lineage'][] = array( + 'extension' => $theme, + 'template' => $result[$hook]['template'], + 'path' => $result[$hook]['path'], + ); + } + // If the default keys are not set, use the default values registered // by the module. if (isset($cache[$hook])) { diff --git a/core/modules/system/src/SystemConfigSubscriber.php b/core/modules/system/src/SystemConfigSubscriber.php index 3c0de5f..d08c961 100644 --- a/core/modules/system/src/SystemConfigSubscriber.php +++ b/core/modules/system/src/SystemConfigSubscriber.php @@ -5,6 +5,7 @@ use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigEvents; use Drupal\Core\Config\ConfigImporterEvent; +use Drupal\Core\PhpStorage\PhpStorageFactory; use Drupal\Core\Routing\RouteBuilderInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -41,6 +42,10 @@ public function onConfigSave(ConfigCrudEvent $event) { $saved_config = $event->getConfig(); if ($saved_config->getName() == 'system.theme' && ($event->isChanged('admin') || $event->isChanged('default'))) { $this->routerBuilder->setRebuildNeeded(); + // Wipe the Twig PHP Storage cache, to ensure templates that extend or + // include another template use the correct list of candidate templates. + // @see \Drupal\Core\Template\ThemeRegistryNodeVisitor + PhpStorageFactory::get('twig')->deleteAll(); } } diff --git a/core/modules/system/src/Tests/Theme/TwigExtensionTest.php b/core/modules/system/src/Tests/Theme/TwigExtensionTest.php index 4be1f73..d195200 100644 --- a/core/modules/system/src/Tests/Theme/TwigExtensionTest.php +++ b/core/modules/system/src/Tests/Theme/TwigExtensionTest.php @@ -49,7 +49,7 @@ function testTwigExtensionFilter() { /** * Tests that the Twig extension's function produces expected output. */ - function testTwigExtensionFunction() { + function testsTwigExtensionFunction() { $this->config('system.theme') ->set('default', 'test_theme') ->save(); diff --git a/core/modules/system/src/Tests/Theme/TwigRegistryLoaderTest.php b/core/modules/system/src/Tests/Theme/TwigRegistryLoaderTest.php index 5cbca9f..d90a484 100644 --- a/core/modules/system/src/Tests/Theme/TwigRegistryLoaderTest.php +++ b/core/modules/system/src/Tests/Theme/TwigRegistryLoaderTest.php @@ -23,6 +23,9 @@ class TwigRegistryLoaderTest extends WebTestBase { */ protected $twig; + /** + * {@inheritdoc} + */ protected function setUp() { parent::setUp(); \Drupal::service('theme_handler')->install(array('test_theme_twig_registry_loader', 'test_theme_twig_registry_loader_theme', 'test_theme_twig_registry_loader_subtheme')); @@ -46,20 +49,22 @@ public function testTemplateDiscovery() { /** * Tests template extension and includes using the Drupal theme registry. */ - public function testTwigNamespaces() { + public function testExtendsAndInclude() { // Test the module-provided extend and insert templates. $this->drupalGet('twig-theme-test/registry-loader'); $this->assertText('This line is from twig_theme_test/templates/twig-registry-loader-test-extend.html.twig'); $this->assertText('This line is from twig_theme_test/templates/twig-registry-loader-test-include.html.twig'); + $this->assertRaw(''); // Enable a theme that overrides the extend and insert templates to ensure - // they are picked up by the registry loader. + // they are picked up by the registry node visitor. $this->config('system.theme') ->set('default', 'test_theme_twig_registry_loader') ->save(); $this->drupalGet('twig-theme-test/registry-loader'); $this->assertText('This line is from test_theme_twig_registry_loader/templates/twig-registry-loader-test-extend.html.twig'); $this->assertText('This line is from test_theme_twig_registry_loader/templates/twig-registry-loader-test-include.html.twig'); + $this->assertRaw(''); // Enable overriding theme that overrides the extend and insert templates // from the base theme. @@ -69,6 +74,7 @@ public function testTwigNamespaces() { $this->drupalGet('twig-theme-test/registry-loader'); $this->assertText('This line is from test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-extend.html.twig'); $this->assertText('This line is from test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-include.html.twig'); + $this->assertRaw(''); // Enable a subtheme for the theme that doesn't have any overrides to make // sure that templates are being loaded from the first parent which has the @@ -79,6 +85,7 @@ public function testTwigNamespaces() { $this->drupalGet('twig-theme-test/registry-loader'); $this->assertText('This line is from test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-extend.html.twig'); $this->assertText('This line is from test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-include.html.twig'); + $this->assertRaw(''); } } diff --git a/core/modules/system/tests/modules/twig_extension_test/twig_extension_test.services.yml b/core/modules/system/tests/modules/twig_extension_test/twig_extension_test.services.yml index 491d1e8..dea3cac 100644 --- a/core/modules/system/tests/modules/twig_extension_test/twig_extension_test.services.yml +++ b/core/modules/system/tests/modules/twig_extension_test/twig_extension_test.services.yml @@ -2,5 +2,7 @@ services: twig_extension_test.twig.test_extension: arguments: ['@renderer'] class: Drupal\twig_extension_test\TwigExtension\TestExtension + calls: + - [setThemeRegistry, ['@theme.registry']] tags: - { name: twig.extension } diff --git a/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test-extend-same-name.html.twig b/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test-extend-same-name.html.twig new file mode 100644 index 0000000..8883d27 --- /dev/null +++ b/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test-extend-same-name.html.twig @@ -0,0 +1,2 @@ +{% set attributes = attributes.addClass('module') %} + diff --git a/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test.html.twig b/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test.html.twig index a3723b5..48c29a4 100644 --- a/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test.html.twig +++ b/core/modules/system/tests/modules/twig_theme_test/templates/twig-registry-loader-test.html.twig @@ -1,5 +1,6 @@ {% extends "twig-registry-loader-test-extend.html.twig" %} {% block content %} + {% include "twig-registry-loader-test-extend-same-name.html.twig" %} {% include "twig-registry-loader-test-include.html.twig" %} {% endblock %} diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module index 550b5ca..5668efd 100644 --- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module +++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module @@ -37,6 +37,9 @@ function twig_theme_test_theme($existing, $type, $theme, $path) { $items['twig_registry_loader_test_extend'] = array( 'variables' => array(), ); + $items['twig_registry_loader_test_extend_same_name'] = array( + 'variables' => array(), + ); $items['twig_raw_test'] = array( 'variables' => array('script' => ''), ); diff --git a/core/modules/system/tests/themes/test_theme_twig_registry_loader/templates/twig-registry-loader-test-extend-same-name.html.twig b/core/modules/system/tests/themes/test_theme_twig_registry_loader/templates/twig-registry-loader-test-extend-same-name.html.twig new file mode 100644 index 0000000..97eda90 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_twig_registry_loader/templates/twig-registry-loader-test-extend-same-name.html.twig @@ -0,0 +1,2 @@ +{% extends "twig-registry-loader-test-extend-same-name.html.twig" %} +{% set attributes = attributes.addClass('theme-test_theme_twig_registry_loader') %} diff --git a/core/modules/system/tests/themes/test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-extend-same-name.html.twig b/core/modules/system/tests/themes/test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-extend-same-name.html.twig new file mode 100644 index 0000000..2dd645e --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_twig_registry_loader_theme/templates/twig-registry-loader-test-extend-same-name.html.twig @@ -0,0 +1,2 @@ +{% extends "twig-registry-loader-test-extend-same-name.html.twig" %} +{% set attributes = attributes.addClass('theme-test_theme_twig_registry_loader_theme') %} diff --git a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php index ea93703..4a1c5e8 100644 --- a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php +++ b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php @@ -15,6 +15,7 @@ use Drupal\Core\Template\Loader\StringLoader; use Drupal\Core\Template\TwigEnvironment; use Drupal\Core\Template\TwigExtension; +use Drupal\Core\Theme\Registry; use Drupal\Tests\UnitTestCase; /** @@ -39,7 +40,8 @@ public function testEscaping($template, $expected) { 'autoescape' => 'html', 'optimizations' => 0 )); - $twig->addExtension((new TwigExtension($renderer))->setUrlGenerator($this->getMock('Drupal\Core\Routing\UrlGeneratorInterface'))); + $registry = $this->prophesize(Registry::class); + $twig->addExtension((new TwigExtension($renderer))->setUrlGenerator($this->getMock('Drupal\Core\Routing\UrlGeneratorInterface'))->setThemeRegistry($registry->reveal())); $nodes = $twig->parse($twig->tokenize($template)); @@ -84,7 +86,9 @@ public function providerTestEscaping() { */ public function testActiveTheme() { $renderer = $this->getMock('\Drupal\Core\Render\RendererInterface'); + $registry = $this->prophesize(Registry::class); $extension = new TwigExtension($renderer); + $extension->setThemeRegistry($registry->reveal()); $theme_manager = $this->getMock('\Drupal\Core\Theme\ThemeManagerInterface'); $active_theme = $this->getMockBuilder('\Drupal\Core\Theme\ActiveTheme') ->disableOriginalConstructor() @@ -117,7 +121,9 @@ public function testFormatDate() { ->method('format') ->willReturn('1978-11-19'); $renderer = $this->getMock('\Drupal\Core\Render\RendererInterface'); + $registry = $this->prophesize(Registry::class); $extension = new TwigExtension($renderer); + $extension->setThemeRegistry($registry->reveal()); $extension->setDateFormatter($date_formatter); $loader = new StringLoader(); @@ -132,7 +138,9 @@ public function testFormatDate() { */ public function testActiveThemePath() { $renderer = $this->getMock('\Drupal\Core\Render\RendererInterface'); + $registry = $this->prophesize(Registry::class); $extension = new TwigExtension($renderer); + $extension->setThemeRegistry($registry->reveal()); $theme_manager = $this->getMock('\Drupal\Core\Theme\ThemeManagerInterface'); $active_theme = $this->getMockBuilder('\Drupal\Core\Theme\ActiveTheme') ->disableOriginalConstructor() diff --git a/core/themes/bartik/templates/block--search-form-block.html.twig b/core/themes/bartik/templates/block--search-form-block.html.twig index b2af1ed..df24e95 100644 --- a/core/themes/bartik/templates/block--search-form-block.html.twig +++ b/core/themes/bartik/templates/block--search-form-block.html.twig @@ -1,4 +1,4 @@ -{% extends "@classy/block/block--search-form-block.html.twig" %} +{% extends "block--search-form-block.html.twig" %} {# /** * @file diff --git a/core/themes/bartik/templates/block--system-menu-block.html.twig b/core/themes/bartik/templates/block--system-menu-block.html.twig index d22cfbf..c065cf2 100644 --- a/core/themes/bartik/templates/block--system-menu-block.html.twig +++ b/core/themes/bartik/templates/block--system-menu-block.html.twig @@ -1,4 +1,4 @@ -{% extends "@classy/block/block--system-menu-block.html.twig" %} +{% extends "block--system-menu-block.html.twig" %} {# /** * @file diff --git a/core/themes/bartik/templates/page-title.html.twig b/core/themes/bartik/templates/page-title.html.twig index e061cd2..f728998 100644 --- a/core/themes/bartik/templates/page-title.html.twig +++ b/core/themes/bartik/templates/page-title.html.twig @@ -1,4 +1,4 @@ -{% extends "@classy/content/page-title.html.twig" %} +{% extends "page-title.html.twig" %} {# /** * @file diff --git a/core/themes/bartik/templates/status-messages.html.twig b/core/themes/bartik/templates/status-messages.html.twig index a4e1e9e..b9539e2 100644 --- a/core/themes/bartik/templates/status-messages.html.twig +++ b/core/themes/bartik/templates/status-messages.html.twig @@ -1,4 +1,4 @@ -{% extends "@classy/misc/status-messages.html.twig" %} +{% extends "status-messages.html.twig" %} {# /** * @file diff --git a/core/themes/seven/templates/block--local-actions-block.html.twig b/core/themes/seven/templates/block--local-actions-block.html.twig index 6539758..d960201 100644 --- a/core/themes/seven/templates/block--local-actions-block.html.twig +++ b/core/themes/seven/templates/block--local-actions-block.html.twig @@ -1,4 +1,4 @@ -{% extends "@block/block.html.twig" %} +{% extends "block.html.twig" %} {# /** * @file