1be906b56SAndreas Gohr<?php 2be906b56SAndreas Gohr 3be906b56SAndreas Gohrnamespace dokuwiki\Parsing\ParserMode; 4be906b56SAndreas Gohr 571096e46SAndreas Gohruse dokuwiki\Parsing\Handler; 615429f02SAndreas Gohruse dokuwiki\Parsing\Helpers\HtmlEntity; 771096e46SAndreas Gohr 8f9d3b7bdSAndreas Gohr/** 9f9d3b7bdSAndreas Gohr * Parser mode for external links (URLs). 10f9d3b7bdSAndreas Gohr * 11f9d3b7bdSAndreas Gohr * This mode is responsible for recognizing and handling external links in the text. It uses regular expressions 12f9d3b7bdSAndreas Gohr * to identify URLs based on common schemes and patterns, and it can handle both standard URLs and Markdown-style 13f9d3b7bdSAndreas Gohr * angle-bracket autolinks. 14f9d3b7bdSAndreas Gohr */ 15be906b56SAndreas Gohrclass Externallink extends AbstractMode 16be906b56SAndreas Gohr{ 17bcaec9f4SAndreas Gohr protected $schemes = []; 18bcaec9f4SAndreas Gohr protected $patterns = []; 19be906b56SAndreas Gohr 20be906b56SAndreas Gohr /** @inheritdoc */ 2171096e46SAndreas Gohr public function getSort() 2271096e46SAndreas Gohr { 2371096e46SAndreas Gohr return 330; 2471096e46SAndreas Gohr } 2571096e46SAndreas Gohr 2671096e46SAndreas Gohr /** @inheritdoc */ 27be906b56SAndreas Gohr public function preConnect() 28be906b56SAndreas Gohr { 29be906b56SAndreas Gohr if (count($this->patterns)) return; 30be906b56SAndreas Gohr 31be906b56SAndreas Gohr $ltrs = '\w'; 32be906b56SAndreas Gohr $gunk = '/\#~:.?+=&%@!\-\[\]'; 33be906b56SAndreas Gohr $punc = '.:?\-;,'; 3415429f02SAndreas Gohr $tail = ''; 3515429f02SAndreas Gohr 3615429f02SAndreas Gohr // GFM autolink extension (Markdown-only): 3715429f02SAndreas Gohr // - Parentheses are allowed inside URLs; trailing unbalanced `)` are trimmed in handle(). 3815429f02SAndreas Gohr // - A trailing entity-reference-like sequence (e.g. `©`, `&hl;`) is consumed by the URL regex 3915429f02SAndreas Gohr // and then stripped in handle(); decodeOne() expands valid named/numeric refs to their Unicode 4015429f02SAndreas Gohr // character (`©` -> `©`) while unknown names round-trip as literal text. 41*47a02a10SAndreas Gohr if ($this->registry->isMdPreferred()) { 4215429f02SAndreas Gohr $gunk .= '()'; 4315429f02SAndreas Gohr $tail = '(?:' . HtmlEntity::PATTERN . ')?'; 4415429f02SAndreas Gohr } 4515429f02SAndreas Gohr 46be906b56SAndreas Gohr $host = $ltrs . $punc; 47be906b56SAndreas Gohr $any = $ltrs . $gunk . $punc; 48be906b56SAndreas Gohr 49be906b56SAndreas Gohr $this->schemes = getSchemes(); 50be906b56SAndreas Gohr foreach ($this->schemes as $scheme) { 5115429f02SAndreas Gohr $this->patterns[] = '\b(?i)' . $scheme . '(?-i)://[' . $any . ']+?' . $tail . 5215429f02SAndreas Gohr '(?=[' . $punc . ']*[^' . $any . '])'; 53be906b56SAndreas Gohr } 54be906b56SAndreas Gohr 554da6a3ceSPhy $this->patterns[] = '(?<![/\\\\])\b(?i)www?(?-i)\.[' . $host . ']+?\.' . 5615429f02SAndreas Gohr '[' . $host . ']+?[' . $any . ']+?' . $tail . 5715429f02SAndreas Gohr '(?=[' . $punc . ']*[^' . $any . '])'; 584da6a3ceSPhy $this->patterns[] = '(?<![/\\\\])\b(?i)ftp?(?-i)\.[' . $host . ']+?\.' . 5915429f02SAndreas Gohr '[' . $host . ']+?[' . $any . ']+?' . $tail . 6015429f02SAndreas Gohr '(?=[' . $punc . ']*[^' . $any . '])'; 61f9d3b7bdSAndreas Gohr 62f9d3b7bdSAndreas Gohr // Markdown-only: angle-bracket autolinks per CommonMark §6.5. One per-scheme pattern that captures the whole 63f9d3b7bdSAndreas Gohr // envelope; handle() decides at match time whether to emit a link or literal cdata based on whether the content 64f9d3b7bdSAndreas Gohr // contains whitespace (which disqualifies the autolink). 65f9d3b7bdSAndreas Gohr // Angle brackets with white space are basically a simple way to write a URL without triggering autolinking 66*47a02a10SAndreas Gohr if ($this->registry->isMdPreferred()) { 67f9d3b7bdSAndreas Gohr foreach ($this->schemes as $scheme) { 68f9d3b7bdSAndreas Gohr $this->patterns[] = '<[ \t]*(?i)' . $scheme . '(?-i)://[^<>\n]*>'; 69f9d3b7bdSAndreas Gohr } 70f9d3b7bdSAndreas Gohr } 71be906b56SAndreas Gohr } 72be906b56SAndreas Gohr 73be906b56SAndreas Gohr /** @inheritdoc */ 74be906b56SAndreas Gohr public function connectTo($mode) 75be906b56SAndreas Gohr { 76be906b56SAndreas Gohr 77be906b56SAndreas Gohr foreach ($this->patterns as $pattern) { 78be906b56SAndreas Gohr $this->Lexer->addSpecialPattern($pattern, $mode, 'externallink'); 79be906b56SAndreas Gohr } 80be906b56SAndreas Gohr } 81be906b56SAndreas Gohr 82be906b56SAndreas Gohr /** @inheritdoc */ 8371096e46SAndreas Gohr public function handle($match, $state, $pos, Handler $handler) 84be906b56SAndreas Gohr { 85f9d3b7bdSAndreas Gohr if (str_starts_with($match, '<') && str_ends_with($match, '>')) { 8615429f02SAndreas Gohr $this->handleAngleAutolink($match, $pos, $handler); 8715429f02SAndreas Gohr } else { 8815429f02SAndreas Gohr $this->handleBareUrl($match, $pos, $handler); 8915429f02SAndreas Gohr } 90f9d3b7bdSAndreas Gohr return true; 91f9d3b7bdSAndreas Gohr } 9215429f02SAndreas Gohr 9315429f02SAndreas Gohr /** 9415429f02SAndreas Gohr * Emit a Markdown angle-bracket autolink (CommonMark §6.5). 9515429f02SAndreas Gohr * 9615429f02SAndreas Gohr * Whitespace inside the brackets disqualifies the autolink; in that case the literal envelope is 9715429f02SAndreas Gohr * preserved as cdata so the brackets remain visible. 9815429f02SAndreas Gohr */ 9915429f02SAndreas Gohr protected function handleAngleAutolink(string $match, int $pos, Handler $handler): void 10015429f02SAndreas Gohr { 10115429f02SAndreas Gohr if (preg_match('/\s/', $match)) { 10215429f02SAndreas Gohr $handler->addCall('cdata', [$match], $pos); 10315429f02SAndreas Gohr return; 10415429f02SAndreas Gohr } 105f9d3b7bdSAndreas Gohr $url = substr($match, 1, -1); 106f9d3b7bdSAndreas Gohr $handler->addCall('externallink', [$url, $url], $pos); 107f9d3b7bdSAndreas Gohr } 108f9d3b7bdSAndreas Gohr 10915429f02SAndreas Gohr /** 11015429f02SAndreas Gohr * Emit a bare-URL autolink, optionally preceded by the GFM-extension trim step. 11115429f02SAndreas Gohr * 11215429f02SAndreas Gohr * In Markdown-preferred mode, peelGfmTail() removes characters the URL regex over-consumed 11315429f02SAndreas Gohr * (trailing entity references, unbalanced closing parens) and returns them as a cdata suffix. 11415429f02SAndreas Gohr */ 11515429f02SAndreas Gohr protected function handleBareUrl(string $match, int $pos, Handler $handler): void 11615429f02SAndreas Gohr { 11771096e46SAndreas Gohr $url = $match; 11815429f02SAndreas Gohr $trailing = ''; 11971096e46SAndreas Gohr 120*47a02a10SAndreas Gohr if ($this->registry->isMdPreferred()) { 12115429f02SAndreas Gohr $trailing = $this->peelGfmTail($url); 12215429f02SAndreas Gohr } 12315429f02SAndreas Gohr 12415429f02SAndreas Gohr $title = $this->addProtocolPrefix($url); 12515429f02SAndreas Gohr 12615429f02SAndreas Gohr $handler->addCall('externallink', [$url, $title], $pos); 12715429f02SAndreas Gohr if ($trailing !== '') { 12815429f02SAndreas Gohr $handler->addCall('cdata', [$trailing], $pos); 12915429f02SAndreas Gohr } 13015429f02SAndreas Gohr } 13115429f02SAndreas Gohr 13215429f02SAndreas Gohr /** 13315429f02SAndreas Gohr * Peel GFM-extension trailing chars off a URL. 13415429f02SAndreas Gohr * 13515429f02SAndreas Gohr * The URL regex deliberately over-consumes parentheses and entity references so this method can decide 13615429f02SAndreas Gohr * what really belongs to the URL. It peels one of two things at a time, repeating until neither applies: 13715429f02SAndreas Gohr * 13815429f02SAndreas Gohr * - A trailing entity reference (e.g. ©): decoded via HtmlEntity::decodeOne so valid named or 13915429f02SAndreas Gohr * numeric refs become their Unicode character and unknown ones round-trip as literal text. 14015429f02SAndreas Gohr * - A trailing ) that has no matching ( earlier in the URL. 14115429f02SAndreas Gohr * 14215429f02SAndreas Gohr * Peels prepend to the trailing string so the final order matches the original source. 14315429f02SAndreas Gohr * 14415429f02SAndreas Gohr * @param string $url Mutated in place to the trimmed URL 14515429f02SAndreas Gohr * @return string The peeled-off chars, in original source order, ready to emit as cdata after the link 14615429f02SAndreas Gohr */ 14715429f02SAndreas Gohr protected function peelGfmTail(string &$url): string 14815429f02SAndreas Gohr { 14915429f02SAndreas Gohr $trailing = ''; 15015429f02SAndreas Gohr while (true) { 15115429f02SAndreas Gohr if (preg_match('/' . HtmlEntity::PATTERN . '$/', $url, $m)) { 15215429f02SAndreas Gohr $trailing = HtmlEntity::decodeOne($m[0]) . $trailing; 15315429f02SAndreas Gohr $url = substr($url, 0, -strlen($m[0])); 15415429f02SAndreas Gohr } elseif (str_ends_with($url, ')') && substr_count($url, ')') > substr_count($url, '(')) { 15515429f02SAndreas Gohr $trailing = ')' . $trailing; 15615429f02SAndreas Gohr $url = substr($url, 0, -1); 15715429f02SAndreas Gohr } else { 15815429f02SAndreas Gohr break; 15915429f02SAndreas Gohr } 16015429f02SAndreas Gohr } 16115429f02SAndreas Gohr return $trailing; 16215429f02SAndreas Gohr } 16315429f02SAndreas Gohr 16415429f02SAndreas Gohr /** 16515429f02SAndreas Gohr * Add the implicit protocol on www./ftp. URLs and return the visible label. 16615429f02SAndreas Gohr * 16715429f02SAndreas Gohr * For scheme URLs (http://, ftp://, ...) the label is null, signalling the renderer to display the 16815429f02SAndreas Gohr * href verbatim. For www./ftp. shortcuts the label is the original unprefixed form. 16915429f02SAndreas Gohr * 17015429f02SAndreas Gohr * @param string $url Mutated in place to include the protocol prefix when one was added 17115429f02SAndreas Gohr * @return string|null The visible label, or null to use the prefixed URL as its own label 17215429f02SAndreas Gohr */ 17315429f02SAndreas Gohr protected function addProtocolPrefix(string &$url): ?string 17415429f02SAndreas Gohr { 17515429f02SAndreas Gohr $title = null; 17671096e46SAndreas Gohr if (str_starts_with($url, 'ftp') && !str_starts_with($url, 'ftp://')) { 17771096e46SAndreas Gohr $title = $url; 17871096e46SAndreas Gohr $url = 'ftp://' . $url; 17971096e46SAndreas Gohr } 18071096e46SAndreas Gohr if (str_starts_with($url, 'www')) { 18171096e46SAndreas Gohr $title = $url; 18271096e46SAndreas Gohr $url = 'http://' . $url; 18371096e46SAndreas Gohr } 18415429f02SAndreas Gohr return $title; 185be906b56SAndreas Gohr } 1865c99934fSAnna Dabrowska 1875c99934fSAnna Dabrowska /** 1885c99934fSAnna Dabrowska * @return array 1895c99934fSAnna Dabrowska */ 1905c99934fSAnna Dabrowska public function getPatterns() 1915c99934fSAnna Dabrowska { 1925c99934fSAnna Dabrowska return $this->patterns; 1935c99934fSAnna Dabrowska } 194be906b56SAndreas Gohr} 195