Outbound Links Opened in New Tabs
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.
When is a link external?
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. 😊
Finally: implement the link alter hook
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).