More Accessible Image Links

June 3, 2023

Drupal's core “Manage display” functionality provides the option to link images to content, which is handy for, e.g., teasers. If the link contains nothing but an image, however, some accessibility checkers will (rightly) flag these links because there's no textual information indicating where they're going. One solution is to add a title attribute to the link.

Step one is to use template_preprocess_image_formatter() to make the title text available to the Twig template:

/**
 * Implements template_preprocess_image_formatter().
 *
 * Add a title attribute to image links.
 */
function THEME_preprocess_image_formatter(array &$variables) {
  // Only proceed if there is a link.
  if (isset($variables['url']) && ($variables['url']) && ($url = $variables['url']->toString())) {
  	// Set a slightly-better-than-nothing link title.
    $link_title = t('Read more');
    // Using the URL, try to get the label of the linked content to use instead
    // of "Read more".
    $alias = _THEME_url_to_alias($url);
    if ($path = \Drupal::service('path_alias.manager')->getPathByAlias($alias)) {
      if ($entity_data = _THEME_entity_data_from_path($path)) {
        if (isset($entity_data['label'])) {
          $link_title = $entity_data['label'];
        }
      }
    }
    $variables['link_attributes'] ??= [];
    $variables['link_attributes']['title'] = $link_title;
  }
}

If a URL exists, this uses it to fetch some information about the linked entity, including the label to use as the link title. Note the creation of a new top level link_attributes element in $variables.

A couple of helper functions are referenced here, because in the context of the whole theme they provide functionality that's reused a few times. First, the URL is converted to an alias that can be used to find the linked content:

/**
 * Convert a URL to an alias.
 *
 * @param string $url
 *   The URL to convert.
 */
function _THEME_url_to_alias(string $url) {
  $alias = $url;
  $base_path = base_path();
  if (substr($alias, 0, strlen($base_path)) == $base_path) {
    $alias = substr($alias, strlen($base_path));
  }
  if (!str_starts_with($alias, '/')) {
    $alias = "/{$alias}";
  }
  return $alias;
}

This just handles removing the base path (I build a lot of Drupal sites that live in subdirectories) and ensuring that the alias starts with a slash. The next helper function uses the alias to provide some information about the entity, including the label:

/**
 * Try to determine the entity type, ID, bundle and label based on the path.
 *
 * @param string $path
 *   The path to evaluate.
 */
function _THEME_entity_data_from_path(string $path) {
  $entity_elements = [];
  $type = NULL;
  $id = NULL;
  if ($path_parts = _THEME_path_parts($path, FALSE)) {
    if (count($path_parts) == 2) {
      if (in_array($path_parts[0], ['node', 'user'])) {
        $type = $path_parts[0];
        $id = $path_parts[1];
      }
    }
    elseif ((count($path_parts) == 3) && ($path_parts[0] == 'taxonomy') && ($path_parts[1] == 'term')) {
      $type = 'taxonomy-term';
      $id = $path_parts[2];
    }
  }
  if (($type) && ($id)) {
    if ($entity = \Drupal::entityTypeManager()->getStorage($type)->load($id)) {
      $entity_elements = _THEME_entity_elements($entity, $type);
    }
  }
  return $entity_elements;
}

This currently provides data for nodes, taxonomy terms, and users, but it could be expanded to add additional entities. A couple of additional helper functions are referenced here—first, just to get a clean array of path parts to work with:

/**
 * Return a clean array of path parts.
 *
 * @param string $path
 *   The path to evaluate. If NULL, the current path is evaluated.
 * @param bool $use_alias
 *   Whether to use the alias (if available) rather than the system path.
 */
function _THEME_path_parts(string $path = NULL, bool $use_alias = TRUE) {
  $path_parts = [];
  if (!$path) {
    $path = \Drupal::service('path.current')->getPath();
  }
  if (($use_alias) && ($path) && (!empty(\Drupal::hasService('path_alias.manager')))) {
    $path = \Drupal::service('path_alias.manager')->getAliasByPath($path);
  }
  if ($path) {
    $path_parts = explode('/', $path);
    $path_parts = array_filter($path_parts);
    $path_parts = array_values($path_parts);
  }
  return $path_parts;
}

…and then to actually retrieve the entity information:

/**
 * Given an entity, return some information.
 *
 * @param mixed $entity
 *   The entity to evaluate.
 * @param string $entity_type
 *   The entity type (required because users are different).
 */
function _THEME_entity_elements($entity, string $entity_type) {
  $entity_elements = [
    'type' => $entity_type,
  ];
  if (in_array($entity_type, ['node', 'taxonomy-term'])) {
    if ($bundle = $entity->bundle()) {
      $entity_elements['bundle'] = $bundle;
    }
    if ($id = $entity->id()) {
      $entity_elements['id'] = $id;
    }
    if ($label = $entity->label()) {
      $entity_elements['label'] = $label;
    }
  }
  elseif ($entity_type == 'user') {
    if ($id = $entity->id()) {
      $entity_elements['id'] = $id;
    }
    if ($label = $entity->getDisplayName()) {
      $entity_elements['label'] = $label;
    }
    if ($roles = $entity->getRoles()) {
      $entity_elements['extra'] = [
        'role' => [],
      ];
      foreach ($roles as $role) {
        $entity_elements['extra']['role'][] = $role;
      }
    }
  }
  return $entity_elements;
}

For nodes, taxonomy terms, and users, this should return an array of the entity type, the bundle (if relevant—users don't usually have bundles), the ID, and the label, plus an array of roles for users. All that really matters in this context, though, is the label, which is used as the title of the image link to the content via the link_attributes element added to $variables in THEME_preprocess_image_formatter() above.

Of course, until the Twig template is updated, the link attributes won't actually show up, so that's the final step—here are the contents of the image-formatter.html.twig template:

<div{{ attributes }}>
  {% if url %}
    {{ link(image, url, link_attributes) }}
  {% else %}
    {{ image }}
  {% endif %}
</div>
The Alhambra in Granada, Spain, March 2014

The Alhambra in Granada, Spain, March 2014