diff --git a/core/core.services.yml b/core/core.services.yml index 657920409ce..5959c447069 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1362,3 +1362,9 @@ services: arguments: ['@controller_resolver', '@theme.manager', '@plugin.manager.element_info', '@render_cache', '%renderer.config%'] email.validator: class: Egulias\EmailValidator\EmailValidator + + response_filter.active_link: + class: Drupal\Core\EventSubscriber\ActiveLinkResponseFilter + arguments: ['@current_user', '@path.current', '@path.matcher', '@language_manager'] + tags: + - { name: event_subscriber } diff --git a/core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php b/core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php new file mode 100644 index 00000000000..3c9fa5e417d --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php @@ -0,0 +1,246 @@ +currentUser = $current_user; + $this->currentPath = $current_path; + $this->pathMatcher = $path_matcher; + $this->languageManager = $language_manager; + } + + /** + * Sets the 'is-active' class on links. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The response event. + */ + public function onResponse(FilterResponseEvent $event) { + // Only care about HTML responses. + if (stripos($event->getResponse()->headers->get('Content-Type'), 'text/html') === FALSE) { + return; + } + + // For authenticated users, the 'is-active' class is set in JavaScript. + // @see system_page_attachments() + if ($this->currentUser->isAuthenticated()) { + return; + } + + $response = $event->getResponse(); + $response->setContent(static::setLinkActiveClass( + $response->getContent(), + ltrim($this->currentPath->getPath(), '/'), + $this->pathMatcher->isFrontPage(), + $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(), + $event->getRequest()->query->all() + )); + } + + + /** + * Sets the "is-active" class on relevant links. + * + * This is a PHP implementation of the drupal.active-link JavaScript library. + * + * @param string $html_markup. + * The HTML markup to update. + * @param string $current_path + * The system path of the currently active page. + * @param bool $is_front + * Whether the current page is the front page (which implies the current + * path might also be ). + * @param string $url_language + * The language code of the current URL. + * @param array $query + * The query string for the current URL. + * + * @return string + * The updated HTML markup. + * + * @todo Once a future version of PHP supports parsing HTML5 properly + * (i.e. doesn't fail on https://drupal.org/comment/7938201#comment-7938201) + * then we can get rid of this manual parsing and use DOMDocument instead. + */ + public static function setLinkActiveClass($html_markup, $current_path, $is_front, $url_language, array $query) { + $search_key_current_path = 'data-drupal-link-system-path="' . $current_path . '"'; + $search_key_front = 'data-drupal-link-system-path="<front>"'; + + // An active link's path is equal to the current path, so search the HTML + // for an attribute with that value. + $offset = 0; + while (strpos($html_markup, $search_key_current_path, $offset) !== FALSE || ($is_front && strpos($html_markup, $search_key_front, $offset) !== FALSE)) { + $pos_current_path = strpos($html_markup, $search_key_current_path, $offset); + $pos_front = strpos($html_markup, $search_key_front, $offset); + + // Determine which of the two values is the next match: the exact path, or + // the special case. + $pos_match = NULL; + if ($pos_front === FALSE) { + $pos_match = $pos_current_path; + } + elseif ($pos_current_path === FALSE) { + $pos_match = $pos_front; + } + elseif ($pos_current_path < $pos_front) { + $pos_match = $pos_current_path; + } + else { + $pos_match = $pos_front; + } + + // Find beginning and ending of opening tag. + $pos_tag_start = NULL; + for ($i = $pos_match; $pos_tag_start === NULL && $i > 0; $i--) { + if ($html_markup[$i] === '<') { + $pos_tag_start = $i; + } + } + $pos_tag_end = NULL; + for ($i = $pos_match; $pos_tag_end === NULL && $i < strlen($html_markup); $i++) { + if ($html_markup[$i] === '>') { + $pos_tag_end = $i; + } + } + + // Get the HTML: this will be the opening part of a single tag, e.g.: + // + $tag = substr($html_markup, $pos_tag_start, $pos_tag_end - $pos_tag_start + 1); + + // Parse it into a DOMDocument so we can reliably read and modify + // attributes. + $dom = new \DOMDocument(); + @$dom->loadHTML('' . $tag . ''); + $node = $dom->getElementsByTagName('body')->item(0)->firstChild; + + // Ensure we don't set the "active" class twice on the same element. + $class = $node->getAttribute('class'); + $add_active = !in_array('is-active', explode(' ', $class)); + + // The language of an active link is equal to the current language. + if ($add_active && $url_language) { + if ($node->hasAttribute('hreflang') && $node->getAttribute('hreflang') !== $url_language) { + $add_active = FALSE; + } + } + // The query parameters of an active link are equal to the current + // parameters. + if ($add_active) { + if ($query) { + if (!$node->hasAttribute('data-drupal-link-query') || $node->getAttribute('data-drupal-link-query') !== Json::encode($query)) { + $add_active = FALSE; + } + } + else { + if ($node->hasAttribute('data-drupal-link-query')) { + $add_active = FALSE; + } + } + } + + // Only if the path, the language and the query match, we set the + // "is-active" class. + if ($add_active) { + if (strlen($class) > 0) { + $class .= ' '; + } + $class .= 'is-active'; + $node->setAttribute('class', $class); + + // Get the updated tag. + $updated_tag = $dom->saveXML($node, LIBXML_NOEMPTYTAG); + // saveXML() added a closing tag, remove it. + $updated_tag = substr($updated_tag, 0, strrpos($updated_tag, '<')); + + $html_markup = str_replace($tag, $updated_tag, $html_markup); + + // Ensure we only search the remaining HTML. + $offset = $pos_tag_end - strlen($tag) + strlen($updated_tag); + } + else { + // Ensure we only search the remaining HTML. + $offset = $pos_tag_end + 1; + } + } + + return $html_markup; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Should run after any other response subscriber that modifies the markup. + $events[KernelEvents::RESPONSE][] = ['onResponse', -512]; + + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php index 70846dd8c63..e5cdd8796fa 100644 --- a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php +++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php @@ -165,6 +165,7 @@ class HtmlRenderer implements MainContentRendererInterface { list($version) = explode('.', \Drupal::VERSION, 2); $response = new CacheableResponse($content, 200,[ + 'Content-Type' => 'text/html; charset=UTF-8', 'X-Generator' => 'Drupal ' . $version . ' (https://www.drupal.org)' ]); diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php index 90c260cf28a..13e2f73660e 100644 --- a/core/modules/system/src/Controller/SystemController.php +++ b/core/modules/system/src/Controller/SystemController.php @@ -328,132 +328,4 @@ class SystemController extends ControllerBase { return $build; } - /** - * #post_render_cache callback; sets the "is-active" class on relevant links. - * - * This is a PHP implementation of the drupal.active-link JavaScript library. - * - * @param array $element - * A renderable array with the following keys: - * - #markup - * - #attached - * @param array $context - * An array with the following keys: - * - path: the system path of the currently active page - * - front: whether the current page is the front page (which implies the - * current path might also be ) - * - language: the language code of the currently active page - * - query: the query string for the currently active page - * - * @return array - * The updated renderable array. - * - * @todo Once a future version of PHP supports parsing HTML5 properly - * (i.e. doesn't fail on https://drupal.org/comment/7938201#comment-7938201) - * then we can get rid of this manual parsing and use DOMDocument instead. - */ - public static function setLinkActiveClass(array $element, array $context) { - $search_key_current_path = 'data-drupal-link-system-path="' . $context['path'] . '"'; - $search_key_front = 'data-drupal-link-system-path="<front>"'; - - // An active link's path is equal to the current path, so search the HTML - // for an attribute with that value. - $offset = 0; - while (strpos($element['#markup'], $search_key_current_path, $offset) !== FALSE || ($context['front'] && strpos($element['#markup'], $search_key_front, $offset) !== FALSE)) { - $pos_current_path = strpos($element['#markup'], $search_key_current_path, $offset); - $pos_front = strpos($element['#markup'], $search_key_front, $offset); - - // Determine which of the two values is the next match: the exact path, or - // the special case. - $pos_match = NULL; - if ($pos_front === FALSE) { - $pos_match = $pos_current_path; - } - elseif ($pos_current_path === FALSE) { - $pos_match = $pos_front; - } - elseif ($pos_current_path < $pos_front) { - $pos_match = $pos_current_path; - } - else { - $pos_match = $pos_front; - } - - // Find beginning and ending of opening tag. - $pos_tag_start = NULL; - for ($i = $pos_match; $pos_tag_start === NULL && $i > 0; $i--) { - if ($element['#markup'][$i] === '<') { - $pos_tag_start = $i; - } - } - $pos_tag_end = NULL; - for ($i = $pos_match; $pos_tag_end === NULL && $i < strlen($element['#markup']); $i++) { - if ($element['#markup'][$i] === '>') { - $pos_tag_end = $i; - } - } - - // Get the HTML: this will be the opening part of a single tag, e.g.: - // - $tag = substr($element['#markup'], $pos_tag_start, $pos_tag_end - $pos_tag_start + 1); - - // Parse it into a DOMDocument so we can reliably read and modify - // attributes. - $dom = new \DOMDocument(); - @$dom->loadHTML('' . $tag . ''); - $node = $dom->getElementsByTagName('body')->item(0)->firstChild; - - // Ensure we don't set the "active" class twice on the same element. - $class = $node->getAttribute('class'); - $add_active = !in_array('is-active', explode(' ', $class)); - - // The language of an active link is equal to the current language. - if ($add_active && $context['language']) { - if ($node->hasAttribute('hreflang') && $node->getAttribute('hreflang') !== $context['language']) { - $add_active = FALSE; - } - } - // The query parameters of an active link are equal to the current - // parameters. - if ($add_active) { - if ($context['query']) { - if (!$node->hasAttribute('data-drupal-link-query') || $node->getAttribute('data-drupal-link-query') !== Json::encode($context['query'])) { - $add_active = FALSE; - } - } - else { - if ($node->hasAttribute('data-drupal-link-query')) { - $add_active = FALSE; - } - } - } - - // Only if the path, the language and the query match, we set the - // "is-active" class. - if ($add_active) { - if (strlen($class) > 0) { - $class .= ' '; - } - $class .= 'is-active'; - $node->setAttribute('class', $class); - - // Get the updated tag. - $updated_tag = $dom->saveXML($node, LIBXML_NOEMPTYTAG); - // saveXML() added a closing tag, remove it. - $updated_tag = substr($updated_tag, 0, strrpos($updated_tag, '<')); - - $element['#markup'] = str_replace($tag, $updated_tag, $element['#markup']); - - // Ensure we only search the remaining HTML. - $offset = $pos_tag_end - strlen($tag) + strlen($updated_tag); - } - else { - // Ensure we only search the remaining HTML. - $offset = $pos_tag_end + 1; - } - } - - return $element; - } - } diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 0575b1a648f..f2dcc7c8152 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -515,7 +515,7 @@ function system_filetransfer_info() { * Implements hook_page_attachments(). * * @see template_preprocess_maintenance_page() - * @see \Drupal\system\Controller\SystemController::setLinkActiveClass() + * @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter */ function system_page_attachments(array &$page) { // Ensure the same CSS is loaded in template_preprocess_maintenance_page(). @@ -596,25 +596,14 @@ function system_page_attachments(array &$page) { // Handle setting the "active" class on links by: // - loading the active-link library if the current user is authenticated; - // - applying a post-render cache callback if the current user is anonymous. + // - applying a response filter if the current user is anonymous. // @see l() // @see \Drupal\Core\Utility\LinkGenerator::generate() // @see template_preprocess_links() - // @see \Drupal\system\Controller\SystemController::setLinkActiveClass + // @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter if (\Drupal::currentUser()->isAuthenticated()) { $page['#attached']['library'][] = 'core/drupal.active-link'; } - else { - $page['#post_render_cache']['\Drupal\system\Controller\SystemController::setLinkActiveClass'] = array( - // Collect the current state that determines whether a link is active. - array( - 'path' => \Drupal::routeMatch()->getRouteName() ? Url::fromRouteMatch(\Drupal::routeMatch())->getInternalPath() : '', - 'front' => \Drupal::service('path.matcher')->isFrontPage(), - 'language' => \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(), - 'query' => \Drupal::request()->query->all(), - ) - ); - } } /** diff --git a/core/modules/system/tests/src/Unit/Controller/SystemControllerTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/ActiveLinkResponseFilterTest.php similarity index 88% rename from core/modules/system/tests/src/Unit/Controller/SystemControllerTest.php rename to core/tests/Drupal/Tests/Core/EventSubscriber/ActiveLinkResponseFilterTest.php index 173a4300929..f9e3eadc4c6 100644 --- a/core/modules/system/tests/src/Unit/Controller/SystemControllerTest.php +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/ActiveLinkResponseFilterTest.php @@ -2,27 +2,26 @@ /** * @file - * Contains \Drupal\Tests\system\Unit\Controller\SystemControllerTest. + * Contains \Drupal\Tests\Core\EventSubscriber\ActiveLinkResponseFilterTest. */ -namespace Drupal\Tests\system\Unit\Controller; +namespace Drupal\Tests\Core\EventSubscriber; use Drupal\Component\Serialization\Json; -use Symfony\Component\HttpFoundation\Request; +use Drupal\Core\EventSubscriber\ActiveLinkResponseFilter; use Drupal\Core\Template\Attribute; -use Drupal\system\Controller\SystemController; use Drupal\Tests\UnitTestCase; /** - * @coversDefaultClass \Drupal\system\Controller\SystemController - * @group system + * @coversDefaultClass \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter + * @group EventSubscriber */ -class SystemControllerTest extends UnitTestCase { +class ActiveLinkResponseFilterTest extends UnitTestCase { /** * Provides test data for testSetLinkActiveClass(). * - * @see \Drupal\system\Controller\SystemController::setLinkActiveClass() + * @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter::setLinkActiveClass() */ public function providerTestSetLinkActiveClass() { // Define all the variations that *don't* affect whether or not an @@ -244,14 +243,6 @@ class SystemControllerTest extends UnitTestCase { $situations[] = array('context' => $context, 'is active' => FALSE, 'attributes' => $attributes + array('hreflang' => 'en', 'data-drupal-link-query' => "")); $situations[] = array('context' => $context, 'is active' => FALSE, 'attributes' => $attributes + array('hreflang' => 'en', 'data-drupal-link-query' => TRUE)); - // Helper function to generate a stubbed renderable array. - $create_element = function ($markup) { - return array( - '#markup' => $markup, - '#attached' => array(), - ); - }; - // Loop over the surrounding HTML variations. $data = array(); for ($h = 0; $h < count($html); $h++) { @@ -292,7 +283,7 @@ class SystemControllerTest extends UnitTestCase { $target_markup = $create_markup(new Attribute($active_attributes)); } - $data[] = array($create_element($source_markup), $situation['context'], $create_element($target_markup)); + $data[] = array($source_markup, $situation['context']['path'], $situation['context']['front'], $situation['context']['language'], $situation['context']['query'], $target_markup); } } } @@ -301,9 +292,12 @@ class SystemControllerTest extends UnitTestCase { // Test case to verify that the 'is-active' class is not added multiple // times. $data[] = [ - 0 => ['#markup' => 'Once Twice'], - 1 => ['path' => '', 'front' => TRUE, 'language' => 'en', 'query' => []], - 2 => ['#markup' => 'Once Twice'], + 0 => 'Once Twice', + 1 => '', + 2 => TRUE, + 3 => 'en', + 4 => [], + 5 => 'Once Twice', ]; // Test cases to verify that the 'is-active' class is added when on the @@ -316,14 +310,20 @@ class SystemControllerTest extends UnitTestCase { $front_path_link = 'Front Path'; $front_path_link_active = 'Front Path'; $data[] = [ - 0 => ['#markup' => $front_path_link . ' ' . $front_special_link], - 1 => ['path' => 'myfrontpage', 'front' => TRUE, 'language' => 'en', 'query' => []], - 2 => ['#markup' => $front_path_link_active . ' ' . $front_special_link_active], + 0 => $front_path_link . ' ' . $front_special_link, + 1 => 'myfrontpage', + 2 => TRUE, + 3 => 'en', + 4 => [], + 5 => $front_path_link_active . ' ' . $front_special_link_active, ]; $data[] = [ - 0 => ['#markup' => $front_special_link . ' ' . $front_path_link], - 1 => ['path' => 'myfrontpage', 'front' => TRUE, 'language' => 'en', 'query' => []], - 2 => ['#markup' => $front_special_link_active . ' ' . $front_path_link_active], + 0 => $front_special_link . ' ' . $front_path_link, + 1 => 'myfrontpage', + 2 => TRUE, + 3 => 'en', + 4 => [], + 5 => $front_special_link_active . ' ' . $front_path_link_active, ]; return $data; @@ -332,25 +332,25 @@ class SystemControllerTest extends UnitTestCase { /** * Tests setLinkActiveClass(). * - * @param array $element - * A renderable array with the following keys: - * - #markup - * - #attached - * @param array $context - * The page context to simulate. An array with the following keys: - * - path: the system path of the currently active page - * - front: whether the current page is the front page (which implies the - * current path might also be ) - * - language: the language code of the currently active page - * - query: the query string for the currently active page - * @param array $expected_element - * The returned renderable array. + * @param string $html_markup + * The original HTML markup. + * @param string $current_path + * The system path of the currently active page. + * @param bool $is_front + * Whether the current page is the front page (which implies the current + * path might also be ). + * @param string $url_language + * The language code of the current URL. + * @param array $query + * The query string for the current URL. + * @param string $expected_html_markup + * The expected updated HTML markup. * * @dataProvider providerTestSetLinkActiveClass * @covers ::setLinkActiveClass */ - public function testSetLinkActiveClass(array $element, array $context, $expected_element) { - $this->assertSame($expected_element, SystemController::setLinkActiveClass($element, $context)); + public function testSetLinkActiveClass($html_markup, $current_path, $is_front, $url_language, array $query, $expected_html_markup) { + $this->assertSame($expected_html_markup, ActiveLinkResponseFilter::setLinkActiveClass($html_markup, $current_path, $is_front, $url_language, $query)); } }