In this article, I want to show you a way to identify outbound links in Drupal and how we can make them open a new browser tab.

Where can we intervene?

The most common places where Drupal displays links are menus. How are menus rendered in Drupal? Well, mainly by the really complex menu[__suggestion].html.twig Twig templates. The menu component is a simple theme function (I mean it’s not a render element), it does not have a default template_preprocess implementation, the output is controlled mostly by the template file itself. Despite these, here we have a great altering point to make the needed modifications by implementing hook_preprocess_HOOK() for menu.

But since the menu links are links (surprise 😉), they are processed by the LinkGenerator service. And that service invokes ModuleHandler::alter() for links before creating the HTML-ready output, so we can use hook_link_alter() as well. And this happens with every kind of links that are created in a Drupal-way:

  • Links that are generated in Drupal\Core\Link
  • Link render elements
  • Menu links, local task links, actions links, and language switcher links, obviously
  • And even with those links that are generated with the deprecated \Drupal::l() method (this is also obvious since it’s just a wrapper around the link generator service).

Let’s choose the link alter hook now because it covers almost all of our links.

A possible solution

The situation is simple: we have to check the URL of the link, and if it refers to an external resource, add the needed attributes to the link that makes it open a new tab. First, let’s create a helper class with a method that returns TRUE if the URL it got points is not an internal link. You may use the one I shared in the previous article as a starting point, but this time we will implement our own business logic instead of relying on UrlHelper. It is also worth it having and running the unit test of the previous article, as we will refactor the helper’s previously developed and tested method.

In most of the cases, if the Url object thinks it is external:

1
2
3
4
5
6
7
8
9
10
11
12
/**
 * Determines whether an Url is external to Drupal.
 *
 * @param \Drupal\Core\Url $url
 *   The url to check.
 *
 * @return bool
 *   TRUE or FALSE, where TRUE indicates an external path.
 */
public static function isExternalUrl($url) {
  return $url->isExternal();
}

Test results: ..FFFF....FFFFFFFF............ – 12 failing cases from 30 total.

It was obvious that this solution doesn’t cover all cases: if we’re checking an internal URL that’s path is absolute, we get a false-positive result. We have to strictly check the provided URL by comparing its string value to our current host (we’re assuming that it is https://example.com). Try to continue with regular expression matching!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function isExternalUrl($url) {
  if (!$url->isExternal()) {
    return FALSE;
  }

  static $host_pattern;
  if (!isset($host_pattern)) {
    $host = \Drupal::request()->getHost();
    $host_pattern = preg_quote($host);
  }

  $uri_string = $url->toUriString();

  return preg_match('/\/\/' . $host_pattern . '/u', $uri_string) === 0;
}

Test result: ......FFFF........FFFF....FFFF – 12 failing cases from 30 total.

Stop for a moment and check the failing cases!

We can see that we have two issues with our expression. The first four failures happen because they match strings like [https:]//example.company[\w]. We have to change the end of the expression after our host string. I think that the easiest way is to add a trailing slash to the end of the string subject and modify the expression to require that trailing slash: \/\/example.com\//u.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function isExternalUrl($url) {
  if (!$url->isExternal()) {
    return FALSE;
  }

  static $host_pattern;
  if (!isset($host_pattern)) {
    $host = \Drupal::request()->getHost();
    $host_pattern = preg_quote($host);
  }

  $uri_string = rtrim($url->toUriString(), '/') . '/';

  return preg_match('/\/\/' . $host_pattern . '\//u', $uri_string) === 0;
}

Test result: ..........................FF.. – 2 failing cases from 30 total.

Yes, so the other issue is that we match every string that contains //example.com/, so for example checking the URL https://drupal.org//example.com/ will return false-negative. The easiest solution for this is matching only when the fragment before the alias does not contain slashes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function isExternalUrl($url) {
  if (!$url->isExternal()) {
    return FALSE;
  }

  static $host_pattern;
  if (!isset($host_pattern)) {
    $host = \Drupal::request()->getHost();
    $host_pattern = preg_quote($host);
  }

  $uri_string = rtrim($url->toUriString(), '/') . '/';

  return preg_match('/^[^\/]*\/\/' . $host_pattern . '\//u', $uri_string) === 0;
}

Test result: 30 passes from 30 total. 😊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

/**
 * @file
 * Contains hooks and other functions for the demo external link module.
 */

use Drupal\external_link\Utility\ExternalLinkUrlHelper;

/**
 * Implements hook_link_alter().
 */
function external_link_link_alter(&$variables) {
  if (ExternalLinkUrlHelper::isExternalUrl($variables['url'])) {
    $url_attributes = $variables['url']->getOption('attributes') ?: [];
    $url_attributes['target'] = '_blank';

    if (
      empty($url_attributes['rel']) ||
      mb_strpos($url_attributes['rel'], 'noopener') === FALSE
    ) {
      $url_attributes['rel'] = empty($url_attributes['rel']) ?
        'noopener' :
        $url_attributes['rel'] . ' noopener';
    }

    $variables['url']->setOption('attributes', $url_attributes);
  }
}

Summary

With the solution above, all of our outbound links will open a new tab.

However, I suggest you to do this separately: With the help of the menu preprocess hook for menu links, and with a link field formatter for those field types. If you do it this way, you don’t have to cover – for instance – those multilingual cases when the URL language detection is set to different domains (e.g. example.eu for English and example.hu for Hungarian language).