1<?php 2 3declare(strict_types=1); 4 5/* 6 * This file is part of the league/commonmark package. 7 * 8 * (c) Colin O'Dell <colinodell@gmail.com> 9 * 10 * For the full copyright and license information, please view the LICENSE 11 * file that was distributed with this source code. 12 */ 13 14namespace League\CommonMark\Extension\ExternalLink; 15 16use League\CommonMark\Event\DocumentParsedEvent; 17use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 18use League\Config\ConfigurationInterface; 19 20final class ExternalLinkProcessor 21{ 22 public const APPLY_NONE = ''; 23 public const APPLY_ALL = 'all'; 24 public const APPLY_EXTERNAL = 'external'; 25 public const APPLY_INTERNAL = 'internal'; 26 27 /** @psalm-readonly */ 28 private ConfigurationInterface $config; 29 30 public function __construct(ConfigurationInterface $config) 31 { 32 $this->config = $config; 33 } 34 35 public function __invoke(DocumentParsedEvent $e): void 36 { 37 $internalHosts = $this->config->get('external_link/internal_hosts'); 38 $openInNewWindow = $this->config->get('external_link/open_in_new_window'); 39 $classes = $this->config->get('external_link/html_class'); 40 41 foreach ($e->getDocument()->iterator() as $link) { 42 if (! ($link instanceof Link)) { 43 continue; 44 } 45 46 $host = \parse_url($link->getUrl(), PHP_URL_HOST); 47 if (! \is_string($host)) { 48 // Something is terribly wrong with this URL 49 continue; 50 } 51 52 if (self::hostMatches($host, $internalHosts)) { 53 $link->data->set('external', false); 54 $this->applyRelAttribute($link, false); 55 continue; 56 } 57 58 // Host does not match our list 59 $this->markLinkAsExternal($link, $openInNewWindow, $classes); 60 } 61 } 62 63 private function markLinkAsExternal(Link $link, bool $openInNewWindow, string $classes): void 64 { 65 $link->data->set('external', true); 66 $this->applyRelAttribute($link, true); 67 68 if ($openInNewWindow) { 69 $link->data->set('attributes/target', '_blank'); 70 } 71 72 if ($classes !== '') { 73 $link->data->append('attributes/class', $classes); 74 } 75 } 76 77 private function applyRelAttribute(Link $link, bool $isExternal): void 78 { 79 $options = [ 80 'nofollow' => $this->config->get('external_link/nofollow'), 81 'noopener' => $this->config->get('external_link/noopener'), 82 'noreferrer' => $this->config->get('external_link/noreferrer'), 83 ]; 84 85 foreach ($options as $type => $option) { 86 switch (true) { 87 case $option === self::APPLY_ALL: 88 case $isExternal && $option === self::APPLY_EXTERNAL: 89 case ! $isExternal && $option === self::APPLY_INTERNAL: 90 $link->data->append('attributes/rel', $type); 91 } 92 } 93 94 // No rel attributes? Mark the attribute as 'false' so LinkRenderer doesn't add defaults 95 if (! $link->data->has('attributes/rel')) { 96 $link->data->set('attributes/rel', false); 97 } 98 } 99 100 /** 101 * @internal This method is only public so we can easily test it. DO NOT USE THIS OUTSIDE OF THIS EXTENSION! 102 * 103 * @param non-empty-string|list<non-empty-string> $compareTo 104 */ 105 public static function hostMatches(string $host, $compareTo): bool 106 { 107 foreach ((array) $compareTo as $c) { 108 if (\strpos($c, '/') === 0) { 109 if (\preg_match($c, $host)) { 110 return true; 111 } 112 } elseif ($c === $host) { 113 return true; 114 } 115 } 116 117 return false; 118 } 119} 120