ActiveLinkResponseFilter.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. <?php
  2. namespace Drupal\Core\EventSubscriber;
  3. use Drupal\Component\Serialization\Json;
  4. use Drupal\Core\Language\LanguageInterface;
  5. use Drupal\Core\Language\LanguageManagerInterface;
  6. use Drupal\Core\Path\CurrentPathStack;
  7. use Drupal\Core\Path\PathMatcherInterface;
  8. use Drupal\Core\Session\AccountInterface;
  9. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  10. use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
  11. use Symfony\Component\HttpKernel\KernelEvents;
  12. /**
  13. * Subscribes to filter HTML responses, to set the 'is-active' class on links.
  14. *
  15. * Only for anonymous users; for authenticated users, the active-link asset
  16. * library is loaded.
  17. *
  18. * @see system_page_attachments()
  19. */
  20. class ActiveLinkResponseFilter implements EventSubscriberInterface {
  21. /**
  22. * The current user.
  23. *
  24. * @var \Drupal\Core\Session\AccountInterface
  25. */
  26. protected $currentUser;
  27. /**
  28. * The current path.
  29. *
  30. * @var \Drupal\Core\Path\CurrentPathStack
  31. */
  32. protected $currentPath;
  33. /**
  34. * The path matcher.
  35. *
  36. * @var \Drupal\Core\Path\PathMatcherInterface
  37. */
  38. protected $pathMatcher;
  39. /**
  40. * The language manager.
  41. *
  42. * @var \Drupal\Core\Language\LanguageManagerInterface
  43. */
  44. protected $languageManager;
  45. /**
  46. * Constructs a new ActiveLinkResponseFilter instance.
  47. *
  48. * @param \Drupal\Core\Session\AccountInterface $current_user
  49. * The current user.
  50. * @param \Drupal\Core\Path\CurrentPathStack $current_path
  51. * The current path.
  52. * @param \Drupal\Core\Path\PathMatcherInterface $path_matcher
  53. * The path matcher.
  54. * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
  55. * The language manager.
  56. */
  57. public function __construct(AccountInterface $current_user, CurrentPathStack $current_path, PathMatcherInterface $path_matcher, LanguageManagerInterface $language_manager) {
  58. $this->currentUser = $current_user;
  59. $this->currentPath = $current_path;
  60. $this->pathMatcher = $path_matcher;
  61. $this->languageManager = $language_manager;
  62. }
  63. /**
  64. * Sets the 'is-active' class on links.
  65. *
  66. * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
  67. * The response event.
  68. */
  69. public function onResponse(FilterResponseEvent $event) {
  70. // Only care about HTML responses.
  71. if (stripos($event->getResponse()->headers->get('Content-Type'), 'text/html') === FALSE) {
  72. return;
  73. }
  74. // For authenticated users, the 'is-active' class is set in JavaScript.
  75. // @see system_page_attachments()
  76. if ($this->currentUser->isAuthenticated()) {
  77. return;
  78. }
  79. $response = $event->getResponse();
  80. $response->setContent(static::setLinkActiveClass(
  81. $response->getContent(),
  82. ltrim($this->currentPath->getPath(), '/'),
  83. $this->pathMatcher->isFrontPage(),
  84. $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(),
  85. $event->getRequest()->query->all()
  86. ));
  87. }
  88. /**
  89. * Sets the "is-active" class on relevant links.
  90. *
  91. * This is a PHP implementation of the drupal.active-link JavaScript library.
  92. *
  93. * @param string $html_markup
  94. * The HTML markup to update.
  95. * @param string $current_path
  96. * The system path of the currently active page.
  97. * @param bool $is_front
  98. * Whether the current page is the front page (which implies the current
  99. * path might also be <front>).
  100. * @param string $url_language
  101. * The language code of the current URL.
  102. * @param array $query
  103. * The query string for the current URL.
  104. *
  105. * @return string
  106. * The updated HTML markup.
  107. *
  108. * @todo Once a future version of PHP supports parsing HTML5 properly
  109. * (i.e. doesn't fail on
  110. * https://www.drupal.org/comment/7938201#comment-7938201) then we can get
  111. * rid of this manual parsing and use DOMDocument instead.
  112. */
  113. public static function setLinkActiveClass($html_markup, $current_path, $is_front, $url_language, array $query) {
  114. $search_key_current_path = 'data-drupal-link-system-path="' . $current_path . '"';
  115. $search_key_front = 'data-drupal-link-system-path="&lt;front&gt;"';
  116. // Receive the query in a standardized manner.
  117. ksort($query);
  118. $offset = 0;
  119. // There are two distinct conditions that can make a link be marked active:
  120. // 1. A link has the current path in its 'data-drupal-link-system-path'
  121. // attribute.
  122. // 2. We are on the front page and a link has the special '<front>' value in
  123. // its 'data-drupal-link-system-path' attribute.
  124. while (strpos($html_markup, $search_key_current_path, $offset) !== FALSE || ($is_front && strpos($html_markup, $search_key_front, $offset) !== FALSE)) {
  125. $pos_current_path = strpos($html_markup, $search_key_current_path, $offset);
  126. // Only look for links with the special '<front>' system path if we are
  127. // actually on the front page.
  128. $pos_front = $is_front ? strpos($html_markup, $search_key_front, $offset) : FALSE;
  129. // Determine which of the two values is the next match: the exact path, or
  130. // the <front> special case.
  131. $pos_match = NULL;
  132. if ($pos_front === FALSE) {
  133. $pos_match = $pos_current_path;
  134. }
  135. elseif ($pos_current_path === FALSE) {
  136. $pos_match = $pos_front;
  137. }
  138. elseif ($pos_current_path < $pos_front) {
  139. $pos_match = $pos_current_path;
  140. }
  141. else {
  142. $pos_match = $pos_front;
  143. }
  144. // Find beginning and ending of opening tag.
  145. $pos_tag_start = NULL;
  146. for ($i = $pos_match; $pos_tag_start === NULL && $i > 0; $i--) {
  147. if ($html_markup[$i] === '<') {
  148. $pos_tag_start = $i;
  149. }
  150. }
  151. $pos_tag_end = NULL;
  152. for ($i = $pos_match; $pos_tag_end === NULL && $i < strlen($html_markup); $i++) {
  153. if ($html_markup[$i] === '>') {
  154. $pos_tag_end = $i;
  155. }
  156. }
  157. // Get the HTML: this will be the opening part of a single tag, e.g.:
  158. // <a href="/" data-drupal-link-system-path="&lt;front&gt;">
  159. $tag = substr($html_markup, $pos_tag_start, $pos_tag_end - $pos_tag_start + 1);
  160. // Parse it into a DOMDocument so we can reliably read and modify
  161. // attributes.
  162. $dom = new \DOMDocument();
  163. @$dom->loadHTML('<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $tag . '</body></html>');
  164. $node = $dom->getElementsByTagName('body')->item(0)->firstChild;
  165. // Ensure we don't set the "active" class twice on the same element.
  166. $class = $node->getAttribute('class');
  167. $add_active = !in_array('is-active', explode(' ', $class));
  168. // The language of an active link is equal to the current language.
  169. if ($add_active && $url_language) {
  170. if ($node->hasAttribute('hreflang') && $node->getAttribute('hreflang') !== $url_language) {
  171. $add_active = FALSE;
  172. }
  173. }
  174. // The query parameters of an active link are equal to the current
  175. // parameters.
  176. if ($add_active) {
  177. if ($query) {
  178. if (!$node->hasAttribute('data-drupal-link-query') || $node->getAttribute('data-drupal-link-query') !== Json::encode($query)) {
  179. $add_active = FALSE;
  180. }
  181. }
  182. else {
  183. if ($node->hasAttribute('data-drupal-link-query')) {
  184. $add_active = FALSE;
  185. }
  186. }
  187. }
  188. // Only if the path, the language and the query match, we set the
  189. // "is-active" class.
  190. if ($add_active) {
  191. if (strlen($class) > 0) {
  192. $class .= ' ';
  193. }
  194. $class .= 'is-active';
  195. $node->setAttribute('class', $class);
  196. // Get the updated tag.
  197. $updated_tag = $dom->saveXML($node, LIBXML_NOEMPTYTAG);
  198. // saveXML() added a closing tag, remove it.
  199. $updated_tag = substr($updated_tag, 0, strrpos($updated_tag, '<'));
  200. $html_markup = str_replace($tag, $updated_tag, $html_markup);
  201. // Ensure we only search the remaining HTML.
  202. $offset = $pos_tag_end - strlen($tag) + strlen($updated_tag);
  203. }
  204. else {
  205. // Ensure we only search the remaining HTML.
  206. $offset = $pos_tag_end + 1;
  207. }
  208. }
  209. return $html_markup;
  210. }
  211. /**
  212. * {@inheritdoc}
  213. */
  214. public static function getSubscribedEvents() {
  215. // Should run after any other response subscriber that modifies the markup.
  216. $events[KernelEvents::RESPONSE][] = ['onResponse', -512];
  217. return $events;
  218. }
  219. }