1<?php 2 3namespace dokuwiki\test\Parsing\ParserMode; 4 5use dokuwiki\Parsing\ModeRegistry; 6use dokuwiki\Parsing\ParserMode\GfmQuote; 7use dokuwiki\Parsing\ParserMode\GfmTable; 8use dokuwiki\Parsing\ParserMode\Listblock; 9use dokuwiki\Parsing\ParserMode\Table; 10 11/** 12 * Tests for GFM-style block quotes. 13 * 14 * GfmQuote is the unified blockquote implementation covering both DW and 15 * GFM dialects. The mode captures the entire quote via addSpecialPattern 16 * and sub-parses the stripped body, so the outer parser only needs 17 * gfm_quote attached; inline modes and block modes (lists, code blocks, 18 * nested quotes) are picked up by the sub-parser. 19 * 20 * Two rendering shapes are exercised. Under DW-preferred syntax, a 21 * post-pass flattens the sub-parser's paragraph wrapping into linebreak- 22 * separated cdata so existing DW pages keep their `<br/>`-between-lines 23 * rendering. Under MD-preferred syntax the sub-parser's paragraph 24 * wrapping survives — a quote with one paragraph emits 25 * `<blockquote><p>...</p></blockquote>`. 26 */ 27class GfmQuoteTest extends ParserTestBase 28{ 29 public function tearDown(): void 30 { 31 ModeRegistry::reset(); 32 parent::tearDown(); 33 } 34 35 private function setSyntax(string $syntax): void 36 { 37 global $conf; 38 $conf['syntax'] = $syntax; 39 ModeRegistry::reset(); 40 } 41 42 /** 43 * Recursively flatten call lists, descending into `nest` content. 44 * Useful for tests that just check whether an instruction appears 45 * somewhere in the rendered output regardless of nesting depth. 46 */ 47 private function flatNames(array $calls): array 48 { 49 $names = []; 50 foreach ($calls as $call) { 51 $names[] = $call[0]; 52 if ($call[0] === 'nest') { 53 $names = array_merge($names, $this->flatNames($call[1][0])); 54 } 55 } 56 return $names; 57 } 58 59 public function testSortValue() 60 { 61 $mode = new GfmQuote(); 62 $this->assertSame(220, $mode->getSort()); 63 } 64 65 // ----- DW-preferred rendering: linebreak-separated, no <p> ------------ 66 67 public function testDwSingleLine() 68 { 69 $this->setSyntax('dw'); 70 $this->P->addMode('gfm_quote', new GfmQuote()); 71 $this->P->parse("> foo\n"); 72 73 $expected = [ 74 ['document_start', []], 75 ['quote_open', []], 76 ['nest', [[ ['cdata', ['foo']] ]]], 77 ['quote_close', []], 78 ['document_end', []], 79 ]; 80 $this->assertCalls($expected, $this->H->calls); 81 } 82 83 public function testDwSpaceAfterMarkerOptional() 84 { 85 // GFM allows omitting the space after `>`; DW always did. Strip 86 // logic removes one optional space after the `>`, so `>foo` and 87 // `> foo` both produce cdata "foo". 88 $this->setSyntax('dw'); 89 $this->P->addMode('gfm_quote', new GfmQuote()); 90 $this->P->parse(">foo\n"); 91 92 $names = $this->flatNames($this->H->calls); 93 $this->assertContains('quote_open', $names); 94 $this->assertContains('cdata', $names); 95 } 96 97 public function testDwTwoLinesEmitLinebreak() 98 { 99 // The DW-preferred post-pass converts the sub-parser's paragraph 100 // wrapping into a linebreak between the two cdata calls, matching 101 // the historical `<blockquote>foo<br/>bar</blockquote>` shape. 102 $this->setSyntax('dw'); 103 $this->P->addMode('gfm_quote', new GfmQuote()); 104 $this->P->parse("> foo\n> bar\n"); 105 106 $expected = [ 107 ['document_start', []], 108 ['quote_open', []], 109 ['nest', [[ 110 ['cdata', ['foo']], 111 ['linebreak', []], 112 ['cdata', ['bar']], 113 ]]], 114 ['quote_close', []], 115 ['document_end', []], 116 ]; 117 $this->assertCalls($expected, $this->H->calls); 118 } 119 120 public function testDwBlankMarkerLineEmitsTwoLinebreaks() 121 { 122 // `>` alone between content lines is a paragraph break in GFM. 123 // The DW post-pass replaces each p_open and each p_close with a 124 // linebreak, producing two adjacent linebreak calls between the 125 // two content cdata — matches the historical DW two-`<br/>` shape. 126 $this->setSyntax('dw'); 127 $this->P->addMode('gfm_quote', new GfmQuote()); 128 $this->P->parse("> foo\n>\n> bar\n"); 129 130 $expected = [ 131 ['document_start', []], 132 ['quote_open', []], 133 ['nest', [[ 134 ['cdata', ['foo']], 135 ['linebreak', []], 136 ['linebreak', []], 137 ['cdata', ['bar']], 138 ]]], 139 ['quote_close', []], 140 ['document_end', []], 141 ]; 142 $this->assertCalls($expected, $this->H->calls); 143 } 144 145 public function testDwNested() 146 { 147 $this->setSyntax('dw'); 148 $this->P->addMode('gfm_quote', new GfmQuote()); 149 $this->P->parse("> > foo\n"); 150 151 // The outer captures a single line `> > foo`. Stripping the 152 // outer marker leaves `> foo`, which the sub-parser feeds back 153 // through GfmQuote — recursion produces a nested quote_open / 154 // quote_close pair carrying the cdata. 155 $names = $this->flatNames($this->H->calls); 156 $opens = array_filter($names, static fn($n) => $n === 'quote_open'); 157 $closes = array_filter($names, static fn($n) => $n === 'quote_close'); 158 $this->assertCount(2, $opens, 'two levels of quote_open expected'); 159 $this->assertCount(2, $closes, 'two levels of quote_close expected'); 160 } 161 162 public function testDwNoLazyContinuation() 163 { 164 // GfmQuote does not implement lazy continuation: every quote 165 // line must begin with `>`. `bar` without a `>` prefix terminates 166 // the quote, so it ends up as a separate paragraph — matching 167 // today's DW behavior. 168 $this->setSyntax('dw'); 169 $this->P->addMode('gfm_quote', new GfmQuote()); 170 $this->P->parse("> foo\nbar\n"); 171 172 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 173 $this->assertCount(1, $opens, 'quote opens once and stops at the non-`>` line'); 174 175 // `bar` is outside the quote — find a top-level cdata after the close 176 $afterClose = false; 177 $sawBarOutside = false; 178 foreach ($this->H->calls as $call) { 179 if ($call[0] === 'quote_close') $afterClose = true; 180 if ($afterClose && $call[0] === 'cdata' && str_contains($call[1][0], 'bar')) { 181 $sawBarOutside = true; 182 } 183 } 184 $this->assertTrue($sawBarOutside, '`bar` must appear as cdata outside the quote'); 185 } 186 187 public function testDwBlankLineSeparatesQuotes() 188 { 189 // A truly blank line ends the quote. The next `>` starts a new 190 // quote, producing two distinct quote_open / quote_close pairs. 191 $this->setSyntax('dw'); 192 $this->P->addMode('gfm_quote', new GfmQuote()); 193 $this->P->parse("> foo\n\n> bar\n"); 194 195 $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 196 $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_close'); 197 $this->assertCount(2, $opens, 'two distinct quote blocks'); 198 $this->assertCount(2, $closes); 199 } 200 201 public function testDwHeaderInsideQuoteStaysCdata() 202 { 203 // Sub-parser excludes BASEONLY (Header / GfmHeader). Header 204 // instructions drive section-edit anchors and TOC entries that 205 // do not compose with `<blockquote>`. `# Foo` therefore stays 206 // as plain cdata text. 207 $this->setSyntax('dw'); 208 $this->P->addMode('gfm_quote', new GfmQuote()); 209 $this->P->parse("> # Foo\n"); 210 211 $names = $this->flatNames($this->H->calls); 212 $this->assertNotContains('header', $names); 213 $this->assertNotContains('section_open', $names); 214 $this->assertContains('cdata', $names); 215 } 216 217 // ----- MD-preferred rendering: paragraph wrapping survives ------------ 218 219 public function testMdSingleParagraph() 220 { 221 $this->setSyntax('md'); 222 $this->P->addMode('gfm_quote', new GfmQuote()); 223 $this->P->parse("> foo\n> bar\n"); 224 225 // Sub-parser wraps the body in `p_open` / `p_close`. The outer 226 // wraps them inside a `nest`, and Block treats the nest as 227 // opaque. Two `>`-content lines join into one paragraph. 228 $expected = [ 229 ['document_start', []], 230 ['quote_open', []], 231 ['nest', [[ 232 ['p_open', []], 233 ['cdata', ["foo\nbar"]], 234 ['p_close', []], 235 ]]], 236 ['quote_close', []], 237 ['document_end', []], 238 ]; 239 $this->assertCalls($expected, $this->H->calls); 240 } 241 242 public function testMdMultiParagraph() 243 { 244 // `>` alone between content lines creates two paragraphs in one 245 // blockquote — under MD-preferred the post-pass does not run, so 246 // the sub-parser's `p_open` / `p_close` pairs survive intact. 247 $this->setSyntax('md'); 248 $this->P->addMode('gfm_quote', new GfmQuote()); 249 $this->P->parse("> foo\n>\n> bar\n"); 250 251 $names = $this->flatNames($this->H->calls); 252 $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 253 $pCloses = array_filter($names, static fn($n) => $n === 'p_close'); 254 $this->assertCount(2, $pOpens, 'two paragraphs inside one blockquote'); 255 $this->assertCount(2, $pCloses); 256 } 257 258 // ----- Handoff from preceding block modes ---------------------------- 259 // 260 // GfmTable, DW Table, and DW Listblock all consume the boundary \n on 261 // their way out (their exit pattern is \n by structural necessity: at 262 // the boundary there is no leading unmatched content for a zero-width 263 // lookahead exit to attach to). The pattern (?:^|\n)>... lets GfmQuote 264 // open at the line that starts the blockquote regardless of whether 265 // the preceding mode left the \n in the stream. 266 267 public function testHandoffFromGfmTable() 268 { 269 // Spec example 201: a `>` line immediately following a GFM table 270 // ends the table and opens a blockquote. GfmTable's exit consumes 271 // the boundary \n, so GfmQuote relies on the line-start (^) 272 // alternative to fire here. 273 $this->setSyntax('md'); 274 $this->P->addMode('gfm_table', new GfmTable()); 275 $this->P->addMode('gfm_quote', new GfmQuote()); 276 $this->P->parse("| abc | def |\n| --- | --- |\n| bar | baz |\n> bar"); 277 278 $names = array_map(static fn($c) => $c[0], $this->H->calls); 279 $this->assertContains('table_open', $names); 280 $this->assertContains('table_close', $names); 281 $this->assertContains('quote_open', $names); 282 $this->assertContains('quote_close', $names); 283 284 // Order: the quote must open after the table closes. 285 $tableCloseIdx = array_search('table_close', $names, true); 286 $quoteOpenIdx = array_search('quote_open', $names, true); 287 $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx); 288 } 289 290 public function testHandoffFromDwTable() 291 { 292 // Same as the GFM-table case for a DW-style table. DW Table's 293 // exit also consumes \n, so the line-start (^) alternative is 294 // what lets the blockquote open. 295 $this->setSyntax('dw'); 296 $this->P->addMode('table', new Table()); 297 $this->P->addMode('gfm_quote', new GfmQuote()); 298 $this->P->parse("| foo | bar |\n> baz"); 299 300 $names = array_map(static fn($c) => $c[0], $this->H->calls); 301 $this->assertContains('table_open', $names); 302 $this->assertContains('table_close', $names); 303 $this->assertContains('quote_open', $names); 304 $this->assertContains('quote_close', $names); 305 306 $tableCloseIdx = array_search('table_close', $names, true); 307 $quoteOpenIdx = array_search('quote_open', $names, true); 308 $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx); 309 } 310 311 public function testHandoffFromDwListblock() 312 { 313 // DW Listblock also exits on \n, consuming the boundary. The 314 // line-start alternative lets a `>` line right after the list 315 // open a blockquote without an intervening blank line. 316 $this->setSyntax('dw'); 317 $this->P->addMode('listblock', new Listblock()); 318 $this->P->addMode('gfm_quote', new GfmQuote()); 319 $this->P->parse(" * foo\n * bar\n> baz"); 320 321 $names = array_map(static fn($c) => $c[0], $this->H->calls); 322 $this->assertContains('listu_open', $names); 323 $this->assertContains('listu_close', $names); 324 $this->assertContains('quote_open', $names); 325 $this->assertContains('quote_close', $names); 326 327 $listCloseIdx = array_search('listu_close', $names, true); 328 $quoteOpenIdx = array_search('quote_open', $names, true); 329 $this->assertGreaterThan($listCloseIdx, $quoteOpenIdx); 330 } 331 332 public function testMdListInsideQuote() 333 { 334 // GfmListblock is loaded under MD-preferred syntax, so a list 335 // inside a quote parses as a real list. The sub-parser's list 336 // calls land inside the outer `nest` wrapper. 337 $this->setSyntax('md'); 338 ModeRegistry::reset(); 339 // Add the registry's full mode set so gfm_listblock is reachable 340 // via the sub-parser (the sub-parser uses ModeRegistry::getModes, 341 // which honors $conf['syntax']). 342 foreach (ModeRegistry::getInstance()->getModes() as $m) { 343 $this->P->addMode($m['mode'], $m['obj']); 344 } 345 346 $this->P->parse("> - foo\n> - bar\n"); 347 348 $names = $this->flatNames($this->H->calls); 349 $this->assertContains('quote_open', $names); 350 $this->assertContains('listu_open', $names, 'list inside quote must parse'); 351 $this->assertContains('listu_close', $names); 352 $this->assertContains('quote_close', $names); 353 } 354} 355