diff --git a/core/includes/theme.inc b/core/includes/theme.inc index 3b156a5..21b38c9 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -115,7 +115,7 @@ function drupal_theme_rebuild() { */ function drupal_find_theme_functions($cache, $prefixes) { $implementations = []; - $grouped_functions = drupal_group_functions_by_prefix(); + $grouped_functions = \Drupal::service('theme.registry')->getPrefixGroupedUserFunctions(); foreach ($cache as $hook => $info) { foreach ($prefixes as $prefix) { @@ -162,25 +162,6 @@ function drupal_find_theme_functions($cache, $prefixes) { } /** - * Group all user functions by word before first underscore. - * - * @return array - * Functions grouped by the first prefix. - */ -function drupal_group_functions_by_prefix() { - $functions = get_defined_functions(); - - $grouped_functions = []; - // Splitting user defined functions into groups by the first prefix. - foreach ($functions['user'] as $function) { - list($first_prefix,) = explode('_', $function, 2); - $grouped_functions[$first_prefix][] = $function; - } - - return $grouped_functions; -} - -/** * Allows themes and/or theme engines to easily discover overridden templates. * * @param $cache diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php index bb89707..d25efb2 100644 --- a/core/lib/Drupal/Core/Theme/Registry.php +++ b/core/lib/Drupal/Core/Theme/Registry.php @@ -138,6 +138,22 @@ class Registry implements DestructableInterface { protected $themeManager; /** + * User function names grouped by the word before the first underscore. + * + * @var string[] + */ + protected $groupedFunctions = []; + + /** + * All user-defined functions that have been added to groupedFunctions. + * + * The array keys are the function names, for faster dereferencing. + * + * @var bool[] + */ + protected $seenFunctions = []; + + /** * Constructs a \Drupal\Core\Theme\Registry object. * * @param string $root @@ -342,9 +358,12 @@ protected function build() { $this->processExtension($cache, $this->theme->getEngine(), 'theme_engine', $this->theme->getName(), $this->theme->getPath()); } - // Finally, hooks provided by the theme itself. + // Hooks provided by the theme itself. $this->processExtension($cache, $this->theme->getName(), 'theme', $this->theme->getName(), $this->theme->getPath()); + // Discover and add all preprocess functions for theme hook suggestions. + $this->postProcessExtension($cache, $this->theme); + // Let modules and themes alter the registry. $this->moduleHandler->alter('theme_registry', $cache); $this->themeManager->alterForTheme($this->theme, 'theme_registry', $cache); @@ -420,7 +439,7 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) 'base hook' => TRUE, ); - $module_list = array_keys((array) $this->moduleHandler->getModuleList()); + $module_list = array_keys($this->moduleHandler->getModuleList()); // Invoke the hook_theme() implementation, preprocess what is returned, and // merge it into $cache. @@ -438,6 +457,12 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) $result[$hook]['type'] = $type; $result[$hook]['theme path'] = $path; + // If a theme hook has a base hook, mark its preprocess functions always + // incomplete in order to inherit the base hook's preprocess functions. + if (!empty($result[$hook]['base hook'])) { + $result[$hook]['incomplete preprocess functions'] = TRUE; + } + if (isset($cache[$hook]['includes'])) { $result[$hook]['includes'] = $cache[$hook]['includes']; } @@ -556,14 +581,142 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) $cache[$hook]['preprocess functions'][] = $name . '_preprocess_' . $hook; $cache[$hook]['theme path'] = $path; } - // Ensure uniqueness. - $cache[$hook]['preprocess functions'] = array_unique($cache[$hook]['preprocess functions']); } } } } /** + * Completes the definition of the requested suggestion hook. + * + * @param string $hook + * The name of the suggestion hook to complete. + * @param array $cache + * The theme registry, as documented in + * \Drupal\Core\Theme\Registry::processExtension(). + */ + protected function completeSuggestion($hook, array &$cache) { + $previous_hook = $hook; + $incomplete_previous_hook = array(); + while ((!isset($cache[$previous_hook]) || isset($cache[$previous_hook]['incomplete preprocess functions'])) + && $pos = strrpos($previous_hook, '__')) { + if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && isset($cache[$previous_hook]['incomplete preprocess functions'])) { + $incomplete_previous_hook = $cache[$previous_hook]; + unset($incomplete_previous_hook['incomplete preprocess functions']); + } + $previous_hook = substr($previous_hook, 0, $pos); + + // If base hook exists clone of it for the preprocess function + // without a template. + // @see https://www.drupal.org/node/2457295 + if (isset($cache[$previous_hook]) && !isset($cache[$previous_hook]['incomplete preprocess functions'])) { + $cache[$hook] = $incomplete_previous_hook + $cache[$previous_hook]; + if (isset($incomplete_previous_hook['preprocess functions'])) { + $diff = array_diff($incomplete_previous_hook['preprocess functions'], $cache[$previous_hook]['preprocess functions']); + $cache[$hook]['preprocess functions'] = array_merge($cache[$previous_hook]['preprocess functions'], $diff); + } + // If a base hook isn't set, this is the actual base hook. + if (!isset($cache[$previous_hook]['base hook'])) { + $cache[$hook]['base hook'] = $previous_hook; + } + } + } + } + + /** + * Completes the theme registry adding discovered functions and hooks. + * + * @param array $cache + * The theme registry as documented in + * \Drupal\Core\Theme\Registry::processExtension(). + * @param \Drupal\Core\Theme\ActiveTheme $theme + * Current active theme. + * + * @see ::processExtension() + */ + protected function postProcessExtension(array &$cache, ActiveTheme $theme) { + $grouped_functions = $this->getPrefixGroupedUserFunctions(); + + // Gather prefixes. This will be used to limit the found functions to the + // expected naming conventions. + $prefixes = array_keys((array) $this->moduleHandler->getModuleList()); + foreach (array_reverse($theme->getBaseThemes()) as $base) { + $prefixes[] = $base->getName(); + } + if ($theme->getEngine()) { + $prefixes[] = $theme->getEngine() . '_engine'; + } + $prefixes[] = $theme->getName(); + + // Collect all variable preprocess functions in the correct order. + $suggestion_level = []; + $matches = []; + // Look for functions named according to the pattern and add them if they + // have matching hooks in the registry. + foreach ($prefixes as $prefix) { + // Grep only the functions which are within the prefix group. + list($first_prefix,) = explode('_', $prefix, 2); + if (!isset($grouped_functions[$first_prefix])) { + continue; + } + // Add the function and the name of the associated theme hook to the list + // of preprocess functions grouped by suggestion specificity if a matching + // base hook is found. + foreach ($grouped_functions[$first_prefix] as $candidate) { + if (preg_match("/^{$prefix}_preprocess_(((?:[^_]++|_(?!_))+)__.*)/", $candidate, $matches)) { + if (isset($cache[$matches[2]])) { + $level = substr_count($matches[1], '__'); + $suggestion_level[$level][$candidate] = $matches[1]; + } + } + } + } + + // Add missing variable preprocessors. This is needed for modules that do + // not explicitly register the hook. For example, when a theme contains a + // variable preprocess function but it does not implement a template, it + // will go missing. This will add the expected function. It also allows + // modules or themes to have a variable process function based on a pattern + // even if the hook does not exist. + for ($level = 1; $level <= count($suggestion_level); $level++) { + foreach ($suggestion_level[$level] as $preprocessor => $hook) { + if (isset($cache[$hook]['preprocess functions']) && !in_array($hook, $cache[$hook]['preprocess functions'])) { + // Add missing preprocessor to existing hook. + $cache[$hook]['preprocess functions'][] = $preprocessor; + } + elseif (!isset($cache[$hook]) && strpos($hook, '__')) { + // Process non-existing hook and register it. + // Look for a previously defined hook that is either a less specific + // suggestion hook or the base hook. + $this->completeSuggestion($hook, $cache); + $cache[$hook]['preprocess functions'][] = $preprocessor; + } + } + } + // Inherit all base hook variable preprocess functions into suggestion + // hooks. This ensures that derivative hooks have a complete set of variable + // preprocess functions. + foreach ($cache as $hook => $info) { + // The 'base hook' is only applied to derivative hooks already registered + // from a pattern. This is typically set from + // drupal_find_theme_functions() and drupal_find_theme_templates(). + if (isset($info['incomplete preprocess functions'])) { + $this->completeSuggestion($hook, $cache); + unset($cache[$hook]['incomplete preprocess functions']); + } + + // Optimize the registry. + if (isset($cache[$hook]['preprocess functions']) && empty($cache[$hook]['preprocess functions'])) { + unset($cache[$hook]['preprocess functions']); + } + // Ensure uniqueness. + if (isset($cache[$hook]['preprocess functions'])) { + $cache[$hook]['preprocess functions'] = array_unique($cache[$hook]['preprocess functions']); + } + } + } + + /** * Invalidates theme registry caches. * * To be called when the list of enabled extensions is changed. @@ -590,6 +743,34 @@ public function destruct() { } /** + * Gets all user functions grouped by the word before the first underscore. + * + * @return array + * Functions grouped by the first prefix. + */ + public function getPrefixGroupedUserFunctions() { + $functions = get_defined_functions(); + + // Splitting user defined functions into groups by the first prefix. + foreach ($functions['user'] as $function) { + if (isset($this->seenFunctions[$function])) { + continue; + } + list($first_prefix,) = explode('_', $function, 2); + $this->groupedFunctions[$first_prefix][] = $function; + } + + // The theme registry may load new code. On encountering newly defined + // functions, we save the list of defined functions again. This works + // because functions cannot disappear between calls. + if (isset($first_prefix)) { + $this->seenFunctions = array_fill_keys($functions['user'], TRUE); + } + + return $this->groupedFunctions; + } + + /** * Wraps drupal_get_path(). * * @param string $module diff --git a/core/lib/Drupal/Core/Theme/ThemeManager.php b/core/lib/Drupal/Core/Theme/ThemeManager.php index 31c10a7..5a2af2e 100644 --- a/core/lib/Drupal/Core/Theme/ThemeManager.php +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php @@ -279,12 +279,10 @@ public function render($hook, array $variables) { include_once $this->root . '/' . $include_file; } } - // Replace the preprocess functions with those from the base hook. if (isset($base_hook_info['preprocess functions'])) { // Set a variable for the 'theme_hook_suggestion'. This is used to // maintain backwards compatibility with template engines. $theme_hook_suggestion = $hook; - $info['preprocess functions'] = $base_hook_info['preprocess functions']; } } if (isset($info['preprocess functions'])) { diff --git a/core/modules/system/src/Tests/Theme/RegistryTest.php b/core/modules/system/src/Tests/Theme/RegistryTest.php index 8a72ecc..e6c80be 100644 --- a/core/modules/system/src/Tests/Theme/RegistryTest.php +++ b/core/modules/system/src/Tests/Theme/RegistryTest.php @@ -99,6 +99,46 @@ public function testMultipleSubThemes() { 'template_preprocess', 'test_basetheme_preprocess_theme_test_template_test', ], $preprocess_functions); + + } + + /** + * Tests the theme registry with suggestions. + */ + public function testSuggestionPreprocessFunctions() { + $theme_handler = \Drupal::service('theme_handler'); + $theme_handler->install(['test_theme']); + + $registry_theme = new Registry(\Drupal::root(), \Drupal::cache(), \Drupal::lock(), \Drupal::moduleHandler(), $theme_handler, \Drupal::service('theme.initialization'), 'test_theme'); + $registry_theme->setThemeManager(\Drupal::theme()); + + $suggestions = ['__kitten', '__flamingo']; + $expected_preprocess_functions = [ + 'template_preprocess', + 'theme_test_preprocess_theme_test_preprocess_suggestions', + ]; + $suggestion = ''; + $hook = 'theme_test_preprocess_suggestions'; + do { + $hook .= "$suggestion"; + $expected_preprocess_functions[] = "test_theme_preprocess_$hook"; + $preprocess_functions = $registry_theme->get()[$hook]['preprocess functions']; + $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, "$hook has correct preprocess functions."); + } while ($suggestion = array_shift($suggestions)); + + $expected_preprocess_functions = [ + 'template_preprocess', + 'theme_test_preprocess_theme_test_preprocess_suggestions', + 'test_theme_preprocess_theme_test_preprocess_suggestions', + 'test_theme_preprocess_theme_test_preprocess_suggestions__kitten', + ]; + + $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__meerkat']['preprocess functions']; + $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, 'Suggestion implemented as a function correctly inherits preprocess functions.'); + + $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__bearcat']['preprocess functions']; + $this->assertIdentical($expected_preprocess_functions, $preprocess_functions, 'Suggestion implemented as a template correctly inherits preprocess functions.'); + } /** diff --git a/core/modules/system/src/Tests/Theme/ThemeTest.php b/core/modules/system/src/Tests/Theme/ThemeTest.php index c2c0e0b..9db4fa8 100644 --- a/core/modules/system/src/Tests/Theme/ThemeTest.php +++ b/core/modules/system/src/Tests/Theme/ThemeTest.php @@ -277,7 +277,7 @@ function testPreprocessHtml() { /** * Tests that region attributes can be manipulated via preprocess functions. */ - function testRegionClass() { + public function testRegionClass() { \Drupal::service('module_installer')->install(array('block', 'theme_region_test')); // Place a block. @@ -287,4 +287,31 @@ function testRegionClass() { $this->assertEqual(count($elements), 1, 'New class found.'); } + /** + * Ensures suggestion preprocess functions run for default implementations. + * + * The theme hook used by this test has its base preprocess function in a + * separate file, so this test also ensures that that file is correctly loaded + * when needed. + */ + public function testSuggestionPreprocessForDefaults() { + \Drupal::service('theme_handler')->setDefault('test_theme'); + // Test with both an unprimed and primed theme registry. + drupal_theme_rebuild(); + for ($i = 0; $i < 2; $i++) { + $this->drupalGet('theme-test/preprocess-suggestions'); + $items = $this->cssSelect('.suggestion'); + $expected_values = [ + 'Suggestion', + 'Kitten', + 'Monkey', + 'Kitten', + 'Flamingo', + ]; + foreach ($expected_values as $key => $value) { + $this->assertEqual((string) $value, $items[$key]); + } + } + } + } diff --git a/core/modules/system/tests/modules/theme_test/src/ThemeTestController.php b/core/modules/system/tests/modules/theme_test/src/ThemeTestController.php index a8f16c6..68b627e 100644 --- a/core/modules/system/tests/modules/theme_test/src/ThemeTestController.php +++ b/core/modules/system/tests/modules/theme_test/src/ThemeTestController.php @@ -143,4 +143,25 @@ public function nonHtml() { return new JsonResponse(['theme_initialized' => $theme_initialized]); } + /** + * Controller for testing preprocess functions with theme suggestions. + */ + public function preprocessSuggestions() { + return [ + [ + '#theme' => 'theme_test_preprocess_suggestions', + '#foo' => 'suggestion', + ], + [ + '#theme' => 'theme_test_preprocess_suggestions', + '#foo' => 'kitten', + ], + [ + '#theme' => 'theme_test_preprocess_suggestions', + '#foo' => 'monkey', + ], + ['#theme' => 'theme_test_preprocess_suggestions__kitten__flamingo'], + ]; + } + } diff --git a/core/modules/system/tests/modules/theme_test/templates/theme-test-preprocess-suggestions.html.twig b/core/modules/system/tests/modules/theme_test/templates/theme-test-preprocess-suggestions.html.twig new file mode 100644 index 0000000..512613d --- /dev/null +++ b/core/modules/system/tests/modules/theme_test/templates/theme-test-preprocess-suggestions.html.twig @@ -0,0 +1,4 @@ +
{{ foo }}
+{% if bar %} +
{{ bar }}
+{% endif %} diff --git a/core/modules/system/tests/modules/theme_test/theme_test.module b/core/modules/system/tests/modules/theme_test/theme_test.module index 7ba5c2d..8b820c3 100644 --- a/core/modules/system/tests/modules/theme_test/theme_test.module +++ b/core/modules/system/tests/modules/theme_test/theme_test.module @@ -55,6 +55,12 @@ function theme_test_theme($existing, $type, $theme, $path) { $info['test_theme_not_existing_function'] = array( 'function' => 'test_theme_not_existing_function', ); + $items['theme_test_preprocess_suggestions'] = [ + 'variables' => [ + 'foo' => '', + 'bar' => '', + ], + ]; return $items; } @@ -90,6 +96,27 @@ function theme_theme_test_function_template_override($variables) { } /** + * Implements hook_theme_suggestions_HOOK(). + */ +function theme_test_theme_suggestions_theme_test_preprocess_suggestions($variables) { + return ['theme_test_preprocess_suggestions__' . $variables['foo']]; +} + +/** + * Implements hook_preprocess_HOOK(). + */ +function theme_test_preprocess_theme_test_preprocess_suggestions(&$variables) { + $variables['foo'] = 'Theme hook implementor=theme_theme_test_preprocess_suggestions().'; +} + +/** + * Tests a module overriding a default hook with a suggestion. + */ +function theme_test_preprocess_theme_test_preprocess_suggestions__monkey(&$variables) { + $variables['foo'] = 'Monkey'; +} + +/** * Prepares variables for test render element templates. * * Default template: theme-test-render-element.html.twig. diff --git a/core/modules/system/tests/modules/theme_test/theme_test.routing.yml b/core/modules/system/tests/modules/theme_test/theme_test.routing.yml index 2dbd188..1ff61cf 100644 --- a/core/modules/system/tests/modules/theme_test/theme_test.routing.yml +++ b/core/modules/system/tests/modules/theme_test/theme_test.routing.yml @@ -103,3 +103,10 @@ theme_test.non_html: _controller: '\Drupal\theme_test\ThemeTestController::nonHtml' requirements: _access: 'TRUE' + +theme_test.preprocess_suggestions: + path: '/theme-test/preprocess-suggestions' + defaults: + _controller: '\Drupal\theme_test\ThemeTestController::preprocessSuggestions' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/themes/test_theme/templates/theme-test-preprocess-suggestions--kitten--bearcat.html.twig b/core/modules/system/tests/themes/test_theme/templates/theme-test-preprocess-suggestions--kitten--bearcat.html.twig new file mode 100644 index 0000000..512613d --- /dev/null +++ b/core/modules/system/tests/themes/test_theme/templates/theme-test-preprocess-suggestions--kitten--bearcat.html.twig @@ -0,0 +1,4 @@ +
{{ foo }}
+{% if bar %} +
{{ bar }}
+{% endif %} diff --git a/core/modules/system/tests/themes/test_theme/templates/theme-test-preprocess-suggestions--suggestion.html.twig b/core/modules/system/tests/themes/test_theme/templates/theme-test-preprocess-suggestions--suggestion.html.twig new file mode 100644 index 0000000..e77924d --- /dev/null +++ b/core/modules/system/tests/themes/test_theme/templates/theme-test-preprocess-suggestions--suggestion.html.twig @@ -0,0 +1 @@ +
{{ foo }}
diff --git a/core/modules/system/tests/themes/test_theme/test_theme.theme b/core/modules/system/tests/themes/test_theme/test_theme.theme index 9350755..3ce72e3 100644 --- a/core/modules/system/tests/themes/test_theme/test_theme.theme +++ b/core/modules/system/tests/themes/test_theme/test_theme.theme @@ -105,3 +105,40 @@ function test_theme_theme_test_function_suggestions__module_override($variables) function test_theme_theme_registry_alter(&$registry) { $registry['theme_test_template_test']['variables']['additional'] = 'value'; } + +/** + * Tests a theme overriding a suggestion of a base theme hook. + */ +function test_theme_theme_test_preprocess_suggestions__kitten__meerkat($variables) { + return 'Theme hook implementor=test_theme_theme_test__suggestion(). Foo=' . $variables['foo']; +} + +/** + * Tests a theme overriding a default hook with a suggestion. + * + * Implements hook_preprocess_HOOK(). + */ +function test_theme_preprocess_theme_test_preprocess_suggestions(&$variables) { + $variables['foo'] = 'Theme hook implementor=test_theme_preprocess_theme_test_preprocess_suggestions().'; +} + +/** + * Tests a theme overriding a default hook with a suggestion. + */ +function test_theme_preprocess_theme_test_preprocess_suggestions__suggestion(&$variables) { + $variables['foo'] = 'Suggestion'; +} + +/** + * Tests a theme overriding a default hook with a suggestion. + */ +function test_theme_preprocess_theme_test_preprocess_suggestions__kitten(&$variables) { + $variables['foo'] = 'Kitten'; +} + +/** + * Tests a theme overriding a default hook with a suggestion. + */ +function test_theme_preprocess_theme_test_preprocess_suggestions__kitten__flamingo(&$variables) { + $variables['bar'] = 'Flamingo'; +} diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php index 4a9101d..0cb5a25 100644 --- a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php @@ -105,6 +105,9 @@ public function testGetRegistryForModule() { ->method('getImplementations') ->with('theme') ->will($this->returnValue(array('theme_test'))); + $this->moduleHandler->expects($this->atLeastOnce()) + ->method('getModuleList') + ->willReturn([]); $registry = $this->registry->get();