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