1<?php 2 3namespace dokuwiki\Parsing\ParserMode; 4 5use dokuwiki\Parsing\Handler; 6use dokuwiki\Parsing\Handler\Nest; 7use dokuwiki\Parsing\ModeRegistry; 8 9/** 10 * Block quotes — single mode covering both DokuWiki and GFM dialects. 11 * 12 * Captures one or more consecutive column-0 `>`-prefixed lines via 13 * addSpecialPattern. Nesting is resolved at this level by counting 14 * leading `>` markers per line and emitting `quote_open` / `quote_close` 15 * pairs around per-depth body segments — sub-parser recursion is 16 * deliberately not used because each sub-parser invocation needs its 17 * own Handler instance and threading the nesting through the registry 18 * pool would only buy us back what depth-walking already provides. 19 * 20 * Each per-depth segment's body is sub-parsed via 21 * ModeRegistry::withSubParser() so block content (lists, fenced code, 22 * tables) works inside the body. The sub-parser excludes BASEONLY so 23 * headers do not fire inside a blockquote — same rationale as 24 * GfmListblock: header instructions drive TOC entries, section-edit 25 * anchors, and section_open/section_close ranges that don't compose 26 * with a `<blockquote>` container. The sub-parser also excludes 27 * gfm_quote itself; nesting is handled at this level, not via 28 * sub-parser recursion. When a list inside a quote re-fires gfm_quote 29 * during the list-item sub-parse, the registry's pool hands the 30 * inner call a different parser instance for the same exclusion key, 31 * so the outer parse state is not corrupted. 32 * 33 * Lazy continuation is deliberately not supported. Every quote line 34 * must begin with `>` at column 0; the first non-`>` line ends the 35 * quote. This matches the policy GfmListblock enforces for lists — 36 * markers required on every line. Trade-off: a few CommonMark 37 * blockquote spec examples that rely on lazy continuation stay red, 38 * but the parser stays single-pass and predictable. 39 * 40 * Rendering shape depends on syntax preference. Under MD-preferred 41 * (`md`, `md+dw`) the sub-parser's paragraph wrapping survives: 42 * a quote with one paragraph emits `<blockquote><p>...</p></blockquote>`. 43 * Under DW-preferred (`dw`, `dw+md`) a post-pass flattens 44 * paragraph wrapping into explicit `linebreak` calls so existing DW 45 * pages keep their `<blockquote>...line1<br/>line2...</blockquote>` 46 * rendering. Same `quote_open` / `quote_close` instructions in both 47 * modes — no renderer change required. 48 */ 49class GfmQuote extends AbstractMode 50{ 51 /** @inheritdoc */ 52 public function getSort() 53 { 54 return 220; 55 } 56 57 /** @inheritdoc */ 58 public function preConnect() 59 { 60 $this->registry->registerBlockEolMode('gfm_quote'); 61 } 62 63 /** 64 * Capture an entire blockquote in one match. 65 * 66 * The pattern requires a column-0 `>` on every line. The first 67 * non-`>` line ends the capture (no lazy continuation). A bare `>` 68 * with no body is valid — it represents an empty paragraph break 69 * inside the quote (spec 240) or an empty quote (spec 239). 70 * 71 * The first line uses (?:^|\n)> rather than \n> so the blockquote 72 * can take over when a preceding block mode (a table or a list) 73 * consumed the boundary \n on its way out. Those modes exit on a 74 * consuming \n, so at the boundary the \n has already left the 75 * stream and a plain \n> opener would have nothing to anchor on. 76 * Accepting either a literal \n or a line start (^ in PCRE 77 * multiline mode, which also matches the position immediately 78 * after a consumed \n) lets the blockquote start regardless. 79 * Subsequent quote lines still anchor on \n> because the previous 80 * line consumed up to but not including the \n, so it is always 81 * available for them. 82 * 83 * @param string $mode the lexer state name to wire the pattern into 84 */ 85 public function connectTo($mode) 86 { 87 // The continuation group is possessive: each quote line ends at a 88 // `\n` that the next iteration re-anchors on, so the group never 89 // backtracks. Without it a very long blockquote makes the non-JIT 90 // PCRE engine retain one backtracking frame per line. 91 $this->Lexer->addSpecialPattern('(?:^|\n)>[^\n]*(?:\n>[^\n]*)*+', $mode, 'gfm_quote'); 92 } 93 94 /** @inheritdoc */ 95 public function handle($match, $state, $pos, Handler $handler) 96 { 97 $stripped = ltrim($match, "\n"); 98 $cursor = strlen($match) - strlen($stripped); 99 100 $parsed = []; 101 foreach (explode("\n", $stripped) as $line) { 102 $parsed[] = $this->parseLine($line, $pos + $cursor); 103 $cursor += strlen($line) + 1; // +1 for the \n consumed by explode 104 } 105 106 $currentDepth = 0; 107 $buffer = []; 108 $segmentStart = $pos; 109 110 foreach ($parsed as $p) { 111 if ($p['depth'] !== $currentDepth) { 112 if ($buffer) { 113 $this->emitBody($handler, $segmentStart, implode("\n", $buffer)); 114 $buffer = []; 115 } 116 while ($currentDepth < $p['depth']) { 117 $handler->addCall('quote_open', [], $pos); 118 $currentDepth++; 119 } 120 while ($currentDepth > $p['depth']) { 121 $handler->addCall('quote_close', [], $pos); 122 $currentDepth--; 123 } 124 } 125 if (!$buffer) $segmentStart = $p['offset']; 126 $buffer[] = $p['content']; 127 } 128 129 if ($buffer) { 130 $this->emitBody($handler, $segmentStart, implode("\n", $buffer)); 131 } 132 while ($currentDepth > 0) { 133 $handler->addCall('quote_close', [], $pos + strlen($match)); 134 $currentDepth--; 135 } 136 137 return true; 138 } 139 140 /** 141 * Parse one captured line into depth, content, and content offset. 142 * 143 * Counts leading `>` characters (each consuming one optional 144 * trailing space) to compute the depth. The remainder of the line 145 * is the content for that depth. The returned `offset` is the 146 * absolute byte position of the content's first character within 147 * the source (`$lineStart` plus the length of the consumed marker 148 * prefix). 149 * 150 * `> > foo` → depth 2, content `foo`. `>>foo` → depth 2, content 151 * `foo`. `>` alone → depth 1, content empty. 152 * 153 * @param string $line one line of captured blockquote text, with 154 * no surrounding newlines 155 * @param int $lineStart absolute byte offset of the line's first 156 * character within the source 157 * @return array{depth: int, content: string, offset: int} 158 */ 159 protected function parseLine(string $line, int $lineStart): array 160 { 161 $depth = 0; 162 $i = 0; 163 $len = strlen($line); 164 while ($i < $len && $line[$i] === '>') { 165 $depth++; 166 $i++; 167 if ($i < $len && $line[$i] === ' ') $i++; 168 } 169 return [ 170 'depth' => $depth, 171 'content' => substr($line, $i), 172 'offset' => $lineStart + $i, 173 ]; 174 } 175 176 /** 177 * Sub-parse a body segment and emit its calls inside a Nest. 178 * 179 * Drops `document_start` / `document_end` from the sub-parser 180 * output. Under DW-preferred syntax, also runs the linebreak 181 * post-pass so paragraph wrapping is flattened into explicit 182 * `linebreak` calls. Empty bodies emit nothing. 183 * 184 * `$segmentStart` is the absolute byte offset of the segment's 185 * first content character within the source. Sub-handler positions 186 * are relative to the sub-parsed body, which begins at the first 187 * line of the segment, so adding `$segmentStart` to each 188 * sub-handler position lands the call back on the right byte in 189 * the source. Lines after the first drift slightly because the 190 * `>[ ]?` prefix between source lines collapses to a single `\n` 191 * in the sub-parsed body — drift is bounded by the prefix length 192 * (one or two bytes per line skipped). 193 * 194 * @param Handler $handler outer handler to emit calls on 195 * @param int $segmentStart absolute byte offset of the segment's 196 * first content character within the source 197 * @param string $body concatenated content of the buffered lines, 198 * separated by `\n` 199 */ 200 protected function emitBody(Handler $handler, int $segmentStart, string $body): void 201 { 202 $registry = $this->registry; 203 $calls = $registry->withSubParser( 204 [ModeRegistry::CATEGORY_BASEONLY], 205 ['gfm_quote'], 206 static function ($subParser) use ($body) { 207 $subParser->getHandler()->reset(); 208 $subParser->parse($body); 209 return $subParser->getHandler()->calls; 210 } 211 ); 212 213 if ($calls && $calls[0][0] === 'document_start') array_shift($calls); 214 if ($calls && end($calls)[0] === 'document_end') array_pop($calls); 215 216 if ($registry->isDwPreferred()) { 217 $calls = $this->flattenForDwRendering($calls); 218 } 219 220 if (!$calls) return; 221 222 $outer = $handler->getCallWriter(); 223 $nest = new Nest($outer); 224 $handler->setCallWriter($nest); 225 foreach ($calls as $call) { 226 $handler->addCall($call[0], $call[1], $segmentStart + $call[2]); 227 } 228 $handler->setCallWriter($nest->process()); 229 } 230 231 /** 232 * Flatten paragraph structure into linebreak-separated cdata. 233 * 234 * DW Quote historically rendered each `>`-line as a separate visible 235 * line via an explicit `<br/>` between same-depth markers. To 236 * preserve that rendering for DW-preferred installs, this pass: 237 * 238 * 1. Replaces every `p_open` and `p_close` with a `linebreak` 239 * call. After this, paragraph boundaries become two adjacent 240 * linebreaks (the close-of-prev plus the open-of-next), which 241 * matches the DW two-`<br/>`-for-blank-line shape. 242 * 2. Drops the first and last `linebreak` calls so the run starts 243 * and ends with content, not break markers. 244 * 3. Splits any `cdata` containing `\n` into multiple `cdata` 245 * calls separated by `linebreak` — sub-parsed paragraphs may 246 * contain soft breaks that a renderer would otherwise collapse 247 * to a single space. 248 * 249 * Block-level calls inside the body (list_open from a list inside 250 * a quote, code, etc.) are passed through unchanged. 251 * 252 * @param array $calls sub-parsed call list to flatten 253 * @return array the flattened call list 254 */ 255 protected function flattenForDwRendering(array $calls): array 256 { 257 $stage = []; 258 foreach ($calls as $call) { 259 if ($call[0] === 'p_open' || $call[0] === 'p_close') { 260 $stage[] = ['linebreak', [], $call[2]]; 261 } else { 262 $stage[] = $call; 263 } 264 } 265 266 while ($stage && $stage[0][0] === 'linebreak') array_shift($stage); 267 while ($stage && end($stage)[0] === 'linebreak') array_pop($stage); 268 269 $out = []; 270 foreach ($stage as $call) { 271 if ($call[0] === 'cdata' && str_contains($call[1][0], "\n")) { 272 $parts = explode("\n", $call[1][0]); 273 foreach ($parts as $i => $part) { 274 if ($i > 0) $out[] = ['linebreak', [], $call[2]]; 275 if ($part !== '') $out[] = ['cdata', [$part], $call[2]]; 276 } 277 } else { 278 $out[] = $call; 279 } 280 } 281 282 return $out; 283 } 284} 285