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 and rel attributes to the anchor tag in the Allowed HTML tags filter’s settings (at the bottom of the form)