xref: /dokuwiki/inc/Parsing/ParserMode/Externallink.php (revision 47a02a102092be9e1e6f1ddaf158bdfffdb13d4f)
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. `&copy;`, `&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 (`&copy;` -> `©`) 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. &copy;): 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