Commit 478490f2 authored by catch's avatar catch

Issue #1922454 by thedavidmeister, steveoliver, Cottser: Gut l(), fix...

Issue #1922454 by thedavidmeister, steveoliver, Cottser: Gut l(), fix theme('link'), have a working framework for consistently generated links inside and outside of Twig.
parent e61d892f
......@@ -1917,68 +1917,77 @@ function drupal_http_header_attributes(array $attributes = array()) {
* An HTML string containing a link to the given path.
*
* @see url()
* @see theme_link()
*/
function l($text, $path, array $options = array()) {
static $use_theme = NULL;
// Build a variables array to keep the structure of the alter consistent with
// theme_link().
$variables = array(
'text' => $text,
'path' => $path,
'options' => $options,
);
// Merge in defaults.
$options += array(
// Merge in default options.
$variables['options'] += array(
'attributes' => array(),
'query' => array(),
'html' => FALSE,
'language' => NULL,
);
// Append active class.
// The link is only active, if its path corresponds to the current path, the
// language of the linked path is equal to the current language, and if the
// query parameters of the link equal those of the current request, since the
// same request with different query parameters may yield a different page
// (e.g., pagers).
$is_active = ($path == current_path() || ($path == '<front>' && drupal_is_front_page()));
$is_active = $is_active && (empty($options['language']) || $options['language']->langcode == language(LANGUAGE_TYPE_URL)->langcode);
$is_active = $is_active && (drupal_container()->get('request')->query->all() == $options['query']);
if ($is_active) {
$options['attributes']['class'][] = 'active';
}
// Remove all HTML and PHP tags from a tooltip. For best performance, we act only
// if a quick strpos() pre-check gave a suspicion (because strip_tags() is expensive).
if (isset($options['attributes']['title']) && strpos($options['attributes']['title'], '<') !== FALSE) {
$options['attributes']['title'] = strip_tags($options['attributes']['title']);
}
// Determine if rendering of the link is to be done with a theme function
// or the inline default. Inline is faster, but if the theme system has been
// loaded and a module or theme implements a preprocess or process function
// or overrides the theme_link() function, then invoke theme(). Preliminary
// benchmarks indicate that invoking theme() can slow down the l() function
// by 20% or more, and that some of the link-heavy Drupal pages spend more
// than 10% of the total page request time in the l() function.
if (!isset($use_theme) && function_exists('theme')) {
// Allow edge cases to prevent theme initialization and force inline link
// rendering.
if (config('system.performance')->get('theme_link')) {
drupal_theme_initialize();
$registry = theme_get_registry(FALSE);
// We don't want to duplicate functionality that's in theme(), so any
// hint of a module or theme doing anything at all special with the 'link'
// theme hook should simply result in theme() being called. This includes
// the overriding of theme_link() with an alternate function or template,
// the presence of preprocess or process functions, or the presence of
// include files.
$use_theme = !isset($registry['link']['function']) || ($registry['link']['function'] != 'theme_link');
$use_theme = $use_theme || !empty($registry['link']['preprocess functions']) || !empty($registry['link']['process functions']) || !empty($registry['link']['includes']);
}
else {
$use_theme = FALSE;
}
// Because l() is called very often we statically cache values that require an
// extra function call.
static $drupal_static_fast;
if (!isset($drupal_static_fast['active'])) {
$drupal_static_fast['active'] = &drupal_static(__FUNCTION__);
}
$active = &$drupal_static_fast['active'];
if (!isset($active)) {
$active = array(
'path' => current_path(),
'front_page' => drupal_is_front_page(),
'language' => language(LANGUAGE_TYPE_URL)->langcode,
'query' => Drupal::service('request')->query->all(),
);
}
if ($use_theme) {
return theme('link', array('text' => $text, 'path' => $path, 'options' => $options));
// Determine whether this link is "active', meaning that it links to the
// current page. It is important that we stop checking "active" conditions if
// we know the link is not active. This helps ensure that l() remains fast.
// An active link's path is equal to the current path.
$variables['url_is_active'] = ($path == $active['path'] || ($path == '<front>' && $active['front_page']))
// The language of an active link is equal to the current language.
&& (empty($variables['options']['language']) || $variables['options']['language']->langcode == $active['language'])
// The query parameters of an active link are equal to the current parameters.
&& ($variables['options']['query'] == $active['query']);
// Add the "active" class if appropriate.
if ($variables['url_is_active']) {
$variables['options']['attributes']['class'][] = 'active';
}
// Remove all HTML and PHP tags from a tooltip, calling expensive strip_tags()
// only when a quick strpos() gives suspicion tags are present.
if (isset($variables['options']['attributes']['title']) && strpos($variables['options']['attributes']['title'], '<') !== FALSE) {
$variables['options']['attributes']['title'] = strip_tags($variables['options']['attributes']['title']);
}
// Allow other modules to modify the structure of the link.
Drupal::service('module_handler')->alter('link', $variables);
// Move attributes out of options. url() doesn't need them.
$attributes = new Attribute($variables['options']['attributes']);
unset($variables['options']['attributes']);
// The result of url() is a plain-text URL. Because we are using it here
// in an HTML argument context, we need to encode it properly.
return '<a href="' . check_plain(url($path, $options)) . '"' . new Attribute($options['attributes']) . '>' . ($options['html'] ? $text : check_plain($text)) . '</a>';
$url = check_plain(url($variables['path'], $variables['options']));
// Sanitize the link text if necessary.
$text = $variables['options']['html'] ? $variables['text'] : check_plain($variables['text']);
return '<a href="' . $url . '"' . $attributes . '>' . $text . '</a>';
}
/**
......
......@@ -1683,23 +1683,22 @@ function theme_status_messages($variables) {
/**
* Returns HTML for a link.
*
* All Drupal code that outputs a link should call the l() function. That
* function performs some initial preprocessing, and then, if necessary, calls
* theme('link') for rendering the anchor tag.
* This is a wrapper around l() to allow for more flexible link themeing.
*
* To optimize performance for sites that don't need custom theming of links,
* the l() function includes an inline copy of this function, and uses that
* copy if none of the enabled modules or the active theme implement any
* preprocess or process functions or override this theme implementation.
* Where performance is more important than theme flexibility, Drupal code that
* outputs a link should call the l() function directly, as #theme 'link'
* implementations have a measurable performance impact.
*
* @param $variables
* An associative array containing the keys 'text', 'path', and 'options'.
* See the l() function for information about these variables.
* See the l() function for information about these variables. However, unlike
* 'text' in l(), both render arrays and strings are supported here.
*
* @see l()
*/
function theme_link($variables) {
return '<a href="' . check_plain(url($variables['path'], $variables['options'])) . '"' . new Attribute($variables['options']['attributes']) . '>' . ($variables['options']['html'] ? $variables['text'] : check_plain($variables['text'])) . '</a>';
$rendered_text = is_array($variables['text']) ? drupal_render($variables['text']) : $variables['text'];
return l($rendered_text, $variables['path'], $variables['options']);
}
/**
......
......@@ -19,7 +19,7 @@ class LanguageSwitchingTest extends WebTestBase {
*
* @var array
*/
public static $modules = array('language', 'block');
public static $modules = array('language', 'block', 'language_test');
public static function getInfo() {
return array(
......@@ -89,4 +89,99 @@ function testLanguageBlock() {
$this->assertIdentical($anchors, array('active' => array('en'), 'inactive' => array('fr')), 'Only the current language anchor is marked as active on the language switcher block.');
}
/**
* Test active class on links when switching languages.
*/
function testLanguageLinkActiveClass() {
// Add language.
$edit = array(
'predefined_langcode' => 'fr',
);
$this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
// Enable URL language detection and selection.
$edit = array('language_interface[enabled][language-url]' => '1');
$this->drupalPost('admin/config/regional/language/detection', $edit, t('Save settings'));
$function_name = 'l()';
// Test links generated by l() on an English page.
$current_language = 'English';
$this->drupalGet('language_test/l-active-class');
// Language code 'none' link should be active.
$langcode = 'none';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'no_lang_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'en' link should be active.
$langcode = 'en';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'en_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'fr' link should not be active.
$langcode = 'fr';
$links = $this->xpath('//a[@id = :id and not(contains(@class, :class))]', array(':id' => 'fr_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is NOT marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Test links generated by l() on a French page.
$current_language = 'French';
$this->drupalGet('fr/language_test/l-active-class');
// Language code 'none' link should be active.
$langcode = 'none';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'no_lang_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'en' link should not be active.
$langcode = 'en';
$links = $this->xpath('//a[@id = :id and not(contains(@class, :class))]', array(':id' => 'en_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is NOT marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'fr' link should be active.
$langcode = 'fr';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'fr_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
$function_name = "theme('link')";
// Test links generated by theme('link') on an English page.
$current_language = 'English';
$this->drupalGet('language_test/theme-link-active-class');
// Language code 'none' link should be active.
$langcode = 'none';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'no_lang_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'en' link should be active.
$langcode = 'en';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'en_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'fr' link should not be active.
$langcode = 'fr';
$links = $this->xpath('//a[@id = :id and not(contains(@class, :class))]', array(':id' => 'fr_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is NOT marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Test links generated by theme('link') on a French page.
$current_language = 'French';
$this->drupalGet('fr/language_test/theme-link-active-class');
// Language code 'none' link should be active.
$langcode = 'none';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'no_lang_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'en' link should not be active.
$langcode = 'en';
$links = $this->xpath('//a[@id = :id and not(contains(@class, :class))]', array(':id' => 'en_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is NOT marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
// Language code 'fr' link should be active.
$langcode = 'fr';
$links = $this->xpath('//a[@id = :id and contains(@class, :class)]', array(':id' => 'fr_link', ':class' => 'active'));
$this->assertTrue(isset($links[0]), t('A link generated by :function to the current :language page with langcode :langcode is marked active.', array(':function' => $function_name, ':language' => $current_language, ':langcode' => $langcode)));
}
}
language_test_l_active_class:
pattern: '/language_test/l-active-class'
defaults:
_content: '\Drupal\language_test\Controller\LanguageTestController::lActiveClass'
requirements:
_access: 'TRUE'
language_test_theme_link_active_class:
pattern: '/language_test/theme-link-active-class'
defaults:
_content: '\Drupal\language_test\Controller\LanguageTestController::themeLinkActiveClass'
requirements:
_access: 'TRUE'
<?php
/**
* @file
* Contains \Drupal\language_test\Controller\LanguageTestController.
*/
namespace Drupal\language_test\Controller;
use Drupal\Core\ControllerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Controller routines for language_test routes.
*/
class LanguageTestController implements ControllerInterface {
/**
* Implements \Drupal\Core\ControllerInterface::create().
*/
public static function create(ContainerInterface $container) {
return new static();
}
/**
* Returns links to the current page with different langcodes.
*
* Using #theme causes these links to be rendered with theme_link().
*/
public function themeLinkActiveClass() {
// We assume that 'en' and 'fr' have been configured.
$languages = language_list();
return array(
'no_language' => array(
'#theme' => 'link',
'#text' => t('Link to the current path with no langcode provided.'),
'#path' => current_path(),
'#options' => array(
'attributes' => array(
'id' => 'no_lang_link',
),
),
),
'fr' => array(
'#theme' => 'link',
'#text' => t('Link to a French version of the current path.'),
'#path' => current_path(),
'#options' => array(
'language' => $languages['fr'],
'attributes' => array(
'id' => 'fr_link',
),
),
),
'en' => array(
'#theme' => 'link',
'#text' => t('Link to an English version of the current path.'),
'#path' => current_path(),
'#options' => array(
'language' => $languages['en'],
'attributes' => array(
'id' => 'en_link',
),
),
),
);
}
/**
* Returns links to the current page with different langcodes.
*
* Using #type causes these links to be rendered with l().
*/
public function lActiveClass() {
// We assume that 'en' and 'fr' have been configured.
$languages = language_list();
return array(
'no_language' => array(
'#type' => 'link',
'#title' => t('Link to the current path with no langcode provided.'),
'#href' => current_path(),
'#options' => array(
'attributes' => array(
'id' => 'no_lang_link',
),
),
),
'fr' => array(
'#type' => 'link',
'#title' => t('Link to a French version of the current path.'),
'#href' => current_path(),
'#options' => array(
'language' => $languages['fr'],
'attributes' => array(
'id' => 'fr_link',
),
),
),
'en' => array(
'#type' => 'link',
'#title' => t('Link to an English version of the current path.'),
'#href' => current_path(),
'#options' => array(
'language' => $languages['en'],
'attributes' => array(
'id' => 'en_link',
),
),
),
);
}
}
......@@ -28,47 +28,135 @@ public static function getInfo() {
}
/**
* Confirms that invalid URLs are filtered.
* Confirms that invalid URLs are filtered in link generating functions.
*/
function testLXSS() {
function testLinkXSS() {
// Test l().
$text = $this->randomName();
$path = "<SCRIPT>alert('XSS')</SCRIPT>";
$link = l($text, $path);
$sanitized_path = check_url(url($path));
$this->assertTrue(strpos($link, $sanitized_path) !== FALSE, format_string('XSS attack @path was filtered', array('@path' => $path)));
$this->assertTrue(strpos($link, $sanitized_path) !== FALSE, format_string('XSS attack @path was filtered by l().', array('@path' => $path)));
// Test #theme.
$link_array = array(
'#theme' => 'link',
'#text' => $this->randomName(),
'#path' => $path,
);
$theme_link = drupal_render($link_array);
$sanitized_path = check_url(url($path));
$this->assertTrue(strpos($theme_link, $sanitized_path) !== FALSE, format_string('XSS attack @path was filtered by #theme', array('@path' => $path)));
}
/**
* Tests for active class in l() function.
* Tests for active class in links produced by l() and theme_link() functions.
*/
function testLActiveClass() {
function testLinkActiveClass() {
$options_no_query = array();
$options_query = array(
'query' => array(
'foo' => 'bar',
'one' => 'two',
),
);
$options_query_reverse = array(
'query' => array(
'one' => 'two',
'foo' => 'bar',
),
);
// Test l().
$path = 'common-test/l-active-class';
$options = array();
$this->drupalGet($path, $options);
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link to the current page is marked active.');
$this->drupalGet($path, $options_no_query);
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options_no_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by l() to the current page is marked active.');
$links = $this->xpath('//a[@href = :href and not(contains(@class, :class))]', array(':href' => url($path, $options_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by l() to the current page with a query string when the current page has no query string is not marked active.');
$this->drupalGet($path, $options_query);
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by l() to the current page with a query string that matches the current query string is marked active.');
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options_query_reverse), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by l() to the current page with a query string that has matching parameters to the current query string but in a different order is marked active.');
$links = $this->xpath('//a[@href = :href and not(contains(@class, :class))]', array(':href' => url($path, $options_no_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by l() to the current page without a query string when the current page has a query string is not marked active.');
$options = array('query' => array('foo' => 'bar'));
$links = $this->xpath('//a[@href = :href and not(contains(@class, :class))]', array(':href' => url($path, $options), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link to the current page with a query string when the current page has no query string is not marked active.');
// Test #theme.
$path = 'common-test/theme-link-active-class';
$this->drupalGet($path, $options);
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link to the current page with a query string that matches the current query string is marked active.');
$this->drupalGet($path, $options_no_query);
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options_no_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by #theme to the current page is marked active.');
$options = array();
$links = $this->xpath('//a[@href = :href and not(contains(@class, :class))]', array(':href' => url($path, $options), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link to the current page without a query string when the current page has a query string is not marked active.');
$links = $this->xpath('//a[@href = :href and not(contains(@class, :class))]', array(':href' => url($path, $options_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by #theme to the current page with a query string when the current page has no query string is not marked active.');
$this->drupalGet($path, $options_query);
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by #theme to the current page with a query string that matches the current query string is marked active.');
$links = $this->xpath('//a[@href = :href and contains(@class, :class)]', array(':href' => url($path, $options_query_reverse), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by #theme to the current page with a query string that has matching parameters to the current query string but in a different order is marked active.');
$links = $this->xpath('//a[@href = :href and not(contains(@class, :class))]', array(':href' => url($path, $options_no_query), ':class' => 'active'));
$this->assertTrue(isset($links[0]), 'A link generated by #theme to the current page without a query string when the current page has a query string is not marked active.');
}
/**
* Tests for custom class in links produced by l() and theme_link() functions.
*/
function testLinkCustomClass() {
// Test l().
$class_l = $this->randomName();
$link_l = l($this->randomName(), current_path(), array('attributes' => array('class' => array($class_l))));
$this->assertTrue($this->hasClass($link_l, $class_l), format_string('Custom class @class is present on link when requested by l()', array('@class' => $class_l)));
// Test #theme.
$class_theme = $this->randomName();
$theme_link = array(
'#theme' => 'link',
'#text' => $this->randomName(),
'#path' => current_path(),
'#options' => array(
'attributes' => array(
'class' => array($class_theme),
),
),
);
$link_theme = drupal_render($theme_link);
$this->assertTrue($this->hasClass($link_theme, $class_theme), format_string('Custom class @class is present on link when requested by #theme', array('@class' => $class_theme)));
}
/**
* Tests for custom class in l() function.
* Tests that theme_link() supports render arrays in 'text' parameter.
*/
function testLCustomClass() {
$class = $this->randomName();
$link = l($this->randomName(), current_path(), array('attributes' => array('class' => array($class))));
$this->assertTrue($this->hasClass($link, $class), format_string('Custom class @class is present on link when requested', array('@class' => $class)));
function testLinkNestedRenderArrays() {
// Build a link with l() for reference.
$l = l('foo', 'http://drupal.org');
// Test a themed link with plain text 'text'.
$theme_link_plain_array = array(
'#theme' => 'link',
'#text' => 'foo',
'#path' => 'http://drupal.org',
);
$theme_link_plain = drupal_render($theme_link_plain_array);
$this->assertEqual($theme_link_plain, $l);
// Build a themed link with renderable 'text'.
$theme_link_nested_array = array(
'#theme' => 'link',
'#text' => array('#markup' => 'foo'),
'#path' => 'http://drupal.org',
);
$theme_link_nested = drupal_render($theme_link_nested_array);
$this->assertEqual($theme_link_nested, $l);
}
/**
......
......@@ -64,12 +64,6 @@ function common_test_menu() {
'access arguments' => array('access content'),
'type' => MENU_CALLBACK,
);
$items['common-test/l-active-class'] = array(
'title' => 'Test l() adding of active class',
'page callback' => 'common_test_l_active_class',
'access arguments' => array('access content'),
'type' => MENU_CALLBACK,
);
return $items;
}
......@@ -340,26 +334,3 @@ function common_test_js_and_css_querystring() {
function common_test_cron() {
throw new Exception(t('Uncaught exception'));
}
/**
* Page callback: Displays links to the current page, one with a query string.
*/
function common_test_l_active_class() {
return array(
'no_query' => array(
'#type' => 'link',
'#title' => t('Link with no query string'),
'#href' => current_path(),
),
'with_query' => array(
'#type' => 'link',
'#title' => t('Link with a query string'),
'#href' => current_path(),
'#options' => array(
'query' => array(
'foo' => 'bar',
),
),
),
);
}
common_test_l_active_class:
pattern: '/common-test/l-active-class'
defaults:
_content: '\Drupal\common_test\Controller\CommonTestController::lActiveClass'
requirements:
_access: 'TRUE'
common_test_theme_link_active_class:
pattern: '/common-test/theme-link-active-class'
defaults:
_content: '\Drupal\common_test\Controller\CommonTestController::themeLinkActiveClass'
requirements:
_access: 'TRUE'
<?php
/**
* @file
* Contains \Drupal\common_test\Controller\CommonTestController.
*/
namespace Drupal\common_test\Controller;
use Drupal\Core\ControllerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Controller routines for common_test routes.
*/
class CommonTestController implements ControllerInterface {
/**
* Implements \Drupal\Core\ControllerInterface::create().