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 `` — 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  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 `[](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 `` 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