3 namespace Drupal\Core\EventSubscriber;
5 use Drupal\Component\Serialization\Json;
6 use Drupal\Core\Language\LanguageInterface;
7 use Drupal\Core\Language\LanguageManagerInterface;
8 use Drupal\Core\Path\CurrentPathStack;
9 use Drupal\Core\Path\PathMatcherInterface;
10 use Drupal\Core\Session\AccountInterface;
11 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
12 use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
13 use Symfony\Component\HttpKernel\KernelEvents;
16 * Subscribes to filter HTML responses, to set the 'is-active' class on links.
18 * Only for anonymous users; for authenticated users, the active-link asset
21 * @see system_page_attachments()
23 class ActiveLinkResponseFilter implements EventSubscriberInterface {
28 * @var \Drupal\Core\Session\AccountInterface
30 protected $currentUser;
35 * @var \Drupal\Core\Path\CurrentPathStack
37 protected $currentPath;
42 * @var \Drupal\Core\Path\PathMatcherInterface
44 protected $pathMatcher;
47 * The language manager.
49 * @var \Drupal\Core\Language\LanguageManagerInterface
51 protected $languageManager;
54 * Constructs a new ActiveLinkResponseFilter instance.
56 * @param \Drupal\Core\Session\AccountInterface $current_user
58 * @param \Drupal\Core\Path\CurrentPathStack $current_path
60 * @param \Drupal\Core\Path\PathMatcherInterface $path_matcher
62 * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
63 * The language manager.
65 public function __construct(AccountInterface $current_user, CurrentPathStack $current_path, PathMatcherInterface $path_matcher, LanguageManagerInterface $language_manager) {
66 $this->currentUser = $current_user;
67 $this->currentPath = $current_path;
68 $this->pathMatcher = $path_matcher;
69 $this->languageManager = $language_manager;
73 * Sets the 'is-active' class on links.
75 * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
78 public function onResponse(FilterResponseEvent $event) {
79 // Only care about HTML responses.
80 if (stripos($event->getResponse()->headers->get('Content-Type'), 'text/html') === FALSE) {
84 // For authenticated users, the 'is-active' class is set in JavaScript.
85 // @see system_page_attachments()
86 if ($this->currentUser->isAuthenticated()) {
90 $response = $event->getResponse();
91 $response->setContent(static::setLinkActiveClass(
92 $response->getContent(),
93 ltrim($this->currentPath->getPath(), '/'),
94 $this->pathMatcher->isFrontPage(),
95 $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(),
96 $event->getRequest()->query->all()
101 * Sets the "is-active" class on relevant links.
103 * This is a PHP implementation of the drupal.active-link JavaScript library.
105 * @param string $html_markup
106 * The HTML markup to update.
107 * @param string $current_path
108 * The system path of the currently active page.
109 * @param bool $is_front
110 * Whether the current page is the front page (which implies the current
111 * path might also be <front>).
112 * @param string $url_language
113 * The language code of the current URL.
114 * @param array $query
115 * The query string for the current URL.
118 * The updated HTML markup.
120 * @todo Once a future version of PHP supports parsing HTML5 properly
121 * (i.e. doesn't fail on
122 * https://www.drupal.org/comment/7938201#comment-7938201) then we can get
123 * rid of this manual parsing and use DOMDocument instead.
125 public static function setLinkActiveClass($html_markup, $current_path, $is_front, $url_language, array $query) {
126 $search_key_current_path = 'data-drupal-link-system-path="' . $current_path . '"';
127 $search_key_front = 'data-drupal-link-system-path="<front>"';
129 // Receive the query in a standardized manner.
133 // There are two distinct conditions that can make a link be marked active:
134 // 1. A link has the current path in its 'data-drupal-link-system-path'
136 // 2. We are on the front page and a link has the special '<front>' value in
137 // its 'data-drupal-link-system-path' attribute.
138 while (strpos($html_markup, $search_key_current_path, $offset) !== FALSE || ($is_front && strpos($html_markup, $search_key_front, $offset) !== FALSE)) {
139 $pos_current_path = strpos($html_markup, $search_key_current_path, $offset);
140 // Only look for links with the special '<front>' system path if we are
141 // actually on the front page.
142 $pos_front = $is_front ? strpos($html_markup, $search_key_front, $offset) : FALSE;
144 // Determine which of the two values is the next match: the exact path, or
145 // the <front> special case.
147 if ($pos_front === FALSE) {
148 $pos_match = $pos_current_path;
150 elseif ($pos_current_path === FALSE) {
151 $pos_match = $pos_front;
153 elseif ($pos_current_path < $pos_front) {
154 $pos_match = $pos_current_path;
157 $pos_match = $pos_front;
160 // Find beginning and ending of opening tag.
161 $pos_tag_start = NULL;
162 for ($i = $pos_match; $pos_tag_start === NULL && $i > 0; $i--) {
163 if ($html_markup[$i] === '<') {
168 for ($i = $pos_match; $pos_tag_end === NULL && $i < strlen($html_markup); $i++) {
169 if ($html_markup[$i] === '>') {
174 // Get the HTML: this will be the opening part of a single tag, e.g.:
175 // <a href="/" data-drupal-link-system-path="<front>">
176 $tag = substr($html_markup, $pos_tag_start, $pos_tag_end - $pos_tag_start + 1);
178 // Parse it into a DOMDocument so we can reliably read and modify
180 $dom = new \DOMDocument();
181 @$dom->loadHTML('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $tag . '</body></html>');
182 $node = $dom->getElementsByTagName('body')->item(0)->firstChild;
184 // Ensure we don't set the "active" class twice on the same element.
185 $class = $node->getAttribute('class');
186 $add_active = !in_array('is-active', explode(' ', $class));
188 // The language of an active link is equal to the current language.
189 if ($add_active && $url_language) {
190 if ($node->hasAttribute('hreflang') && $node->getAttribute('hreflang') !== $url_language) {
194 // The query parameters of an active link are equal to the current
198 if (!$node->hasAttribute('data-drupal-link-query') || $node->getAttribute('data-drupal-link-query') !== Json::encode($query)) {
203 if ($node->hasAttribute('data-drupal-link-query')) {
209 // Only if the path, the language and the query match, we set the
210 // "is-active" class.
212 if (strlen($class) > 0) {
215 $class .= 'is-active';
216 $node->setAttribute('class', $class);
218 // Get the updated tag.
219 $updated_tag = $dom->saveXML($node, LIBXML_NOEMPTYTAG);
220 // saveXML() added a closing tag, remove it.
221 $updated_tag = substr($updated_tag, 0, strrpos($updated_tag, '<'));
223 $html_markup = str_replace($tag, $updated_tag, $html_markup);
225 // Ensure we only search the remaining HTML.
226 $offset = $pos_tag_end - strlen($tag) + strlen($updated_tag);
229 // Ensure we only search the remaining HTML.
230 $offset = $pos_tag_end + 1;
240 public static function getSubscribedEvents() {
241 // Should run after any other response subscriber that modifies the markup.
242 $events[KernelEvents::RESPONSE][] = ['onResponse', -512];