diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php index 094780a10e..883c7e421f 100644 --- a/core/lib/Drupal/Core/Theme/Registry.php +++ b/core/lib/Drupal/Core/Theme/Registry.php @@ -2,7 +2,6 @@ namespace Drupal\Core\Theme; -use Drupal\Component\Utility\NestedArray; use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\DestructableInterface; @@ -351,6 +350,7 @@ protected function build() { // and preprocess functions comes first. foreach (array_reverse($this->theme->getBaseThemeExtensions()) as $base) { // If the base theme uses a theme engine, process its hooks. + /** @var \Drupal\Core\Theme\ActiveTheme $base */ $base_path = $base->getPath(); if ($this->theme->getEngine()) { $this->processExtension($cache, $this->theme->getEngine(), 'base_theme_engine', $base->getName(), $base_path); @@ -374,12 +374,6 @@ protected function build() { $this->themeManager->alterForTheme($this->theme, 'theme_registry', $cache); // @todo Implement more reduction of the theme registry entry. - // Optimize the registry to not have empty arrays for functions. - foreach ($cache as $hook => $info) { - if (empty($info['preprocess functions'])) { - unset($cache[$hook]['preprocess functions']); - } - } $this->registry[$this->theme->getName()] = $cache; return $this->registry[$this->theme->getName()]; @@ -438,13 +432,6 @@ protected function build() { protected function processExtension(array &$cache, $name, $type, $theme, $path) { $result = []; - $hook_defaults = [ - 'variables' => TRUE, - 'render element' => TRUE, - 'pattern' => TRUE, - 'base hook' => TRUE, - ]; - $module_list = array_keys($this->moduleHandler->getModuleList()); // Invoke the hook_theme() implementation, preprocess what is returned, and @@ -453,139 +440,32 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) if (function_exists($function)) { $result = $function($cache, $type, $theme, $path); foreach ($result as $hook => $info) { + // If there was no string $hook provided retrieve it from the ThemeHook. + if ($info instanceof ThemeHook && is_int($hook)) { + $hook = $info->getName(); + } + + // @todo Remove support for legacy array-based theme hooks in + // https://www.drupal.org/node/2873117. + if (is_array($info)) { + $info = ThemeHook::createFromLegacy($hook, $info); + } // When a theme or engine overrides a module's theme function // $result[$hook] will only contain key/value pairs for information being // overridden. Pull the rest of the information from what was defined by // an earlier hook. + $cached_info = isset($cache[$hook]) ? $cache[$hook] : NULL; - // Fill in the type and path of the module, theme, or engine that + // Fill in the name, type, and path of the module, theme, or engine that // implements this theme function. - $result[$hook]['type'] = $type; - $result[$hook]['theme path'] = $path; + $info->setProvider($name); + $info->setProviderType($type); + $info->setThemePath($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']; - } - - // Load the includes, as they may contain preprocess functions. - if (isset($info['includes'])) { - foreach ($info['includes'] as $include_file) { - include_once $this->root . '/' . $include_file; - } - } - - // If the theme implementation defines a file, then also use the path - // that it defined. Otherwise use the default path. This allows - // system.module to declare theme functions on behalf of core .include - // files. - if (isset($info['file'])) { - $include_file = isset($info['path']) ? $info['path'] : $path; - $include_file .= '/' . $info['file']; - include_once $this->root . '/' . $include_file; - $result[$hook]['includes'][] = $include_file; - } - - // A template file is the default implementation for a theme hook, but - // if the theme hook specifies a function callback instead, check to - // ensure the function actually exists. - if (isset($info['function'])) { - trigger_error(sprintf('Theme functions are deprecated in drupal:8.0.0 and are removed from drupal:10.0.0. Use Twig templates instead of %s(). See https://www.drupal.org/node/1831138', $info['function']), E_USER_DEPRECATED); - if (!function_exists($info['function'])) { - throw new \BadFunctionCallException(sprintf( - 'Theme hook "%s" refers to a theme function callback that does not exist: "%s"', - $hook, - $info['function'] - )); - } - } - // Provide a default naming convention for 'template' based on the - // hook used. If the template does not exist, the theme engine used - // should throw an exception at runtime when attempting to include - // the template file. - elseif (!isset($info['template'])) { - $info['template'] = strtr($hook, '_', '-'); - $result[$hook]['template'] = $info['template']; - } - - // Prepend the current theming path when none is set. This is required - // for the default theme engine to know where the template lives. - if (isset($result[$hook]['template']) && !isset($info['path'])) { - $result[$hook]['path'] = $path . '/templates'; - } - - // If the default keys are not set, use the default values registered - // by the module. - if (isset($cache[$hook])) { - $result[$hook] += array_intersect_key($cache[$hook], $hook_defaults); - } - - // Preprocess variables for all theming hooks, whether the hook is - // implemented as a template or as a function. Ensure they are arrays. - if (!isset($info['preprocess functions']) || !is_array($info['preprocess functions'])) { - $info['preprocess functions'] = []; - $prefixes = []; - if ($type == 'module') { - // Default variable preprocessor prefix. - $prefixes[] = 'template'; - // Add all modules so they can intervene with their own variable - // preprocessors. This allows them to provide variable preprocessors - // even if they are not the owner of the current hook. - $prefixes = array_merge($prefixes, $module_list); - } - elseif ($type == 'theme_engine' || $type == 'base_theme_engine') { - // Theme engines get an extra set that come before the normally - // named variable preprocessors. - $prefixes[] = $name . '_engine'; - // The theme engine registers on behalf of the theme using the - // theme's name. - $prefixes[] = $theme; - } - else { - // This applies when the theme manually registers their own variable - // preprocessors. - $prefixes[] = $name; - } - foreach ($prefixes as $prefix) { - // Only use non-hook-specific variable preprocessors for theming - // hooks implemented as templates. See the @defgroup themeable - // topic. - if (isset($info['template']) && function_exists($prefix . '_preprocess')) { - $info['preprocess functions'][] = $prefix . '_preprocess'; - } - if (function_exists($prefix . '_preprocess_' . $hook)) { - $info['preprocess functions'][] = $prefix . '_preprocess_' . $hook; - } - } - } - // Check for the override flag and prevent the cached variable - // preprocessors from being used. This allows themes or theme engines - // to remove variable preprocessors set earlier in the registry build. - if (!empty($info['override preprocess functions'])) { - // Flag not needed inside the registry. - unset($result[$hook]['override preprocess functions']); - } - elseif (isset($cache[$hook]['preprocess functions']) && is_array($cache[$hook]['preprocess functions'])) { - $info['preprocess functions'] = array_merge($cache[$hook]['preprocess functions'], $info['preprocess functions']); - } - $result[$hook]['preprocess functions'] = $info['preprocess functions']; - - // If a theme implementation definition provides both 'template' and - // 'function', the 'function' will be used. In this case, if the new - // result provides a 'template' value, any existing 'function' value - // must be removed for the override to be called. - if (isset($result[$hook]['template'])) { - unset($cache[$hook]['function']); - } + // Process the theme hook. + $cache[$hook] = $info->process($this->root, $theme, $module_list, $cached_info); } - // Merge the newly created theme hooks into the existing cache. - $cache = NestedArray::mergeDeep($cache, $result); } // Let themes have variable preprocessors even if they didn't register a @@ -594,17 +474,15 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) foreach ($cache as $hook => $info) { // Check only if not registered by the theme or engine. if (empty($result[$hook])) { - if (!isset($info['preprocess functions'])) { - $cache[$hook]['preprocess functions'] = []; - } + // Only use non-hook-specific variable preprocessors for theme hooks // implemented as templates. See the @defgroup themeable topic. - if (isset($info['template']) && function_exists($name . '_preprocess')) { - $cache[$hook]['preprocess functions'][] = $name . '_preprocess'; + if ($info->getTemplate() && function_exists($name . '_preprocess')) { + $info->addPreprocessFunction($name . '_preprocess'); } if (function_exists($name . '_preprocess_' . $hook)) { - $cache[$hook]['preprocess functions'][] = $name . '_preprocess_' . $hook; - $cache[$hook]['theme path'] = $path; + $info->addPreprocessFunction($name . '_preprocess_' . $hook); + $info->setThemePath($path); } } } @@ -616,64 +494,71 @@ protected function processExtension(array &$cache, $name, $type, $theme, $path) * * @param string $hook * The name of the suggestion hook to complete. - * @param array $cache + * @param \Drupal\Core\Theme\ThemeHook[] $cache * The theme registry, as documented in * \Drupal\Core\Theme\Registry::processExtension(). */ protected function completeSuggestion($hook, array &$cache) { $previous_hook = $hook; - $incomplete_previous_hook = []; + $incomplete_previous_hook = NULL; // Continue looping if the candidate hook doesn't exist or if the candidate // hook has incomplete preprocess functions, and if the candidate hook is a // suggestion (has a double underscore). - while ((!isset($cache[$previous_hook]) || isset($cache[$previous_hook]['incomplete preprocess functions'])) + while ((!isset($cache[$previous_hook]) || $cache[$previous_hook]->isIncomplete()) && $pos = strrpos($previous_hook, '__')) { // Find the first existing candidate hook that has incomplete preprocess // functions. - if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && isset($cache[$previous_hook]['incomplete preprocess functions'])) { + if (isset($cache[$previous_hook]) && !$incomplete_previous_hook && $cache[$previous_hook]->isIncomplete()) { $incomplete_previous_hook = $cache[$previous_hook]; - unset($incomplete_previous_hook['incomplete preprocess functions']); } $previous_hook = substr($previous_hook, 0, $pos); - $this->mergePreprocessFunctions($hook, $previous_hook, $incomplete_previous_hook, $cache); + $this->mergeHookFromSuggestion($hook, $previous_hook, $cache, $incomplete_previous_hook); } - // In addition to processing suggestions, include base hooks. - if (isset($cache[$hook]['base hook'])) { - // In order to retain the additions from above, pass in the current hook - // as the parent hook, otherwise it will be overwritten. - $this->mergePreprocessFunctions($hook, $cache[$hook]['base hook'], $cache[$hook], $cache); + // If a theme hook specifies a base hook, and that base hook is its own + // theme hook and has a complete list of preprocess functions, merge it into + // the current hook. + $base_hook = $cache[$hook]->getBaseHook(); + if ($base_hook && isset($cache[$base_hook]) && !$cache[$base_hook]->isIncomplete()) { + $cache[$hook] = $cache[$hook]->merge($cache[$base_hook]); } + + $cache[$hook]->markComplete(); } /** - * Merges the source hook's preprocess functions into the destination hook's. + * Merges the source hook into the destination hook. * * @param string $destination_hook_name - * The name of the hook to merge preprocess functions to. + * The name of the hook being merged into. * @param string $source_hook_name - * The name of the hook to merge preprocess functions from. - * @param array $parent_hook - * The parent hook if it exists. Either an incomplete hook from suggestions - * or a base hook. - * @param array $cache + * The name of the hook being merged from. + * @param \Drupal\Core\Theme\ThemeHook[] $cache * The theme registry, as documented in * \Drupal\Core\Theme\Registry::processExtension(). + * @param \Drupal\Core\Theme\ThemeHook $parent_hook + * The parent hook if it exists. Either an incomplete hook from suggestions + * or a base hook. */ - protected function mergePreprocessFunctions($destination_hook_name, $source_hook_name, $parent_hook, array &$cache) { - // If base hook exists clone of it for the preprocess function - // without a template. - // @see https://www.drupal.org/node/2457295 - if (isset($cache[$source_hook_name]) && (!isset($cache[$source_hook_name]['incomplete preprocess functions']) || !isset($cache[$destination_hook_name]['incomplete preprocess functions']))) { - $cache[$destination_hook_name] = $parent_hook + $cache[$source_hook_name]; - if (isset($parent_hook['preprocess functions'])) { - $diff = array_diff($parent_hook['preprocess functions'], $cache[$source_hook_name]['preprocess functions']); - $cache[$destination_hook_name]['preprocess functions'] = array_merge($cache[$source_hook_name]['preprocess functions'], $diff); - } - // If a base hook isn't set, this is the actual base hook. - if (!isset($cache[$source_hook_name]['base hook'])) { - $cache[$destination_hook_name]['base hook'] = $source_hook_name; + protected function mergeHookFromSuggestion($destination_hook_name, $source_hook_name, array &$cache, ThemeHook $parent_hook = NULL) { + // If the source hook doesn't exist, do not continue. + if (!isset($cache[$source_hook_name])) { + return; + } + + // If either of the source or destination hook have complete preprocess + // functions, or the destination hook does not exist yet, continue. + if (!$cache[$source_hook_name]->isIncomplete() || (!isset($cache[$destination_hook_name]) || !$cache[$destination_hook_name]->isIncomplete())) { + $to_be_merged = $cache[$source_hook_name]; + // If a parent hook was provided, use it as the basis for a merged result. + if ($parent_hook) { + $to_be_merged = $parent_hook->merge($to_be_merged); } + $cache[$destination_hook_name] = ThemeHook::createFromExisting($destination_hook_name, $to_be_merged)->merge($to_be_merged); + } + // If a base hook isn't set, this is the actual base hook. + if (!$cache[$destination_hook_name]->getBaseHook()) { + $cache[$destination_hook_name]->setBaseHook($source_hook_name); } } @@ -692,7 +577,9 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) { // 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->getBaseThemeExtensions()) as $base) { + /** @var \Drupal\Core\Theme\ActiveTheme $base */ $prefixes[] = $base->getName(); } if ($theme->getEngine()) { @@ -735,16 +622,16 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) { ksort($suggestion_level); foreach ($suggestion_level as $level => $item) { foreach ($item as $preprocessor => $hook) { - if (isset($cache[$hook]['preprocess functions']) && !in_array($hook, $cache[$hook]['preprocess functions'])) { + if (isset($cache[$hook]) && !$cache[$hook]->hasPreprocessFunction($hook)) { // Add missing preprocessor to existing hook. - $cache[$hook]['preprocess functions'][] = $preprocessor; + $cache[$hook]->addPreprocessFunction($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; + $cache[$hook]->addPreprocessFunction($preprocessor); } } } @@ -755,18 +642,8 @@ protected function postProcessExtension(array &$cache, ActiveTheme $theme) { // 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'])) { + if ($info->isIncomplete()) { $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']); } } } diff --git a/core/lib/Drupal/Core/Theme/ThemeHook.php b/core/lib/Drupal/Core/Theme/ThemeHook.php new file mode 100644 index 0000000000..5bf9610f51 --- /dev/null +++ b/core/lib/Drupal/Core/Theme/ThemeHook.php @@ -0,0 +1,1075 @@ +name = $name; + } + + /** + * Creates a new ThemeHook instance. + * + * @param string $name + * The machine name of the theme hook. + * + * @return static + */ + public static function create($name) { + return new static($name); + } + + /** + * Creates a new ThemeHook instance based on an existing ThemeHook. + * + * This does not copy every single property, only those considered default. + * + * @param string $name + * The machine name of the theme hook. + * @param \Drupal\Core\Theme\ThemeHook $other + * Another theme hook to use as the basis for a new theme hook. + * + * @return static + */ + public static function createFromExisting($name, ThemeHook $other) { + $instance = static::create($name); + $instance->handleDefaultValues($other); + return $instance; + } + + /** + * Constructs a new ThemeHook using the legacy array-based format. + * + * @param string $name + * The machine name of the theme hook. + * @param array $values + * An array of values. + * + * @return static + * + * @deprecated + */ + public static function createFromLegacy($name, array $values) { + $instance = static::create($name); + foreach ($values as $key => $value) { + $instance->offsetSet($key, $value); + } + return $instance; + } + + /** + * Merges another theme hook into the values of the current theme hook. + * + * This does not modify the current theme hook, but returns a new instance. + * + * @param \Drupal\Core\Theme\ThemeHook $other + * Another theme hook to use as the basis for a new theme hook. + * + * @return static + */ + public function merge(ThemeHook $other) { + $result = clone $this; + + // If this object doesn't have a value, use the value from the other object. + if (!$result->getProviderType()) { + $result->setProviderType($other->getProviderType()); + } + if (!$result->getFile()) { + $result->setFile($other->getFile()); + } + if (!$result->getThemePath()) { + $result->setThemePath($other->getThemePath()); + } + if (!$result->getPath()) { + $result->setPath($other->getPath()); + } + if (!$result->isPreprocessOverridden()) { + $result->overridePreprocess($other->isPreprocessOverridden()); + } + if ($other->hasVariables() && !$result->hasVariables()) { + $result->setVariables($other->getVariables()); + } + if (!$result->getPattern()) { + $result->setPattern($other->getPattern()); + } + if (!$result->getBaseHook()) { + $result->setBaseHook($other->getBaseHook()); + } + if ($other->getRenderElement() && !$result->getRenderElement()) { + $result->setRenderElement($other->getRenderElement()); + } + + // Only copy over the function and template if the original has neither. + if (!$result->getFunction() && !$result->getTemplate()) { + $result->setTemplate($other->getTemplate()); + $result->setFunction($other->getFunction()); + } + + // If the other object does not yet have a complete list of preprocess + // functions, mark the result as incomplete. + if ($other->isIncomplete()) { + $result->markIncomplete(); + } + + // Merge all includes together. + $result->setIncludes(array_merge($other->getIncludes(), $result->getIncludes())); + + // Check for the override flag and prevent the cached variable preprocessors + // from being used. This allows themes or theme engines to remove variable + // preprocessors set earlier in the registry build. + if (!$result->isPreprocessOverridden()) { + $result->setPreprocessFunctions(array_merge($other->getPreprocessFunctions(), $result->getPreprocessFunctions())); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function &offsetGet($name) { + $value = NULL; + $name = str_replace(' ', '_', $name); + if (property_exists($this, $name)) { + $value = &$this->{$name}; + } + else { + if (isset($this->extra[$name])) { + $value = &$this->extra[$name]; + } + } + return $value; + } + + /** + * {@inheritdoc} + */ + public function offsetSet($name, $value) { + $name = str_replace(' ', '_', $name); + if (property_exists($this, $name)) { + $this->{$name} = $value; + } + else { + $this->extra[$name] = $value; + } + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($name) { + $name = str_replace(' ', '_', $name); + if (property_exists($this, $name)) { + $reflection = new \ReflectionClass($this); + $this->{$name} = $reflection->getDefaultProperties()[$name]; + } + else { + unset($this->extra[$name]); + } + } + + /** + * {@inheritdoc} + */ + public function offsetExists($name) { + $name = str_replace(' ', '_', $name); + if (property_exists($this, $name)) { + return isset($this->{$name}); + } + else { + return isset($this->extra[$name]); + } + } + + /** + * Gets the directory path of the theme or module. + * + * @return string + * The directory path of the theme or module. + */ + public function getThemePath() { + return $this->theme_path; + } + + /** + * Sets the directory path of the theme or module. + * + * @param string $theme_path + * The directory path of the theme or module. + * + * @return $this + */ + public function setThemePath($theme_path) { + $this->theme_path = $theme_path; + + return $this; + } + + /** + * Gets the type of provider of the theme hook. + * + * @return string + * The type of provider of the theme hook. May be one of: + * - 'module': A module is being checked for theme implementations. + * - 'base_theme_engine': A theme engine is being checked for a theme that + * is a parent of the actual theme being used. + * - 'theme_engine': A theme engine is being checked for the actual theme + * being used. + * - 'base_theme': A base theme is being checked for theme implementations. + * - 'theme': The actual theme in use is being checked. + */ + public function getProviderType() { + return $this->type; + } + + /** + * Sets the type of provider of the theme hook. + * + * @param string $type + * The type of provider of the theme hook. + * + * @return $this + */ + public function setProviderType($type) { + $this->type = $type; + + return $this; + } + + /** + * Gets the provider of the theme hook. + * + * @return string + * The provider of the theme hook. + */ + public function getProvider() { + return $this->type; + } + + /** + * Sets the provider of the theme hook. + * + * @param string $type + * The provider of the theme hook. + * + * @return $this + */ + public function setProvider($type) { + $this->type = $type; + + return $this; + } + + /** + * Returns the default values to be passed to the template. + * + * @return mixed[]|null + * An associative array where the keys are names of variables, and the + * values are the default values if they are not given in the render array. + * If this returns NULL, self::getRenderElement() will be used instead. + */ + public function getVariables() { + return $this->variables; + } + + /** + * Sets the default values to be passed to the template. + * + * Only used for #theme in render array. Template implementations receive each + * array key as a variable in the template file (so they must be legal + * PHP/Twig variable names). Function implementations are passed the variables + * in a single $variables function argument. If you are using these variables + * in a render array, prefix the variable names defined here with a #. + * + * @param mixed[]|null $variables + * An associative array where the keys are names of variables, and the + * values are the default values if they are not given in the render array. + * + * @return $this + */ + public function setVariables($variables) { + $this->variables = $variables; + + return $this; + } + + /** + * Returns whether this theme hook has variables to pass to the template. + * + * @return bool + * TRUE if the theme hook uses variables, FALSE otherwise. + */ + public function hasVariables() { + return isset($this->variables); + } + + /** + * Gets the name of the renderable element. + * + * @return string + * The name of the renderable element to pass to the theme function. + */ + public function getRenderElement() { + return $this->render_element; + } + + /** + * Sets the name of the renderable element. + * + * Used for render element items only. This name is used as the name of the + * variable that holds the renderable element or tree in preprocess functions. + * + * @param string $render_element + * The name of the renderable element to pass to the theme function. + * + * @return $this + */ + public function setRenderElement($render_element) { + $this->render_element = $render_element; + + return $this; + } + + /** + * Gets the regular expression pattern. + * + * @return string|null + * A regular expression pattern, if one exists. + */ + public function getPattern() { + return $this->pattern; + } + + /** + * Sets the regular expression pattern. + * + * A pattern allows the theme implementation to have a dynamic name. + * + * The convention is to use __ to differentiate the dynamic portion of the + * theme. For example, to allow forums to be themed individually, the pattern + * might be: 'forum__'. Then, when the forum is rendered, following render + * array can be used: + * @code + * $render_array = array( + * '#theme' => array('forum__' . $tid, 'forum'), + * '#forum' => $forum, + * ); + * @endcode + * + * @param string|null $pattern + * A regular expression pattern, or NULL to set no pattern. + * + * @return $this + */ + public function setPattern($pattern) { + $this->pattern = $pattern; + + return $this; + } + + /** + * Returns if this theme hook has a regular expression pattern set. + * + * @return bool + * TRUE if a regular expression pattern is set, FALSE otherwise. + */ + public function hasPattern() { + return !is_null($this->pattern); + } + + /** + * Gets the base theme hook name. + * + * @return string|null + * The name of a theme hook to use as the basis for this theme hook, or NULL + * if no base hook exists. + */ + public function getBaseHook() { + return $this->base_hook; + } + + /** + * Sets the base theme hook name. + * + * Used for theme suggestions only. + * + * Instead of this suggestion's implementation being used directly, the base + * hook will be invoked with this implementation as its first suggestion. The + * base hook's files will be included and the base hook's preprocess functions + * will be called in addition to any suggestion's preprocess functions. If an + * implementation of hook_theme_suggestions_HOOK() (where HOOK is the base + * hook) changes the suggestion order, a different suggestion may be used in + * place of this suggestion. If after hook_theme_suggestions_HOOK() this + * suggestion remains the first suggestion, then this suggestion's function or + * template will be used to generate the rendered output. + * + * @param string|null $base_hook + * The name of a theme hook to use as the basis for this theme hook, or NULL + * if no base hook exists. + * + * @return $this + */ + public function setBaseHook($base_hook) { + $this->base_hook = $base_hook; + + return $this; + } + + /** + * Gets the array of files to be included. + * + * @return string[] + * An array of files to be included. The file paths are relative to the + * Drupal root directory. + */ + public function getIncludes() { + return $this->includes; + } + + /** + * Sets the array of files to be included. + * + * @param string[] $includes + * An array of files to be included. The file paths must be relative to the + * Drupal root directory. + * + * @return $this + */ + public function setIncludes(array $includes) { + $this->includes = $includes; + + return $this; + } + + /** + * Adds a file to be included. + * + * @param string $include + * A path relative to the Drupal root directory, or NULL if no path is set. + * + * @return $this + */ + public function addInclude($include) { + $includes = $this->getIncludes(); + $includes[] = $include; + $this->setIncludes($includes); + + return $this; + } + + /** + * Returns whether any files are specified for inclusion. + * + * @return bool + * TRUE if files exist to be included, FALSE otherwise. + */ + public function hasIncludes() { + return !empty($this->includes); + } + + /** + * Gets the file the theme implementation resides in. + * + * @return string + * The file the theme implementation resides in. + */ + public function getFile() { + return $this->file; + } + + /** + * Gets the file the theme implementation resides in. + * + * This file will be included prior to the theme being rendered, to make sure + * that the function or preprocess function (as needed) is actually loaded. + * + * @param string $file + * The file the theme implementation resides in. + * + * @return $this + */ + public function setFile($file) { + $this->file = $file; + + return $this; + } + + /** + * Gets the function name to invoke for this implementation, if it exists. + * + * If this is set, self::getTemplate() will be ignored. + * + * @return string|null + * The function name to invoke for the theme implementation. + * + * @deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x. + */ + public function getFunction() { + return $this->function; + } + + /** + * Sets the function name to invoke for this implementation. + * + * If this is set, self::getTemplate() will be ignored. + * + * @param string $function + * The function name to invoke for the theme implementation. + * + * @return $this + * + * @deprecated in Drupal 8.0.x, will be removed in Drupal 9.0.x. + */ + public function setFunction($function) { + $this->function = $function; + + return $this; + } + + /** + * Gets the template name to use for the theme implementation. + * + * This is ignored if self::getFunction() is set. + * + * @return string + * The template name for this theme implementation. + */ + public function getTemplate() { + return $this->template; + } + + /** + * Sets the template name to use for the theme implementation. + * + * This is ignored if self::getFunction() is set. If neither this nor + * self::getFunction() are specified, a default template name will be assumed. + * For example, if a module registers the 'search_result' theme hook, + * 'search-result' will be assigned as its template name. + * + * @param string|null $template + * The template name for this theme implementation. Do not add 'html.twig' + * on the end of the template name. The extension will be added + * automatically by the default rendering engine (which is Twig). + * + * @return $this + */ + public function setTemplate($template) { + $this->template = $template; + + return $this; + } + + /** + * Gets the path to the theme implementation, if it exists. + * + * @return string|null + * A path relative to the Drupal root directory, or NULL if no path is set. + */ + public function getPath() { + return $this->path; + } + + /** + * Overrides the path to the file containing the theme implementation. + * + * If this is used, self::setTemplate() should also be called. + * + * @param string|null $path + * A path relative to the Drupal root directory, or NULL to use the default + * theme path. + * + * @return $this + */ + public function setPath($path) { + $this->path = $path; + + return $this; + } + + /** + * Gets the list of functions used to preprocess this data. + * + * @return string[] + * An array of functions to be called during the preprocess phase. + */ + public function getPreprocessFunctions() { + return $this->preprocess_functions; + } + + /** + * Sets the list of functions used to preprocess this data. + * + * Ordinarily this won't be used; it's automatically filled in. By default, + * for a module this will be filled in as template_preprocess_HOOK. For a + * theme this will be filled in as twig_preprocess and twig_preprocess_HOOK as + * well as themename_preprocess and themename_preprocess_HOOK. + * + * @param string[] $preprocess_functions + * An array of functions to be called during the preprocess phase. + * + * @return $this + */ + public function setPreprocessFunctions(array $preprocess_functions) { + $this->preprocess_functions = array_values(array_unique($preprocess_functions)); + + return $this; + } + + /** + * Indicates if a given preprocess function will be used for this theme hook. + * + * @param string $preprocess_function + * The name of a preprocess function. + * + * @return bool + * TRUE if this preprocess function will be used for this theme hook, + * FALSE otherwise. + */ + public function hasPreprocessFunction($preprocess_function) { + return in_array($preprocess_function, $this->getPreprocessFunctions()); + } + + /** + * Adds a preprocess function to this theme hook. + * + * @param string $preprocess_function + * The name of a preprocess function. + * + * @return $this + */ + public function addPreprocessFunction($preprocess_function) { + $preprocess_functions = $this->getPreprocessFunctions(); + $preprocess_functions[] = $preprocess_function; + $this->setPreprocessFunctions($preprocess_functions); + + return $this; + } + + /** + * Determines if standard preprocess functions should be ignored. + * + * @return bool + * TRUE if standard preprocess functions should be ignored, FALSE otherwise. + */ + public function isPreprocessOverridden() { + return $this->override_preprocess_functions; + } + + /** + * Prevents standard preprocess functions from running if set to TRUE. + * + * This can be used to give a theme FULL control over how variables are set. + * For example, if a theme wants total control over how certain variables in + * the page.html.twig are set, this can be set to true. Please keep in mind + * that when this is used by a theme, that theme becomes responsible for + * making sure necessary variables are set. + * + * @param bool $status + * (optional) TRUE if standard preprocess functions should be ignored, FALSE + * otherwise. Defaults to TRUE. + * + * @return $this + */ + public function overridePreprocess($status = TRUE) { + $this->override_preprocess_functions = (bool) $status; + + return $this; + } + + /** + * Returns whether the list of preprocess functions is incomplete. + * + * Generally this is for internal use only. A completely built registry should + * contain only completed theme hooks. + * + * @return bool + * TRUE if the list of preprocess functions is incomplete. + */ + public function isIncomplete() { + return $this->incomplete_preprocess_functions; + } + + /** + * Marks this theme hook as being incomplete. + * + * @see self::isIncomplete() + * + * @return $this + */ + public function markIncomplete() { + $this->incomplete_preprocess_functions = TRUE; + + return $this; + } + + /** + * Marks this theme hook as being complete. + * + * @see self::isIncomplete() + * + * @return $this + */ + public function markComplete() { + $this->incomplete_preprocess_functions = FALSE; + + return $this; + } + + /** + * Gets the name of the theme hook. + * + * @return string + * The machine name of the theme hook. + */ + public function getName() { + return $this->name; + } + + /** + * Process a theme hook. + * + * This is intended for usage by the theme registry. + * + * @param string $root + * The app root. + * @param string $theme + * The theme currently being processed. + * @param array $module_list + * An array of module names. + * @param \Drupal\Core\Theme\ThemeHook|null $existing_theme_hook + * An existing theme hook, if it exists. + * + * @return static + */ + public function process($root, $theme, array $module_list, ThemeHook $existing_theme_hook = NULL) { + $hook = $this->getName(); + + // 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 ($this->getBaseHook()) { + $this->markIncomplete(); + } + + $this->handleIncludes($root, $existing_theme_hook); + + // A template file is the default implementation for a theme hook, but if + // the theme hook specifies a function callback instead, check to ensure the + // function actually exists. + if ($function = $this->getFunction()) { + if (!function_exists($function)) { + throw new \BadFunctionCallException(sprintf( + 'Theme hook "%s" refers to a theme function callback that does not exist: "%s"', + $hook, + $function + )); + } + } + // Provide a default naming convention for 'template' based on the hook + // used. If the template does not exist, the theme engine used should throw + // an exception at runtime when attempting to include the template file. + elseif (!$this->getTemplate()) { + $this->setTemplate(strtr($hook, '_', '-')); + } + + // Prepend the current theming path when none is set. This is required for + // the default theme engine to know where the template lives. + if ($this->getTemplate() && !$this->getPath()) { + $this->setPath($this->getThemePath() . '/templates'); + } + + if ($existing_theme_hook) { + $this->handleDefaultValues($existing_theme_hook); + } + $this->handlePreprocess($theme, $module_list); + + // Merge the newly created theme hooks into the existing cache. + if ($existing_theme_hook) { + $result = $this->merge($existing_theme_hook); + } + else { + $result = $this; + } + + if (!$result->getRenderElement() && !$result->hasVariables() && !$result->getBaseHook()) { + // @todo Convert this to an exception in + // https://www.drupal.org/node/2873117. + @trigger_error(sprintf('The "%s" theme hook must have either a render element, variables, or a base hook', $result->getName()), E_USER_DEPRECATED); + } + return $result; + } + + /** + * Handles including any files for the processing of this theme hook. + * + * @param string $root + * The app root. + * @param \Drupal\Core\Theme\ThemeHook|null $existing_theme_hook + * An existing theme hook, if it exists. + */ + protected function handleIncludes($root, ThemeHook $existing_theme_hook = NULL) { + if ($existing_theme_hook && $existing_theme_hook->hasIncludes()) { + $this->setIncludes($existing_theme_hook->getIncludes()); + } + + // If the theme implementation defines a file, then also use the path + // that it defined. Otherwise use the default path. This allows + // system.module to declare theme functions on behalf of core .include + // files. + if ($this->getFile()) { + $include_file = $this->getPath() ?: $this->getThemePath(); + $include_file .= '/' . $this->getFile(); + $this->addInclude($include_file); + } + + // Load the includes, as they may contain preprocess functions. + if ($this->hasIncludes()) { + foreach ($this->getIncludes() as $include_file) { + include_once $root . '/' . $include_file; + } + } + } + + /** + * Handles merging in any default values from an existing theme hook. + * + * @param \Drupal\Core\Theme\ThemeHook $existing_theme_hook + * An existing theme hook. + */ + protected function handleDefaultValues(ThemeHook $existing_theme_hook) { + if ($existing_theme_hook->hasVariables() && !$this->hasVariables()) { + $this->setVariables($existing_theme_hook->getVariables()); + } + if (!$this->getPattern()) { + $this->setPattern($existing_theme_hook->getPattern()); + } + if (!$this->getBaseHook()) { + $this->setBaseHook($existing_theme_hook->getBaseHook()); + } + if ($existing_theme_hook->getRenderElement() && !$this->getRenderElement()) { + $this->setRenderElement($existing_theme_hook->getRenderElement()); + } + } + + /** + * Generates the list of preprocess functions from the theme and module list. + * + * @param string $theme + * The theme currently being processed. + * @param array $module_list + * An array of module names. + */ + protected function handlePreprocess($theme, array $module_list) { + // Preprocess variables for all theming hooks, whether the hook is + // implemented as a template or as a function. Ensure they are arrays. + if (!$this->getPreprocessFunctions()) { + $provider_name = $this->getProvider(); + $type = $this->getProviderType(); + $hook = $this->getName(); + + $prefixes = []; + if ($type == 'module') { + // Default variable preprocessor prefix. + $prefixes[] = 'template'; + // Add all modules so they can intervene with their own variable + // preprocessors. This allows them to provide variable preprocessors + // even if they are not the owner of the current hook. + $prefixes = array_merge($prefixes, $module_list); + } + elseif ($type == 'theme_engine' || $type == 'base_theme_engine') { + // Theme engines get an extra set that come before the normally + // named variable preprocessors. + $prefixes[] = $provider_name . '_engine'; + // The theme engine registers on behalf of the theme using the + // theme's name. + $prefixes[] = $theme; + } + else { + // This applies when the theme manually registers their own variable + // preprocessors. + $prefixes[] = $provider_name; + } + foreach ($prefixes as $prefix) { + // Only use non-hook-specific variable preprocessors for theming + // hooks implemented as templates. See the @defgroup themeable + // topic. + if ($this->getTemplate() && function_exists($prefix . '_preprocess')) { + $this->addPreprocessFunction($prefix . '_preprocess'); + } + if (function_exists($prefix . '_preprocess_' . $hook)) { + $this->addPreprocessFunction($prefix . '_preprocess_' . $hook); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function __sleep() { + $values = get_object_vars($this); + + // In order to minimize the registry, exclude any key with default values. + $default_properties = (new \ReflectionClass($this))->getDefaultProperties(); + foreach ($values as $key => $value) { + if (array_key_exists($key, $default_properties) && $default_properties[$key] === $value) { + unset($values[$key]); + } + } + + return array_keys($values); + } + +} 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 3763c3a18a..06a6ad8c79 100644 --- a/core/modules/system/tests/modules/theme_test/theme_test.module +++ b/core/modules/system/tests/modules/theme_test/theme_test.module @@ -6,6 +6,7 @@ */ use Drupal\Core\Extension\Extension; +use Drupal\Core\Theme\ThemeHook; /** * Implements hook_theme(). @@ -57,6 +58,8 @@ function theme_test_theme($existing, $type, $theme, $path) { 'message' => '', ], ]; + $items[] = ThemeHook::create('theme_test_object_based') + ->setRenderElement('elements'); return $items; } diff --git a/core/tests/Drupal/KernelTests/Core/Theme/RegistryLegacyTest.php b/core/tests/Drupal/KernelTests/Core/Theme/RegistryLegacyTest.php index 350f2accb4..dae98d2506 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/RegistryLegacyTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/RegistryLegacyTest.php @@ -27,9 +27,13 @@ class RegistryLegacyTest extends KernelTestBase { /** * Tests the theme registry with theme functions and multiple subthemes. * - * @expectedDeprecation Unsilenced deprecation: Theme functions are deprecated in drupal:8.0.0 and are removed from drupal:10.0.0. Use Twig templates instead of theme_theme_test(). See https://www.drupal.org/node/1831138 + * @deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use Twig + * templates instead of theme_theme_test(). + * + * @see https://www.drupal.org/node/1831138 */ public function testMultipleSubThemes() { + @trigger_error(__METHOD__ . '() is deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use templates instead. See https://www.drupal.org/node/1831138', E_USER_DEPRECATED); $theme_handler = \Drupal::service('theme_handler'); \Drupal::service('module_installer')->install(['theme_legacy_test']); \Drupal::service('theme_installer')->install(['test_basetheme']); @@ -47,9 +51,13 @@ public function testMultipleSubThemes() { /** * Tests the theme registry with theme functions with suggestions. * - * @expectedDeprecation Unsilenced deprecation: Theme functions are deprecated in drupal:8.0.0 and are removed from drupal:10.0.0. Use Twig templates instead of test_legacy_theme_theme_test_preprocess_suggestions__kitten__meerkat(). See https://www.drupal.org/node/1831138 + * @deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use Twig + * templates instead of test_legacy_theme_theme_test_preprocess_suggestions__kitten__meerkat(). + * + * @see https://www.drupal.org/node/1831138 */ public function testSuggestionPreprocessFunctions() { + @trigger_error(__METHOD__ . '() is deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use templates instead. See https://www.drupal.org/node/1831138', E_USER_DEPRECATED); $theme_handler = \Drupal::service('theme_handler'); \Drupal::service('theme_installer')->install(['test_legacy_theme']); diff --git a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php index baaaeb471e..836d59b9c7 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/RegistryTest.php @@ -6,6 +6,7 @@ use Drupal\Core\Path\PathMatcherInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Theme\Registry; +use Drupal\Core\Theme\ThemeHook; use Drupal\Core\Utility\ThemeRegistry; use Drupal\KernelTests\KernelTestBase; @@ -133,7 +134,17 @@ public function testSuggestionPreprocessFunctions() { $preprocess_functions = $registry_theme->get()['theme_test_preprocess_suggestions__kitten__bearcat']['preprocess functions']; $this->assertSame($expected_preprocess_functions, $preprocess_functions, 'Suggestion implemented as a template correctly inherits preprocess functions.'); + // Test an object-based theme hook. + $info = $registry_theme->get()['theme_test_object_based']; + $this->assertSame(['template_preprocess'], $info['preprocess functions']); + $this->assertSame('elements', $info['render element']); + $this->assertTrue(isset($registry_theme->get()['theme_test_preprocess_suggestions__kitten__meerkat__tarsier__moose']), 'Preprocess function with an unimplemented lower-level suggestion is added to the registry.'); + foreach ($registry_theme->get() as $name => $info) { + $this->assertInstanceOf(ThemeHook::class, $info); + $this->assertSame($name, $info->getName()); + $this->assertTrue($info->getTemplate() || $info->getFunction()); + } } /** diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryLegacyTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryLegacyTest.php index 20fdd80c08..9e877621d7 100644 --- a/core/tests/Drupal/Tests/Core/Theme/RegistryLegacyTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/RegistryLegacyTest.php @@ -83,9 +83,13 @@ protected function setUp(): void { /** * Tests getting legacy theme function registry data defined by a module. * - * @expectedDeprecation Unsilenced deprecation: Theme functions are deprecated in drupal:8.0.0 and are removed from drupal:10.0.0. Use Twig templates instead of theme_theme_test(). See https://www.drupal.org/node/1831138 + * @deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use Twig + * templates instead of theme_theme_test(). + * + * @see https://www.drupal.org/node/1831138 */ public function testGetLegacyThemeFunctionRegistryForModule() { + @trigger_error(__METHOD__ . '() is deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use templates instead. See https://www.drupal.org/node/1831138', E_USER_DEPRECATED); $test_theme = new ActiveTheme([ 'name' => 'test_legacy_theme', 'path' => 'core/modules/system/tests/themes/test_legacy_theme/test_legacy_theme.info.yml', diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php index c10c484647..9dd92ebe6b 100644 --- a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php @@ -4,6 +4,7 @@ use Drupal\Core\Theme\ActiveTheme; use Drupal\Core\Theme\Registry; +use Drupal\Core\Theme\ThemeHook; use Drupal\Tests\UnitTestCase; /** @@ -94,6 +95,9 @@ protected function tearDown(): void { /** * Tests getting the theme registry defined by a module. + * + * @group legacy + * @expectedDeprecation The "%s" theme hook must have either a render element, variables, or a base hook */ public function testGetRegistryForModule() { $test_theme = new ActiveTheme([ @@ -162,7 +166,7 @@ public function testGetRegistryForModule() { /** * @covers ::postProcessExtension * @covers ::completeSuggestion - * @covers ::mergePreprocessFunctions + * @covers ::mergeHookFromSuggestion * * @dataProvider providerTestPostProcessExtension * @@ -176,6 +180,21 @@ public function testGetRegistryForModule() { public function testPostProcessExtension($defined_functions, $hooks, $expected) { static::$functions['user'] = $defined_functions; + // @todo The BC layer is in \Drupal\Core\Theme\Registry::processExtension() + // which this test bypasses via reflection. These legacy conversion calls + // are necessary until the test data and expectations are converted. + // Remove this in https://www.drupal.org/node/2873117. + foreach ($expected as $name => $hook) { + if (is_array($hook)) { + $expected[$name] = ThemeHook::createFromLegacy($name, $hook); + } + } + foreach ($hooks as $name => $hook) { + if (is_array($hook)) { + $hooks[$name] = ThemeHook::createFromLegacy($name, $hook); + } + } + $theme = $this->prophesize(ActiveTheme::class); $theme->getBaseThemeExtensions()->willReturn([]); $theme->getName()->willReturn('test'); diff --git a/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php b/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php new file mode 100644 index 0000000000..bb70dc2d6f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Theme/ThemeHookTest.php @@ -0,0 +1,26 @@ +setRenderElement('elements') + ->setFunction('nonexistent'); + $this->expectException(\BadFunctionCallException::class); + $this->expectExceptionMessage('Theme hook "foo" refers to a theme function callback that does not exist: "nonexistent"'); + $hook->process('', '', []); + } + +} diff --git a/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php b/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php index b940fd2c5e..f232fb9842 100644 --- a/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php +++ b/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php @@ -158,6 +158,10 @@ public static function getSkippedDeprecations() { // testing using \Symfony\Component\ErrorHandler\DebugClassLoader. 'The "Twig\Environment::getTemplateClass()" method is considered internal. It may change without further notice. You should not extend it from "Drupal\Core\Template\TwigEnvironment".', '"Symfony\Component\DomCrawler\Crawler::text()" will normalize whitespaces by default in Symfony 5.0, set the second "$normalizeWhitespace" argument to false to retrieve the non-normalized version of the text.', + 'The "theme_test_template_test" theme hook must have either a render element, variables, or a base hook', + 'The "theme_test_template_test_2" theme hook must have either a render element, variables, or a base hook', + 'The "locale_test_tokenized" theme hook must have either a render element, variables, or a base hook', + 'The "twig_theme_test_php_variables" theme hook must have either a render element, variables, or a base hook', // PHPUnit 8. "The \"Drupal\Tests\Listeners\AfterSymfonyListener\" class implements \"PHPUnit\Framework\TestListener\" that is deprecated Use the `TestHook` interfaces instead.", "The \"Drupal\Tests\Listeners\AfterSymfonyListener\" class uses \"PHPUnit\Framework\TestListenerDefaultImplementation\" that is deprecated The `TestListener` interface is deprecated.",