ActiveLinkResponseFilter.php

Same filename in other branches
  1. 9 core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php
  2. 8.9.x core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php
  3. 11.x core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php

Namespace

Drupal\Core\EventSubscriber

File

core/lib/Drupal/Core/EventSubscriber/ActiveLinkResponseFilter.php

View source
<?php

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\Session\AccountInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
// cspell:ignore noemptytag

/**
 * Subscribes to filter HTML responses, to set attributes on active links.
 *
 * Sets the 'is-active' class and sets the aria-current attribute to 'page'.
 *
 * 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\ResponseEvent $event
     *   The response event.
     */
    public function onResponse(ResponseEvent $event) {
        $response = $event->getResponse();
        // Only care about HTML responses.
        if (stripos($response->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;
        }
        // If content is FALSE, assume the response does not support the
        // setContent() method and skip it, for example,
        // \Symfony\Component\HttpFoundation\BinaryFileResponse.
        $content = $response->getContent();
        if ($content !== FALSE) {
            $response->setContent(static::setLinkActiveClass($content, ltrim($this->currentPath
                ->getPath(), '/'), $this->pathMatcher
                ->isFrontPage(), $this->languageManager
                ->getCurrentLanguage(LanguageInterface::TYPE_URL)
                ->getId(), $event->getRequest()->query
                ->all()));
        }
    }
    
    /**
     * Sets the "is-active" class and aria-current attribute 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://www.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;"';
        // Receive the query in a standardized manner.
        ksort($query);
        $offset = 0;
        // There are two distinct conditions that can make a link be marked active:
        // 1. A link has the current path in its 'data-drupal-link-system-path'
        //    attribute.
        // 2. We are on the front page and a link has the special '<front>' value in
        //    its 'data-drupal-link-system-path' attribute.
        while (str_contains(substr($html_markup, $offset), $search_key_current_path) || $is_front && str_contains(substr($html_markup, $offset), $search_key_front)) {
            $pos_current_path = strpos($html_markup, $search_key_current_path, $offset);
            // Only look for links with the special '<front>' system path if we are
            // actually on the front page.
            $pos_front = $is_front ? strpos($html_markup, $search_key_front, $offset) : FALSE;
            // 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 ?? 0, $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 and add aria-current="page".
            if ($add_active) {
                if (strlen($class) > 0) {
                    $class .= ' ';
                }
                $class .= 'is-active';
                $node->setAttribute('class', $class);
                $node->setAttribute('aria-current', 'page');
                // 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() : array {
        // Should run after any other response subscriber that modifies the markup.
        $events[KernelEvents::RESPONSE][] = [
            'onResponse',
            -512,
        ];
        return $events;
    }

}

Classes

Title Deprecated Summary
ActiveLinkResponseFilter Subscribes to filter HTML responses, to set attributes on active links.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.