Make External Links in Formatted Text Fields Open New Tabs
In the previous post we changed Drupal-generated external links and made them open a new tab. But there we left an important case uncovered where outbound links can potentially be rendered as well: this is formatted text fields.
In this article we will cover this missing case:
- We will create a new Filter plugin for identifying and processing external links in formatted text fields.
- We will improve our recently written
ExternalLinkUrlHelper
utility by adding a new static method that accepts a URI string as an argument.
The skeleton
Let’s re-open the module from my previous article and add a bare Filter plugin!
src/Plugin/Filter/ExternalLinkFilter.php
:
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
30
31
32
33
34
<?php
namespace Drupal\external_link\Plugin\Filter;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
/**
* Provides a filter to process external links.
*
* @Filter(
* id = "filter_external_link",
* title = @Translation("External link filter"),
* description = @Translation("Blank target and noopener relation for external links"),
* type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE
* )
*/
class ExternalLinkFilter extends FilterBase {
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
return new FilterProcessResult($text);
}
/**
* {@inheritdoc}
*/
public function tips($long) {
return $this->t('External links will open a new tab.');
}
}
Since we have a new unit, we will need a new unit test as well. We want to handle as many cases as possible, but I’ll share only a few here. It is a good idea to reuse and extend the test cases from the test of the helper class. (We have to mock the same things that we mocked in the helper’s test.)
tests/src/Unit/ExternalLinkFilterTest.php
:
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
<?php
namespace Drupal\Tests\external_link\Unit;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\external_link\Plugin\Filter\ExternalLinkFilter;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @group filter
*/
class ExternalLinkFilterTest extends UnitTestCase {
/**
* @var \Drupal\external_link\Plugin\Filter\ExternalLinkFilter
*/
protected $filter;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$request = Request::createFromGlobals();
$request->headers->set('HOST', 'example.com');
// Mocks the request stack getting the current request.
$request_stack = $this
->createMock('Symfony\Component\HttpFoundation\RequestStack');
$request_stack->expects($this->any())
->method('getCurrentRequest')
->willReturn($request);
$container = new ContainerBuilder();
$container->set('request_stack', $request_stack);
\Drupal::setContainer($container);
$this->filter = new ExternalLinkFilter([], 'filter_external_link', ['provider' => 'external_link']);
}
/**
* Test markups.
*
* @dataProvider providerTestMarkup
*
* @param string $html
* Input HTML.
* @param string $expected
* The expected output string.
*/
public function testExternalLinkFilter($html, $expected) {
$this->assertSame($expected, (string) $this->filter->process($html, 'en'));
}
/**
* Provides data for testExternalLinkFilter.
*
* @return array
* An array of test data.
*/
public function providerTestMarkup() {
return [
[
'<a>No reference</a>',
'<a>No reference</a>',
],
[
'<a href="/">Home</a>',
'<a href="/">Home</a>',
],
[
'<a href="#somewhere">Anchor</a>',
'<a href="#somewhere">Anchor</a>',
],
[
'<a href="internal:node/1">Malformed internal uri</a>',
'<a href="internal:node/1">Malformed internal uri</a>',
],
[
'<a href="internal:/node/1">Internal non-parsed uri</a>',
'<a href="internal:/node/1">Internal non-parsed uri</a>',
],
[
'<a href="/node/1">Parsed internal uri</a>',
'<a href="/node/1">Parsed internal uri</a>',
],
[
'<a href="//example.com">Internal protocol-relative</a>',
'<a href="//example.com">Internal protocol-relative</a>',
],
[
'<a href="//example.company">External proto-rel</a>',
'<a href="//example.company" rel="noopener" target="_blank">External proto-rel</a>',
],
[
'<a href="https://example.company/test/subpath">External https + subpath</a>',
'<a href="https://example.company/test/subpath" rel="noopener" target="_blank">External https + subpath</a>',
],
[
'<a href="http://drupal.org/example.company/test">Drupal.org http external</a>',
'<a href="http://drupal.org/example.company/test" rel="noopener" target="_blank">Drupal.org http external</a>',
],
// Test cases for existing rel and target attributes.
[
'<a href="/" target="_blank">Home</a>',
'<a href="/" target="_blank">Home</a>',
],
[
'<a href="//example.com/user/113" target="_blank" rel="author">Internal protocol-relative</a>',
'<a href="//example.com/user/113" target="_blank" rel="author">Internal protocol-relative</a>',
],
[
'<a href="//example.company" target="_self">External proto-rel</a>',
'<a href="//example.company" target="_blank" rel="noopener">External proto-rel</a>',
],
[
'<a href="//example.company/" target="_blank" rel="nofollow">External proto-rel</a>',
'<a href="//example.company/" target="_blank" rel="nofollow noopener">External proto-rel</a>',
],
[
'<a href="//example.company/test" target="_blank" rel>External proto-rel + subpath</a>',
'<a href="//example.company/test" target="_blank" rel="noopener">External proto-rel + subpath</a>',
],
[
'<a href="https://example.company" rel="noreferrer nofollow">External https</a>',
'<a href="https://example.company" rel="noreferrer nofollow noopener" target="_blank">External https</a>',
],
[
'<a href="͋͘҉˰Ęм˪ؕۏ½ȕ͢ϲۧݸ߽ظɂٹч߂וø̥ۚ֫Ѯ҉ȖԼԪңӔ߮ɕײσ߅÷ȣDzۉˮƔږѢȦ܄ҰԲ͓ȆʟŚΌܸƺէҳΩњʟԅյ۵߬ϼǨы۳ǠǗƴˇЯɌިСɒԥĹѲɞćҏۭېΓ˺Юڙٸ؍طʞޘڏ۽˺ެɫӯ؟ĚߖŦ̺·ǖڮږÄƺϾ֞ƻܥӝ">Two-byte UTF8 cat walked over the keyboard</a>',
'<a href="͋͘҉˰Ęм˪ؕۏ½ȕ͢ϲۧݸ߽ظɂٹч߂וø̥ۚ֫Ѯ҉ȖԼԪңӔ߮ɕײσ߅÷ȣDzۉˮƔږѢȦ܄ҰԲ͓ȆʟŚΌܸƺէҳΩњʟԅյ۵߬ϼǨы۳ǠǗƴˇЯɌިСɒԥĹѲɞćҏۭېΓ˺Юڙٸ؍طʞޘڏ۽˺ެɫӯ؟ĚߖŦ̺·ǖڮږÄƺϾ֞ƻܥӝ">Two-byte UTF8 cat walked over the keyboard</a>'
],
];
}
}
The implementation
Let’s assume that the built-in URL filter “Convert URLs into links” (filter_url
) precedes our new filter and we only have to parse anchor tags: in the filter plugin, we will test the href
attribute of the anchors and make the needed markup changes on the external ones.
If we want to reuse our custom helper’s already existing logic (and not relying on the core-provided UrlHelper, even if in most of the cases it works as expected), we need to create a valid Url objects from these URI strings. At first, we will try to instantiate the Url object assuming that the string parameter is a regular URI and if it fails, we will try to create the Url with the fromUserInput
method. I think it is a good idea to add a new method in our helper class for this (in src/Utility/ExternalLinkUrlHelper.php
):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Determines whether a URI string is external to Drupal.
*
* @param string $uri_string
* The URI string to check.
*
* @return bool
* TRUE or FALSE, where TRUE indicates an external path.
*/
public static function isExternalUriString($uri_string) {
try {
$url = Url::fromUri($uri_string);
}
catch (\Exception $e) {
$url = Url::fromUserInput($uri_string);
}
return static::isExternalUrl($url);
}
Now that the missing method is available, we are ready to implement our new filter’s Drupal\external_link\Plugin\FilterExternalLinkFilter::process
method:
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
30
31
32
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
$html_dom = Html::load($text);
$links = $html_dom->getElementsByTagName('a');
foreach ($links as $link) {
$uri_string = $link->getAttribute('href');
if (
!empty($uri_string) &&
ExternalLinkUrlHelper::isExternalUriString($uri_string)
) {
$link_rel = $link->getAttribute('rel');
if (empty($link_rel) || strpos($link_rel, 'noopener') === FALSE) {
$link_rel = empty($link_rel) ?
'noopener' :
$link_rel . ' noopener';
$link->setAttribute('rel', $link_rel);
}
$link->setAttribute('target', '_blank');
}
}
$text = Html::serialize($html_dom);
return new FilterProcessResult($text);
}
Test result: .EEEEE....E.....E
– 7 failing cases from 17 total.
We got seven exceptions. Four of them are ServiceNotFoundExceptions
complaining about the missing path.validator
service, the other three are InvalidArgumentException thrown by the Url class fromUserInput
method. We should add the missing path.validator
service to our test’s Drupal container to fix the former exceptions and also handle the exception of Url::fromUserInput
, but wait a sec!
These are the test cases which are considered internal URLs! We should return FALSE in the catch block of the new helper method Drupal\external_link\Utility\ExternalLinkUrlHelper::isExternalUriString
and the test will pass!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Determines whether a URI string is external to Drupal.
*
* @param string $uri_string
* The URI string to check.
*
* @return bool
* TRUE or FALSE, where TRUE indicates an external path.
*/
public static function isExternalUriString($uri_string) {
try {
$url = Url::fromUri($uri_string);
}
catch (\Exception $e) {
return FALSE;
}
return static::isExternalUrl($url);
}
Summary
Now that our filter is ready, we just have to properly set it up on the desired text format’s configuration page:
- Enable the new External link filter.
- Make sure that the Convert URLs into links filter is also enabled and precedes External link filter.
- If the Limit allowed HTML tags and correct faulty HTML filter is on,
- Make sure that it precedes our new filter as well, or
- Add the required
target
andrel
attributes to the anchor tag in the Allowed HTML tags filter’s settings (at the bottom of the form)