xref: /dokuwiki/inc/Parsing/ParserMode/GfmLink.php (revision fe58309edafb067d90f3f40ef3d416100d558a04)
1<?php
2
3namespace dokuwiki\Parsing\ParserMode;
4
5use dokuwiki\Parsing\Handler;
6use dokuwiki\Parsing\Helpers\Escape;
7use dokuwiki\Parsing\Helpers\HtmlEntity;
8use dokuwiki\Parsing\Helpers\Link;
9use dokuwiki\Parsing\Helpers\Media as MediaHelper;
10
11/**
12 * GFM inline link [text](url) with optional title [text](url "title").
13 *
14 * The link text may be either plain text (the common case) or an inline
15 * image `![alt](imgUrl)` — the Markdown equivalent of DW's
16 * `[[target|{{imgUrl}}]]`. The image-as-label form emits a single link
17 * handler call with a media descriptor array in the label slot, reusing
18 * the same flow that `Internallink` already drives. No new handler
19 * instructions; renderers (xhtml, odt, metadata, …) already know how to
20 * render a link whose label is a media descriptor.
21 *
22 * Mirrors DW's `Internallink` architecture: a permissive outer pattern
23 * plus handle-time parsing, rather than encoding every GFM rule at
24 * pattern level.
25 *
26 * Deliberately not supported (see skip.php for the affected spec examples):
27 *
28 *   - Reference links [text][id] / [text][] / [foo] — the single-pass
29 *     lexer cannot resolve forward references to [foo]: url definitions.
30 *   - Pointy-bracket destinations [link](<foo bar>) — the simplified
31 *     pattern will happily match, but handle() produces an internallink
32 *     with a broken src; spec tests for this stay in skip.php.
33 *   - Balanced-parens inside URLs [link](foo(bar)) — matches truncate
34 *     at first `)`, producing odd output; also in skip.php.
35 *   - Title HTML attribute — DokuWiki link handler instructions have no
36 *     title-attribute slot, and plumbing one through every renderer just
37 *     for this is out of scope. The title parses cleanly but is discarded.
38 *   - Mixed text + image in the label ([prefix ![alt](img) suffix](url))
39 *     — matches DW's policy: Internallink only converts the label to a
40 *     media descriptor when it matches `^{{…}}$` exactly.
41 */
42class GfmLink extends AbstractMode
43{
44    // URL slot character set: any non-paren / non-newline char, OR a
45    // backslash-escape sequence so an escaped `\)` doesn't terminate the
46    // URL early (spec examples 504/506/508). Backslash-unescape is
47    // applied post-extraction; the pattern only needs to keep escaped
48    // close-parens from prematurely ending the match.
49    private const URL_CHAR = '(?:\\\\.|[^)\n])';
50
51    // Label character set: forbids unescaped `[` / `]` so the outer
52    // bracket pair stays balanced, but allows `\[` / `\]` so an escaped
53    // bracket can appear inside the label (spec example 523). The same
54    // backslash-escape trick the URL slot already uses. A bare `\n` is
55    // permitted as long as it is not followed by a blank line — soft
56    // line breaks inside link text are allowed by the spec, blank lines
57    // are not (and they would also tie up `\n#`-anchored block modes).
58    private const LABEL_CHAR = '(?:\\\\.|[^\[\]\n]|' . self::NOT_AT_PARA_BREAK . '\n)';
59
60    // Image sub-pattern reused for both the label alternative in the main
61    // pattern and the image-as-label detector in handle(). No capture
62    // groups here — the lexer wraps user patterns in a capture and
63    // additional captures would renumber unpredictably. The label/url
64    // quantifiers are possessive for the same reason as the main pattern.
65    private const IMAGE_SUB = '!\[' . self::LABEL_CHAR . '*+\]\(' . self::URL_CHAR . '++\)';
66
67    /** @inheritdoc */
68    public function getSort()
69    {
70        return 300;
71    }
72
73    /** @inheritdoc */
74    public function connectTo($mode)
75    {
76        // Outer shape: `[text-or-image](url)`. Text class forbids
77        // unescaped brackets and newlines but allows `\[` / `\]`; the
78        // image alternative explicitly matches one inline image. URL
79        // slot is permissive — handle() does URL / title splitting
80        // post-entry, mirroring how DW Internallink parses inside `[[...]]`.
81        //
82        // The label and url quantifiers are possessive: LABEL_CHAR never
83        // matches an unescaped `]` and URL_CHAR never matches an unescaped
84        // `)`, so each run stops exactly at its own closer and never needs to
85        // backtrack. A plain `+` here lets an unclosed `[text…` drive the
86        // non-JIT PCRE engine to retain one backtracking frame per byte.
87        $pattern = '\[(?!\[)(?:' . self::LABEL_CHAR . '++|' . self::IMAGE_SUB . ')\]\(' . self::URL_CHAR . '++\)';
88        $this->Lexer->addSpecialPattern($pattern, $mode, 'gfm_link');
89    }
90
91    /** @inheritdoc */
92    public function handle($match, $state, $pos, Handler $handler)
93    {
94        // Detect image-as-label `[![alt](img)](target)`. Parallels
95        // Internallink's `^{{…}}$` check — when the label is exactly an
96        // inline image, parse it into a media descriptor; otherwise
97        // treat the label as plain text.
98        if (preg_match('/^\[(' . self::IMAGE_SUB . ')\]\((' . self::URL_CHAR . '+)\)$/', $match, $m)) {
99            $label     = $this->parseImageDescriptor($m[1]);
100            $targetUrl = $this->extractUrl($m[2]);
101        } else {
102            // Plain text label can't contain `]`, so the first `](` is
103            // the label/target separator.
104            $sep       = strpos($match, '](');
105            $label     = Escape::unescapeBackslashes(substr($match, 1, $sep - 1));
106            $targetUrl = $this->extractUrl(substr($match, $sep + 2, -1));
107        }
108
109        // Classify on the raw URL so windowssharelink detection sees the
110        // literal `\\host\path` runs intact — GFM's `\\` → `\` collapse
111        // would otherwise destroy the share prefix.
112        [$call, $args] = Link::classify($targetUrl, $label);
113        if ($call !== 'windowssharelink') {
114            $args[0] = Escape::unescapeBackslashes($args[0]);
115        }
116        $handler->addCall($call, $args, $pos);
117        return true;
118    }
119
120    /**
121     * Extract the URL from a parenthesized payload: trim surrounding
122     * whitespace, take the first whitespace-delimited token, then
123     * apply GFM's URL-slot transformations (entity decoding;
124     * backslash-unescape happens later, after Link::classify, because
125     * windowssharelink detection needs the raw `\\` runs intact).
126     * Any trailing title is discarded (no renderer slot for it).
127     */
128    private function extractUrl(string $inside): string
129    {
130        $inside = trim($inside);
131        $url    = substr($inside, 0, strcspn($inside, " \t\n")); // remove optional title
132        return HtmlEntity::decode($url);
133    }
134
135    /**
136     * Parse an inline image sub-match `![alt](imgUrl)` into the media
137     * descriptor shape Media::parseMedia() returns, so the link handler
138     * can treat it as a media label identically to `[[page|{{img}}]]`.
139     */
140    private function parseImageDescriptor(string $imageMatch): array
141    {
142        $sep    = strpos($imageMatch, '](');
143        $alt    = Escape::unescapeBackslashes(substr($imageMatch, 2, $sep - 2));
144        $imgUrl = Escape::unescapeBackslashes($this->extractUrl(substr($imageMatch, $sep + 2, -1)));
145
146        $p = MediaHelper::parseParameters($imgUrl);
147        $type = (media_isexternal($p['src']) || link_isinterwiki($p['src']))
148            ? 'externalmedia'
149            : 'internalmedia';
150
151        return [
152            'type'    => $type,
153            'src'     => $p['src'],
154            'title'   => $alt !== '' ? $alt : null,
155            'align'   => $p['align'],
156            'width'   => $p['width'],
157            'height'  => $p['height'],
158            'cache'   => $p['cache'],
159            'linking' => $p['linking'],
160        ];
161    }
162}
163