Issue #2478443 by Wim Leers: Set the 'is-active' class for anonymous users in a Response Filter instead of a #post_render_cache callback

8.0.x
Nathaniel Catchpole 2015-04-28 16:44:27 +01:00
parent a4fac14a6d
commit 046965dfc1
6 changed files with 297 additions and 183 deletions

View File

@ -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 }

View File

@ -0,0 +1,246 @@
<?php
/**
* @file
* Contains \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter.
*/
namespace Drupal\Core\EventSubscriber;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\Path\PathMatcherInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Subscribes to filter HTML responses, to set the 'is-active' class on links.
*
* Only for anonymous users; for authenticated users, the active-link asset
* library is loaded.
*
* @see system_page_attachments()
*/
class ActiveLinkResponseFilter implements EventSubscriberInterface {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The current path.
*
* @var \Drupal\Core\Path\CurrentPathStack
*/
protected $currentPath;
/**
* The path matcher.
*
* @var \Drupal\Core\Path\PathMatcherInterface
*/
protected $pathMatcher;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* Constructs a new ActiveLinkResponseFilter instance.
*
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\Core\Path\CurrentPathStack $current_path
* The current path.
* @param \Drupal\Core\Path\PathMatcherInterface $path_matcher
* The path matcher.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct(AccountInterface $current_user, CurrentPathStack $current_path, PathMatcherInterface $path_matcher, LanguageManagerInterface $language_manager) {
$this->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 <front>).
* @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="&lt;front&gt;"';
// 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 <front> 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.:
// <a href="/" data-drupal-link-system-path="&lt;front&gt;">
$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('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $tag . '</body></html>');
$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;
}
}

View File

@ -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)'
]);

View File

@ -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 <front>)
* - 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="&lt;front&gt;"';
// 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 <front> 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.:
// <a href="/" data-drupal-link-system-path="&lt;front&gt;">
$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('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $tag . '</body></html>');
$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;
}
}

View File

@ -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(),
)
);
}
}
/**

View File

@ -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' => '<a data-drupal-link-system-path="&lt;front&gt;">Once</a> <a data-drupal-link-system-path="&lt;front&gt;">Twice</a>'],
1 => ['path' => '', 'front' => TRUE, 'language' => 'en', 'query' => []],
2 => ['#markup' => '<a data-drupal-link-system-path="&lt;front&gt;" class="is-active">Once</a> <a data-drupal-link-system-path="&lt;front&gt;" class="is-active">Twice</a>'],
0 => '<a data-drupal-link-system-path="&lt;front&gt;">Once</a> <a data-drupal-link-system-path="&lt;front&gt;">Twice</a>',
1 => '',
2 => TRUE,
3 => 'en',
4 => [],
5 => '<a data-drupal-link-system-path="&lt;front&gt;" class="is-active">Once</a> <a data-drupal-link-system-path="&lt;front&gt;" class="is-active">Twice</a>',
];
// 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 = '<a data-drupal-link-system-path="myfrontpage">Front Path</a>';
$front_path_link_active = '<a data-drupal-link-system-path="myfrontpage" class="is-active">Front Path</a>';
$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 <front>)
* - 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 <front>).
* @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));
}
}