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