diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index 9ee787aeb1..c6f91325ee 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -27,7 +27,7 @@ expectedArguments(\League\CommonMark\Inline\Element\Newline::__construct(), 0, argumentsSet('league_commonmark_newline_types')); expectedReturnValues(\League\CommonMark\Inline\Element\Newline::getType(), argumentsSet('league_commonmark_newline_types')); - registerArgumentsSet('league_commonmark_options', 'renderer', 'enable_em', 'enable_strong', 'use_asterisk', 'use_underscore', 'unordered_list_markers', 'html_input', 'allow_unsafe_links', 'max_nesting_level', 'heading_permalink', 'heading_permalink/html_class', 'heading_permalink/id_prefix', 'heading_permalink/inner_contents', 'heading_permalink/insert', 'heading_permalink/title', 'table_of_contents', 'table_of_contents/style', 'table_of_contents/normalize', 'table_of_contents/position', 'table_of_contents/html_class', 'table_of_contents/min_heading_level', 'table_of_contents/max_heading_level', 'table_of_contents/placeholder'); + registerArgumentsSet('league_commonmark_options', 'renderer', 'enable_em', 'enable_strong', 'use_asterisk', 'use_underscore', 'unordered_list_markers', 'html_input', 'allow_unsafe_links', 'max_nesting_level', 'external_link', 'external_link/nofollow', 'external_link/noopener', 'external_link/noreferrer', 'heading_permalink', 'heading_permalink/html_class', 'heading_permalink/id_prefix', 'heading_permalink/inner_contents', 'heading_permalink/insert', 'heading_permalink/title', 'table_of_contents', 'table_of_contents/style', 'table_of_contents/normalize', 'table_of_contents/position', 'table_of_contents/html_class', 'table_of_contents/min_heading_level', 'table_of_contents/max_heading_level', 'table_of_contents/placeholder'); expectedArguments(\League\CommonMark\EnvironmentInterface::getConfig(), 0, argumentsSet('league_commonmark_options')); expectedArguments(\League\CommonMark\Util\ConfigurationInterface::get(), 0, argumentsSet('league_commonmark_options')); expectedArguments(\League\CommonMark\Util\ConfigurationInterface::set(), 0, argumentsSet('league_commonmark_options')); diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cc4d5e83..8bdb20ea66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi - Added the ability to render `TableOfContents` nodes anywhere in a document (given by a placeholder) - Added the ability to properly clone `Node` objects + - Added options to customize the value of `rel` attributes set via the `ExternalLink` extension (#476) - Added new classes: - `TableOfContentsGenerator` - `TableOfContentsGeneratorInterface` @@ -18,6 +19,7 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi ### Changed - "Moved" the `TableOfContents` class into a new `Node` sub-namespace (with backward-compatibility) + - External links detected by the `ExternalLink` extension will now include `nofollow` in the renderer `rel` attribute (#476) ### Deprecated diff --git a/docs/1.5/extensions/external-links.md b/docs/1.5/extensions/external-links.md index a8d4175d38..904aa6b725 100644 --- a/docs/1.5/extensions/external-links.md +++ b/docs/1.5/extensions/external-links.md @@ -9,7 +9,8 @@ redirect_from: /extensions/external-links/ This extension can detect links to external sites and adjust the markup accordingly: - - Adds a `rel="noopener noreferrer"` attribute + - Make the links open in new tabs/windows + - Adds a `rel` attribute to the resulting `` tag with values like `"nofollow noopener noreferrer"` - Optionally adds any custom HTML classes ## Usage @@ -30,9 +31,12 @@ $environment->addExtension(new ExternalLinkExtension()); // Set your configuration $config = [ 'external_link' => [ - 'internal_hosts' => 'www.example.com', // Don't forget to set this! + 'internal_hosts' => 'www.example.com', // TODO: Don't forget to set this! 'open_in_new_window' => true, 'html_class' => 'external-link', + 'nofollow' => 'external', + 'noopener' => 'all', + 'noreferrer' => 'external', ], ]; @@ -73,6 +77,15 @@ This option (which defaults to `false`) determines whether any external links sh This option allows you to provide a `string` containing one or more HTML classes that should be added to the external link `` tags: No classes are added by default. +### `nofollow`, `noopener`, and `noreferrer` + +These options allow you to configure whether a `rel` attribute should be applied to links. Each of these options can be set to one of the following `string` values: + + - `'external'` - **Apply to external links only (default)** + - `'internal'` - Apply to internal links only + - `'all'` - Apply to all links (both internal and external) + - `''` (empty string) - Don't apply to any links + ## Advanced Rendering When an external link is detected, the `ExternalLinkProcessor` will set the `external` data option on the `Link` node to either `true` or `false`. You can therefore create a [custom link renderer](/1.5/customization/inline-rendering/) which checks this value and behaves accordingly: diff --git a/src/Extension/ExternalLink/ExternalLinkProcessor.php b/src/Extension/ExternalLink/ExternalLinkProcessor.php index 5a2f66481b..decff4ccf6 100644 --- a/src/Extension/ExternalLink/ExternalLinkProcessor.php +++ b/src/Extension/ExternalLink/ExternalLinkProcessor.php @@ -17,6 +17,11 @@ final class ExternalLinkProcessor { + public const APPLY_NONE = ''; + public const APPLY_ALL = 'all'; + public const APPLY_EXTERNAL = 'external'; + public const APPLY_INTERNAL = 'internal'; + /** @var EnvironmentInterface */ private $environment; @@ -50,6 +55,7 @@ public function __invoke(DocumentParsedEvent $e) if (self::hostMatches($host, $internalHosts)) { $link->data['external'] = false; + $this->applyRelAttribute($link, false); continue; } @@ -63,7 +69,7 @@ private function markLinkAsExternal(Link $link, bool $openInNewWindow, string $c { $link->data['external'] = true; $link->data['attributes'] = $link->getData('attributes', []); - $link->data['attributes']['rel'] = 'noopener noreferrer'; + $this->applyRelAttribute($link, true); if ($openInNewWindow) { $link->data['attributes']['target'] = '_blank'; @@ -74,6 +80,27 @@ private function markLinkAsExternal(Link $link, bool $openInNewWindow, string $c } } + private function applyRelAttribute(Link $link, bool $isExternal): void + { + $rel = []; + + foreach (['nofollow', 'noopener', 'noreferrer'] as $type) { + $option = $this->environment->getConfig('external_link/' . $type, self::APPLY_EXTERNAL); + switch (true) { + case $option === self::APPLY_ALL: + case $isExternal && $option === self::APPLY_EXTERNAL: + case !$isExternal && $option === self::APPLY_INTERNAL: + $rel[] = $type; + } + } + + if ($rel === []) { + return; + } + + $link->data['attributes']['rel'] = \implode(' ', $rel); + } + /** * @param string $host * @param mixed $compareTo diff --git a/tests/unit/Extension/ExternalLink/ExternalLinkProcessorTest.php b/tests/unit/Extension/ExternalLink/ExternalLinkProcessorTest.php index d5e45cb62d..f04784f51a 100644 --- a/tests/unit/Extension/ExternalLink/ExternalLinkProcessorTest.php +++ b/tests/unit/Extension/ExternalLink/ExternalLinkProcessorTest.php @@ -23,14 +23,14 @@ final class ExternalLinkProcessorTest extends TestCase public function testDefaultConfiguration() { - $expected = '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

' . "\n"; + $expected = '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

'; $this->assertEquals($expected, $this->parse(self::INPUT)); } public function testCustomConfiguration() { - $expected = '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

' . "\n"; + $expected = '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

'; $config = [ 'external_link' => [ @@ -47,7 +47,7 @@ public function testWithBadUrls() { $input = 'Report [xss](javascript:alert(0);) vulnerabilities by emailing '; - $expected = '

Report xss vulnerabilities by emailing colinodell@gmail.com

' . "\n"; + $expected = '

Report xss vulnerabilities by emailing colinodell@gmail.com

'; $this->assertEquals($expected, $this->parse($input)); } @@ -59,7 +59,7 @@ private function parse(string $markdown, array $config = []) $c = new CommonMarkConverter($config, $e); - return $c->convertToHtml($markdown); + return \rtrim($c->convertToHtml($markdown)); } /** @@ -91,4 +91,94 @@ public function dataProviderForTestHostMatches() // You can even mix-and-match multiple strings with multiple regexes yield ['www.colinodell.com', ['/colinodell\.com/', 'aol.com'], true]; } + + /** + * @param string $nofollow + * @param string $noopener + * @param string $noreferrer + * @param string $expectedOutput + * + * @dataProvider dataProviderForTestRelOptions + */ + public function testRelOptions(string $nofollow, string $noopener, string $noreferrer, string $expectedOutput): void + { + $config = [ + 'external_link' => [ + 'nofollow' => $nofollow, + 'noopener' => $noopener, + 'noreferrer' => $noreferrer, + 'internal_hosts' => ['commonmark.thephpleague.com'], + ], + ]; + + $this->assertEquals($expectedOutput, $this->parse(self::INPUT, $config)); + } + + public function dataProviderForTestRelOptions(): iterable + { + yield ['', '', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', '', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', '', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', '', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'all', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'all', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'all', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'all', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'external', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'external', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'external', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'external', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'internal', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'internal', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'internal', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['', 'internal', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', '', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', '', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', '', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', '', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'all', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'all', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'all', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'all', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'external', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'external', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'external', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'external', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'internal', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'internal', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'internal', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['all', 'internal', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', '', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', '', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', '', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', '', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'all', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'all', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'all', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'all', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'external', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'external', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'external', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'external', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'internal', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'internal', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'internal', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['external', 'internal', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', '', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', '', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', '', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', '', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'all', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'all', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'all', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'all', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'external', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'external', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'external', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'external', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'internal', '', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'internal', 'all', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'internal', 'external', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + yield ['internal', 'internal', 'internal', '

My favorite sites are https://www.colinodell.com and https://commonmark.thephpleague.com

']; + } }