1be906b56SAndreas Gohr<?php 2be906b56SAndreas Gohr 3be906b56SAndreas Gohrnamespace dokuwiki\Parsing\ParserMode; 4be906b56SAndreas Gohr 571096e46SAndreas Gohruse dokuwiki\Parsing\Handler; 6*15429f02SAndreas Gohruse dokuwiki\Parsing\Helpers\HtmlEntity; 7f9d3b7bdSAndreas Gohruse dokuwiki\Parsing\ModeRegistry; 871096e46SAndreas Gohr 9f9d3b7bdSAndreas Gohr/** 10f9d3b7bdSAndreas Gohr * Parser mode for external links (URLs). 11f9d3b7bdSAndreas Gohr * 12f9d3b7bdSAndreas Gohr * This mode is responsible for recognizing and handling external links in the text. It uses regular expressions 13f9d3b7bdSAndreas Gohr * to identify URLs based on common schemes and patterns, and it can handle both standard URLs and Markdown-style 14f9d3b7bdSAndreas Gohr * angle-bracket autolinks. 15f9d3b7bdSAndreas Gohr */ 16be906b56SAndreas Gohrclass Externallink extends AbstractMode 17be906b56SAndreas Gohr{ 18bcaec9f4SAndreas Gohr protected $schemes = []; 19bcaec9f4SAndreas Gohr protected $patterns = []; 20be906b56SAndreas Gohr 21be906b56SAndreas Gohr /** @inheritdoc */ 2271096e46SAndreas Gohr public function getSort() 2371096e46SAndreas Gohr { 2471096e46SAndreas Gohr return 330; 2571096e46SAndreas Gohr } 2671096e46SAndreas Gohr 2771096e46SAndreas Gohr /** @inheritdoc */ 28be906b56SAndreas Gohr public function preConnect() 29be906b56SAndreas Gohr { 30be906b56SAndreas Gohr if (count($this->patterns)) return; 31be906b56SAndreas Gohr 32be906b56SAndreas Gohr $ltrs = '\w'; 33be906b56SAndreas Gohr $gunk = '/\#~:.?+=&%@!\-\[\]'; 34be906b56SAndreas Gohr $punc = '.:?\-;,'; 35*15429f02SAndreas Gohr $tail = ''; 36*15429f02SAndreas Gohr 37*15429f02SAndreas Gohr // GFM autolink extension (Markdown-only): 38*15429f02SAndreas Gohr // - Parentheses are allowed inside URLs; trailing unbalanced `)` are trimmed in handle(). 39*15429f02SAndreas Gohr // - A trailing entity-reference-like sequence (e.g. `©`, `&hl;`) is consumed by the URL regex 40*15429f02SAndreas Gohr // and then stripped in handle(); decodeOne() expands valid named/numeric refs to their Unicode 41*15429f02SAndreas Gohr // character (`©` -> `©`) while unknown names round-trip as literal text. 42*15429f02SAndreas Gohr if (ModeRegistry::getInstance()->isMdPreferred()) { 43*15429f02SAndreas Gohr $gunk .= '()'; 44*15429f02SAndreas Gohr $tail = '(?:' . HtmlEntity::PATTERN . ')?'; 45*15429f02SAndreas Gohr } 46*15429f02SAndreas Gohr 47be906b56SAndreas Gohr $host = $ltrs . $punc; 48be906b56SAndreas Gohr $any = $ltrs . $gunk . $punc; 49be906b56SAndreas Gohr 50be906b56SAndreas Gohr $this->schemes = getSchemes(); 51be906b56SAndreas Gohr foreach ($this->schemes as $scheme) { 52*15429f02SAndreas Gohr $this->patterns[] = '\b(?i)' . $scheme . '(?-i)://[' . $any . ']+?' . $tail . 53*15429f02SAndreas Gohr '(?=[' . $punc . ']*[^' . $any . '])'; 54be906b56SAndreas Gohr } 55be906b56SAndreas Gohr 564da6a3ceSPhy $this->patterns[] = '(?<![/\\\\])\b(?i)www?(?-i)\.[' . $host . ']+?\.' . 57*15429f02SAndreas Gohr '[' . $host . ']+?[' . $any . ']+?' . $tail . 58*15429f02SAndreas Gohr '(?=[' . $punc . ']*[^' . $any . '])'; 594da6a3ceSPhy $this->patterns[] = '(?<![/\\\\])\b(?i)ftp?(?-i)\.[' . $host . ']+?\.' . 60*15429f02SAndreas Gohr '[' . $host . ']+?[' . $any . ']+?' . $tail . 61*15429f02SAndreas Gohr '(?=[' . $punc . ']*[^' . $any . '])'; 62f9d3b7bdSAndreas Gohr 63f9d3b7bdSAndreas Gohr // Markdown-only: angle-bracket autolinks per CommonMark §6.5. One per-scheme pattern that captures the whole 64f9d3b7bdSAndreas Gohr // envelope; handle() decides at match time whether to emit a link or literal cdata based on whether the content 65f9d3b7bdSAndreas Gohr // contains whitespace (which disqualifies the autolink). 66f9d3b7bdSAndreas Gohr // Angle brackets with white space are basically a simple way to write a URL without triggering autolinking 67f9d3b7bdSAndreas Gohr if (ModeRegistry::getInstance()->isMdPreferred()) { 68f9d3b7bdSAndreas Gohr foreach ($this->schemes as $scheme) { 69f9d3b7bdSAndreas Gohr $this->patterns[] = '<[ \t]*(?i)' . $scheme . '(?-i)://[^<>\n]*>'; 70f9d3b7bdSAndreas Gohr } 71f9d3b7bdSAndreas Gohr } 72be906b56SAndreas Gohr } 73be906b56SAndreas Gohr 74be906b56SAndreas Gohr /** @inheritdoc */ 75be906b56SAndreas Gohr public function connectTo($mode) 76be906b56SAndreas Gohr { 77be906b56SAndreas Gohr 78be906b56SAndreas Gohr foreach ($this->patterns as $pattern) { 79be906b56SAndreas Gohr $this->Lexer->addSpecialPattern($pattern, $mode, 'externallink'); 80be906b56SAndreas Gohr } 81be906b56SAndreas Gohr } 82be906b56SAndreas Gohr 83be906b56SAndreas Gohr /** @inheritdoc */ 8471096e46SAndreas Gohr public function handle($match, $state, $pos, Handler $handler) 85be906b56SAndreas Gohr { 86f9d3b7bdSAndreas Gohr if (str_starts_with($match, '<') && str_ends_with($match, '>')) { 87*15429f02SAndreas Gohr $this->handleAngleAutolink($match, $pos, $handler); 88*15429f02SAndreas Gohr } else { 89*15429f02SAndreas Gohr $this->handleBareUrl($match, $pos, $handler); 90*15429f02SAndreas Gohr } 91f9d3b7bdSAndreas Gohr return true; 92f9d3b7bdSAndreas Gohr } 93*15429f02SAndreas Gohr 94*15429f02SAndreas Gohr /** 95*15429f02SAndreas Gohr * Emit a Markdown angle-bracket autolink (CommonMark §6.5). 96*15429f02SAndreas Gohr * 97*15429f02SAndreas Gohr * Whitespace inside the brackets disqualifies the autolink; in that case the literal envelope is 98*15429f02SAndreas Gohr * preserved as cdata so the brackets remain visible. 99*15429f02SAndreas Gohr */ 100*15429f02SAndreas Gohr protected function handleAngleAutolink(string $match, int $pos, Handler $handler): void 101*15429f02SAndreas Gohr { 102*15429f02SAndreas Gohr if (preg_match('/\s/', $match)) { 103*15429f02SAndreas Gohr $handler->addCall('cdata', [$match], $pos); 104*15429f02SAndreas Gohr return; 105*15429f02SAndreas Gohr } 106f9d3b7bdSAndreas Gohr $url = substr($match, 1, -1); 107f9d3b7bdSAndreas Gohr $handler->addCall('externallink', [$url, $url], $pos); 108f9d3b7bdSAndreas Gohr } 109f9d3b7bdSAndreas Gohr 110*15429f02SAndreas Gohr /** 111*15429f02SAndreas Gohr * Emit a bare-URL autolink, optionally preceded by the GFM-extension trim step. 112*15429f02SAndreas Gohr * 113*15429f02SAndreas Gohr * In Markdown-preferred mode, peelGfmTail() removes characters the URL regex over-consumed 114*15429f02SAndreas Gohr * (trailing entity references, unbalanced closing parens) and returns them as a cdata suffix. 115*15429f02SAndreas Gohr */ 116*15429f02SAndreas Gohr protected function handleBareUrl(string $match, int $pos, Handler $handler): void 117*15429f02SAndreas Gohr { 11871096e46SAndreas Gohr $url = $match; 119*15429f02SAndreas Gohr $trailing = ''; 12071096e46SAndreas Gohr 121*15429f02SAndreas Gohr if (ModeRegistry::getInstance()->isMdPreferred()) { 122*15429f02SAndreas Gohr $trailing = $this->peelGfmTail($url); 123*15429f02SAndreas Gohr } 124*15429f02SAndreas Gohr 125*15429f02SAndreas Gohr $title = $this->addProtocolPrefix($url); 126*15429f02SAndreas Gohr 127*15429f02SAndreas Gohr $handler->addCall('externallink', [$url, $title], $pos); 128*15429f02SAndreas Gohr if ($trailing !== '') { 129*15429f02SAndreas Gohr $handler->addCall('cdata', [$trailing], $pos); 130*15429f02SAndreas Gohr } 131*15429f02SAndreas Gohr } 132*15429f02SAndreas Gohr 133*15429f02SAndreas Gohr /** 134*15429f02SAndreas Gohr * Peel GFM-extension trailing chars off a URL. 135*15429f02SAndreas Gohr * 136*15429f02SAndreas Gohr * The URL regex deliberately over-consumes parentheses and entity references so this method can decide 137*15429f02SAndreas Gohr * what really belongs to the URL. It peels one of two things at a time, repeating until neither applies: 138*15429f02SAndreas Gohr * 139*15429f02SAndreas Gohr * - A trailing entity reference (e.g. ©): decoded via HtmlEntity::decodeOne so valid named or 140*15429f02SAndreas Gohr * numeric refs become their Unicode character and unknown ones round-trip as literal text. 141*15429f02SAndreas Gohr * - A trailing ) that has no matching ( earlier in the URL. 142*15429f02SAndreas Gohr * 143*15429f02SAndreas Gohr * Peels prepend to the trailing string so the final order matches the original source. 144*15429f02SAndreas Gohr * 145*15429f02SAndreas Gohr * @param string $url Mutated in place to the trimmed URL 146*15429f02SAndreas Gohr * @return string The peeled-off chars, in original source order, ready to emit as cdata after the link 147*15429f02SAndreas Gohr */ 148*15429f02SAndreas Gohr protected function peelGfmTail(string &$url): string 149*15429f02SAndreas Gohr { 150*15429f02SAndreas Gohr $trailing = ''; 151*15429f02SAndreas Gohr while (true) { 152*15429f02SAndreas Gohr if (preg_match('/' . HtmlEntity::PATTERN . '$/', $url, $m)) { 153*15429f02SAndreas Gohr $trailing = HtmlEntity::decodeOne($m[0]) . $trailing; 154*15429f02SAndreas Gohr $url = substr($url, 0, -strlen($m[0])); 155*15429f02SAndreas Gohr } elseif (str_ends_with($url, ')') && substr_count($url, ')') > substr_count($url, '(')) { 156*15429f02SAndreas Gohr $trailing = ')' . $trailing; 157*15429f02SAndreas Gohr $url = substr($url, 0, -1); 158*15429f02SAndreas Gohr } else { 159*15429f02SAndreas Gohr break; 160*15429f02SAndreas Gohr } 161*15429f02SAndreas Gohr } 162*15429f02SAndreas Gohr return $trailing; 163*15429f02SAndreas Gohr } 164*15429f02SAndreas Gohr 165*15429f02SAndreas Gohr /** 166*15429f02SAndreas Gohr * Add the implicit protocol on www./ftp. URLs and return the visible label. 167*15429f02SAndreas Gohr * 168*15429f02SAndreas Gohr * For scheme URLs (http://, ftp://, ...) the label is null, signalling the renderer to display the 169*15429f02SAndreas Gohr * href verbatim. For www./ftp. shortcuts the label is the original unprefixed form. 170*15429f02SAndreas Gohr * 171*15429f02SAndreas Gohr * @param string $url Mutated in place to include the protocol prefix when one was added 172*15429f02SAndreas Gohr * @return string|null The visible label, or null to use the prefixed URL as its own label 173*15429f02SAndreas Gohr */ 174*15429f02SAndreas Gohr protected function addProtocolPrefix(string &$url): ?string 175*15429f02SAndreas Gohr { 176*15429f02SAndreas Gohr $title = null; 17771096e46SAndreas Gohr if (str_starts_with($url, 'ftp') && !str_starts_with($url, 'ftp://')) { 17871096e46SAndreas Gohr $title = $url; 17971096e46SAndreas Gohr $url = 'ftp://' . $url; 18071096e46SAndreas Gohr } 18171096e46SAndreas Gohr if (str_starts_with($url, 'www')) { 18271096e46SAndreas Gohr $title = $url; 18371096e46SAndreas Gohr $url = 'http://' . $url; 18471096e46SAndreas Gohr } 185*15429f02SAndreas Gohr return $title; 186be906b56SAndreas Gohr } 1875c99934fSAnna Dabrowska 1885c99934fSAnna Dabrowska /** 1895c99934fSAnna Dabrowska * @return array 1905c99934fSAnna Dabrowska */ 1915c99934fSAnna Dabrowska public function getPatterns() 1925c99934fSAnna Dabrowska { 1935c99934fSAnna Dabrowska return $this->patterns; 1945c99934fSAnna Dabrowska } 195be906b56SAndreas Gohr} 196