1309a0852SAndreas Gohr<?php 2309a0852SAndreas Gohr 3309a0852SAndreas Gohrnamespace dokuwiki\test\Parsing\ParserMode; 4309a0852SAndreas Gohr 5309a0852SAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmQuote; 6dccbd514SAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmTable; 7dccbd514SAndreas Gohruse dokuwiki\Parsing\ParserMode\Listblock; 8dccbd514SAndreas Gohruse dokuwiki\Parsing\ParserMode\Table; 9309a0852SAndreas Gohr 10309a0852SAndreas Gohr/** 11309a0852SAndreas Gohr * Tests for GFM-style block quotes. 12309a0852SAndreas Gohr * 13309a0852SAndreas Gohr * GfmQuote is the unified blockquote implementation covering both DW and 14309a0852SAndreas Gohr * GFM dialects. The mode captures the entire quote via addSpecialPattern 15309a0852SAndreas Gohr * and sub-parses the stripped body, so the outer parser only needs 16309a0852SAndreas Gohr * gfm_quote attached; inline modes and block modes (lists, code blocks, 17309a0852SAndreas Gohr * nested quotes) are picked up by the sub-parser. 18309a0852SAndreas Gohr * 19309a0852SAndreas Gohr * Two rendering shapes are exercised. Under DW-preferred syntax, a 20309a0852SAndreas Gohr * post-pass flattens the sub-parser's paragraph wrapping into linebreak- 21309a0852SAndreas Gohr * separated cdata so existing DW pages keep their `<br/>`-between-lines 22309a0852SAndreas Gohr * rendering. Under MD-preferred syntax the sub-parser's paragraph 23309a0852SAndreas Gohr * wrapping survives — a quote with one paragraph emits 24309a0852SAndreas Gohr * `<blockquote><p>...</p></blockquote>`. 25309a0852SAndreas Gohr */ 26309a0852SAndreas Gohrclass GfmQuoteTest extends ParserTestBase 27309a0852SAndreas Gohr{ 28309a0852SAndreas Gohr /** 29309a0852SAndreas Gohr * Recursively flatten call lists, descending into `nest` content. 30309a0852SAndreas Gohr * Useful for tests that just check whether an instruction appears 31309a0852SAndreas Gohr * somewhere in the rendered output regardless of nesting depth. 32309a0852SAndreas Gohr */ 33309a0852SAndreas Gohr private function flatNames(array $calls): array 34309a0852SAndreas Gohr { 35309a0852SAndreas Gohr $names = []; 36309a0852SAndreas Gohr foreach ($calls as $call) { 37309a0852SAndreas Gohr $names[] = $call[0]; 38309a0852SAndreas Gohr if ($call[0] === 'nest') { 39309a0852SAndreas Gohr $names = array_merge($names, $this->flatNames($call[1][0])); 40309a0852SAndreas Gohr } 41309a0852SAndreas Gohr } 42309a0852SAndreas Gohr return $names; 43309a0852SAndreas Gohr } 44309a0852SAndreas Gohr 45309a0852SAndreas Gohr public function testSortValue() 46309a0852SAndreas Gohr { 47309a0852SAndreas Gohr $mode = new GfmQuote(); 48309a0852SAndreas Gohr $this->assertSame(220, $mode->getSort()); 49309a0852SAndreas Gohr } 50309a0852SAndreas Gohr 51309a0852SAndreas Gohr // ----- DW-preferred rendering: linebreak-separated, no <p> ------------ 52309a0852SAndreas Gohr 53309a0852SAndreas Gohr public function testDwSingleLine() 54309a0852SAndreas Gohr { 5513a62f81SAndreas Gohr $this->setSyntax('dw'); 56309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 57309a0852SAndreas Gohr $this->P->parse("> foo\n"); 58309a0852SAndreas Gohr 59309a0852SAndreas Gohr $expected = [ 60309a0852SAndreas Gohr ['document_start', []], 61309a0852SAndreas Gohr ['quote_open', []], 62309a0852SAndreas Gohr ['nest', [[ ['cdata', ['foo']] ]]], 63309a0852SAndreas Gohr ['quote_close', []], 64309a0852SAndreas Gohr ['document_end', []], 65309a0852SAndreas Gohr ]; 66309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 67309a0852SAndreas Gohr } 68309a0852SAndreas Gohr 69309a0852SAndreas Gohr public function testDwSpaceAfterMarkerOptional() 70309a0852SAndreas Gohr { 71309a0852SAndreas Gohr // GFM allows omitting the space after `>`; DW always did. Strip 72309a0852SAndreas Gohr // logic removes one optional space after the `>`, so `>foo` and 73309a0852SAndreas Gohr // `> foo` both produce cdata "foo". 7413a62f81SAndreas Gohr $this->setSyntax('dw'); 75309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 76309a0852SAndreas Gohr $this->P->parse(">foo\n"); 77309a0852SAndreas Gohr 78309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 79309a0852SAndreas Gohr $this->assertContains('quote_open', $names); 80309a0852SAndreas Gohr $this->assertContains('cdata', $names); 81309a0852SAndreas Gohr } 82309a0852SAndreas Gohr 83309a0852SAndreas Gohr public function testDwTwoLinesEmitLinebreak() 84309a0852SAndreas Gohr { 85309a0852SAndreas Gohr // The DW-preferred post-pass converts the sub-parser's paragraph 86309a0852SAndreas Gohr // wrapping into a linebreak between the two cdata calls, matching 87309a0852SAndreas Gohr // the historical `<blockquote>foo<br/>bar</blockquote>` shape. 8813a62f81SAndreas Gohr $this->setSyntax('dw'); 89309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 90309a0852SAndreas Gohr $this->P->parse("> foo\n> bar\n"); 91309a0852SAndreas Gohr 92309a0852SAndreas Gohr $expected = [ 93309a0852SAndreas Gohr ['document_start', []], 94309a0852SAndreas Gohr ['quote_open', []], 95309a0852SAndreas Gohr ['nest', [[ 96309a0852SAndreas Gohr ['cdata', ['foo']], 97309a0852SAndreas Gohr ['linebreak', []], 98309a0852SAndreas Gohr ['cdata', ['bar']], 99309a0852SAndreas Gohr ]]], 100309a0852SAndreas Gohr ['quote_close', []], 101309a0852SAndreas Gohr ['document_end', []], 102309a0852SAndreas Gohr ]; 103309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 104309a0852SAndreas Gohr } 105309a0852SAndreas Gohr 106309a0852SAndreas Gohr public function testDwBlankMarkerLineEmitsTwoLinebreaks() 107309a0852SAndreas Gohr { 108309a0852SAndreas Gohr // `>` alone between content lines is a paragraph break in GFM. 109309a0852SAndreas Gohr // The DW post-pass replaces each p_open and each p_close with a 110309a0852SAndreas Gohr // linebreak, producing two adjacent linebreak calls between the 111309a0852SAndreas Gohr // two content cdata — matches the historical DW two-`<br/>` shape. 11213a62f81SAndreas Gohr $this->setSyntax('dw'); 113309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 114309a0852SAndreas Gohr $this->P->parse("> foo\n>\n> bar\n"); 115309a0852SAndreas Gohr 116309a0852SAndreas Gohr $expected = [ 117309a0852SAndreas Gohr ['document_start', []], 118309a0852SAndreas Gohr ['quote_open', []], 119309a0852SAndreas Gohr ['nest', [[ 120309a0852SAndreas Gohr ['cdata', ['foo']], 121309a0852SAndreas Gohr ['linebreak', []], 122309a0852SAndreas Gohr ['linebreak', []], 123309a0852SAndreas Gohr ['cdata', ['bar']], 124309a0852SAndreas Gohr ]]], 125309a0852SAndreas Gohr ['quote_close', []], 126309a0852SAndreas Gohr ['document_end', []], 127309a0852SAndreas Gohr ]; 128309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 129309a0852SAndreas Gohr } 130309a0852SAndreas Gohr 131309a0852SAndreas Gohr public function testDwNested() 132309a0852SAndreas Gohr { 13313a62f81SAndreas Gohr $this->setSyntax('dw'); 134309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 135309a0852SAndreas Gohr $this->P->parse("> > foo\n"); 136309a0852SAndreas Gohr 137309a0852SAndreas Gohr // The outer captures a single line `> > foo`. Stripping the 138309a0852SAndreas Gohr // outer marker leaves `> foo`, which the sub-parser feeds back 139309a0852SAndreas Gohr // through GfmQuote — recursion produces a nested quote_open / 140309a0852SAndreas Gohr // quote_close pair carrying the cdata. 141309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 142309a0852SAndreas Gohr $opens = array_filter($names, static fn($n) => $n === 'quote_open'); 143309a0852SAndreas Gohr $closes = array_filter($names, static fn($n) => $n === 'quote_close'); 144309a0852SAndreas Gohr $this->assertCount(2, $opens, 'two levels of quote_open expected'); 145309a0852SAndreas Gohr $this->assertCount(2, $closes, 'two levels of quote_close expected'); 146309a0852SAndreas Gohr } 147309a0852SAndreas Gohr 148309a0852SAndreas Gohr public function testDwNoLazyContinuation() 149309a0852SAndreas Gohr { 150309a0852SAndreas Gohr // GfmQuote does not implement lazy continuation: every quote 151309a0852SAndreas Gohr // line must begin with `>`. `bar` without a `>` prefix terminates 152309a0852SAndreas Gohr // the quote, so it ends up as a separate paragraph — matching 153309a0852SAndreas Gohr // today's DW behavior. 15413a62f81SAndreas Gohr $this->setSyntax('dw'); 155309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 156309a0852SAndreas Gohr $this->P->parse("> foo\nbar\n"); 157309a0852SAndreas Gohr 158309a0852SAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 159309a0852SAndreas Gohr $this->assertCount(1, $opens, 'quote opens once and stops at the non-`>` line'); 160309a0852SAndreas Gohr 161309a0852SAndreas Gohr // `bar` is outside the quote — find a top-level cdata after the close 162309a0852SAndreas Gohr $afterClose = false; 163309a0852SAndreas Gohr $sawBarOutside = false; 164309a0852SAndreas Gohr foreach ($this->H->calls as $call) { 165309a0852SAndreas Gohr if ($call[0] === 'quote_close') $afterClose = true; 166309a0852SAndreas Gohr if ($afterClose && $call[0] === 'cdata' && str_contains($call[1][0], 'bar')) { 167309a0852SAndreas Gohr $sawBarOutside = true; 168309a0852SAndreas Gohr } 169309a0852SAndreas Gohr } 170309a0852SAndreas Gohr $this->assertTrue($sawBarOutside, '`bar` must appear as cdata outside the quote'); 171309a0852SAndreas Gohr } 172309a0852SAndreas Gohr 173309a0852SAndreas Gohr public function testDwBlankLineSeparatesQuotes() 174309a0852SAndreas Gohr { 175309a0852SAndreas Gohr // A truly blank line ends the quote. The next `>` starts a new 176309a0852SAndreas Gohr // quote, producing two distinct quote_open / quote_close pairs. 17713a62f81SAndreas Gohr $this->setSyntax('dw'); 178309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 179309a0852SAndreas Gohr $this->P->parse("> foo\n\n> bar\n"); 180309a0852SAndreas Gohr 181309a0852SAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 182309a0852SAndreas Gohr $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_close'); 183309a0852SAndreas Gohr $this->assertCount(2, $opens, 'two distinct quote blocks'); 184309a0852SAndreas Gohr $this->assertCount(2, $closes); 185309a0852SAndreas Gohr } 186309a0852SAndreas Gohr 187309a0852SAndreas Gohr public function testDwHeaderInsideQuoteStaysCdata() 188309a0852SAndreas Gohr { 189309a0852SAndreas Gohr // Sub-parser excludes BASEONLY (Header / GfmHeader). Header 190309a0852SAndreas Gohr // instructions drive section-edit anchors and TOC entries that 191309a0852SAndreas Gohr // do not compose with `<blockquote>`. `# Foo` therefore stays 192309a0852SAndreas Gohr // as plain cdata text. 19313a62f81SAndreas Gohr $this->setSyntax('dw'); 194309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 195309a0852SAndreas Gohr $this->P->parse("> # Foo\n"); 196309a0852SAndreas Gohr 197309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 198309a0852SAndreas Gohr $this->assertNotContains('header', $names); 199309a0852SAndreas Gohr $this->assertNotContains('section_open', $names); 200309a0852SAndreas Gohr $this->assertContains('cdata', $names); 201309a0852SAndreas Gohr } 202309a0852SAndreas Gohr 203309a0852SAndreas Gohr // ----- MD-preferred rendering: paragraph wrapping survives ------------ 204309a0852SAndreas Gohr 205309a0852SAndreas Gohr public function testMdSingleParagraph() 206309a0852SAndreas Gohr { 20713a62f81SAndreas Gohr $this->setSyntax('md'); 208309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 209309a0852SAndreas Gohr $this->P->parse("> foo\n> bar\n"); 210309a0852SAndreas Gohr 211309a0852SAndreas Gohr // Sub-parser wraps the body in `p_open` / `p_close`. The outer 212309a0852SAndreas Gohr // wraps them inside a `nest`, and Block treats the nest as 213309a0852SAndreas Gohr // opaque. Two `>`-content lines join into one paragraph. 214309a0852SAndreas Gohr $expected = [ 215309a0852SAndreas Gohr ['document_start', []], 216309a0852SAndreas Gohr ['quote_open', []], 217309a0852SAndreas Gohr ['nest', [[ 218309a0852SAndreas Gohr ['p_open', []], 219309a0852SAndreas Gohr ['cdata', ["foo\nbar"]], 220309a0852SAndreas Gohr ['p_close', []], 221309a0852SAndreas Gohr ]]], 222309a0852SAndreas Gohr ['quote_close', []], 223309a0852SAndreas Gohr ['document_end', []], 224309a0852SAndreas Gohr ]; 225309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 226309a0852SAndreas Gohr } 227309a0852SAndreas Gohr 228309a0852SAndreas Gohr public function testMdMultiParagraph() 229309a0852SAndreas Gohr { 230309a0852SAndreas Gohr // `>` alone between content lines creates two paragraphs in one 231309a0852SAndreas Gohr // blockquote — under MD-preferred the post-pass does not run, so 232309a0852SAndreas Gohr // the sub-parser's `p_open` / `p_close` pairs survive intact. 23313a62f81SAndreas Gohr $this->setSyntax('md'); 234309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 235309a0852SAndreas Gohr $this->P->parse("> foo\n>\n> bar\n"); 236309a0852SAndreas Gohr 237309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 238309a0852SAndreas Gohr $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 239309a0852SAndreas Gohr $pCloses = array_filter($names, static fn($n) => $n === 'p_close'); 240309a0852SAndreas Gohr $this->assertCount(2, $pOpens, 'two paragraphs inside one blockquote'); 241309a0852SAndreas Gohr $this->assertCount(2, $pCloses); 242309a0852SAndreas Gohr } 243309a0852SAndreas Gohr 244dccbd514SAndreas Gohr // ----- Handoff from preceding block modes ---------------------------- 245dccbd514SAndreas Gohr // 246dccbd514SAndreas Gohr // GfmTable, DW Table, and DW Listblock all consume the boundary \n on 247dccbd514SAndreas Gohr // their way out (their exit pattern is \n by structural necessity: at 248dccbd514SAndreas Gohr // the boundary there is no leading unmatched content for a zero-width 249dccbd514SAndreas Gohr // lookahead exit to attach to). The pattern (?:^|\n)>... lets GfmQuote 250dccbd514SAndreas Gohr // open at the line that starts the blockquote regardless of whether 251dccbd514SAndreas Gohr // the preceding mode left the \n in the stream. 252dccbd514SAndreas Gohr 253dccbd514SAndreas Gohr public function testHandoffFromGfmTable() 254dccbd514SAndreas Gohr { 255dccbd514SAndreas Gohr // Spec example 201: a `>` line immediately following a GFM table 256dccbd514SAndreas Gohr // ends the table and opens a blockquote. GfmTable's exit consumes 257dccbd514SAndreas Gohr // the boundary \n, so GfmQuote relies on the line-start (^) 258dccbd514SAndreas Gohr // alternative to fire here. 259dccbd514SAndreas Gohr $this->setSyntax('md'); 260dccbd514SAndreas Gohr $this->P->addMode('gfm_table', new GfmTable()); 261dccbd514SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 262dccbd514SAndreas Gohr $this->P->parse("| abc | def |\n| --- | --- |\n| bar | baz |\n> bar"); 263dccbd514SAndreas Gohr 264dccbd514SAndreas Gohr $names = array_map(static fn($c) => $c[0], $this->H->calls); 265dccbd514SAndreas Gohr $this->assertContains('table_open', $names); 266dccbd514SAndreas Gohr $this->assertContains('table_close', $names); 267dccbd514SAndreas Gohr $this->assertContains('quote_open', $names); 268dccbd514SAndreas Gohr $this->assertContains('quote_close', $names); 269dccbd514SAndreas Gohr 270dccbd514SAndreas Gohr // Order: the quote must open after the table closes. 271dccbd514SAndreas Gohr $tableCloseIdx = array_search('table_close', $names, true); 272dccbd514SAndreas Gohr $quoteOpenIdx = array_search('quote_open', $names, true); 273dccbd514SAndreas Gohr $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx); 274dccbd514SAndreas Gohr } 275dccbd514SAndreas Gohr 276dccbd514SAndreas Gohr public function testHandoffFromDwTable() 277dccbd514SAndreas Gohr { 278dccbd514SAndreas Gohr // Same as the GFM-table case for a DW-style table. DW Table's 279dccbd514SAndreas Gohr // exit also consumes \n, so the line-start (^) alternative is 280dccbd514SAndreas Gohr // what lets the blockquote open. 281dccbd514SAndreas Gohr $this->setSyntax('dw'); 282dccbd514SAndreas Gohr $this->P->addMode('table', new Table()); 283dccbd514SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 284dccbd514SAndreas Gohr $this->P->parse("| foo | bar |\n> baz"); 285dccbd514SAndreas Gohr 286dccbd514SAndreas Gohr $names = array_map(static fn($c) => $c[0], $this->H->calls); 287dccbd514SAndreas Gohr $this->assertContains('table_open', $names); 288dccbd514SAndreas Gohr $this->assertContains('table_close', $names); 289dccbd514SAndreas Gohr $this->assertContains('quote_open', $names); 290dccbd514SAndreas Gohr $this->assertContains('quote_close', $names); 291dccbd514SAndreas Gohr 292dccbd514SAndreas Gohr $tableCloseIdx = array_search('table_close', $names, true); 293dccbd514SAndreas Gohr $quoteOpenIdx = array_search('quote_open', $names, true); 294dccbd514SAndreas Gohr $this->assertGreaterThan($tableCloseIdx, $quoteOpenIdx); 295dccbd514SAndreas Gohr } 296dccbd514SAndreas Gohr 297dccbd514SAndreas Gohr public function testHandoffFromDwListblock() 298dccbd514SAndreas Gohr { 299dccbd514SAndreas Gohr // DW Listblock also exits on \n, consuming the boundary. The 300dccbd514SAndreas Gohr // line-start alternative lets a `>` line right after the list 301dccbd514SAndreas Gohr // open a blockquote without an intervening blank line. 302dccbd514SAndreas Gohr $this->setSyntax('dw'); 303dccbd514SAndreas Gohr $this->P->addMode('listblock', new Listblock()); 304dccbd514SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 305dccbd514SAndreas Gohr $this->P->parse(" * foo\n * bar\n> baz"); 306dccbd514SAndreas Gohr 307dccbd514SAndreas Gohr $names = array_map(static fn($c) => $c[0], $this->H->calls); 308dccbd514SAndreas Gohr $this->assertContains('listu_open', $names); 309dccbd514SAndreas Gohr $this->assertContains('listu_close', $names); 310dccbd514SAndreas Gohr $this->assertContains('quote_open', $names); 311dccbd514SAndreas Gohr $this->assertContains('quote_close', $names); 312dccbd514SAndreas Gohr 313dccbd514SAndreas Gohr $listCloseIdx = array_search('listu_close', $names, true); 314dccbd514SAndreas Gohr $quoteOpenIdx = array_search('quote_open', $names, true); 315dccbd514SAndreas Gohr $this->assertGreaterThan($listCloseIdx, $quoteOpenIdx); 316dccbd514SAndreas Gohr } 317dccbd514SAndreas Gohr 318309a0852SAndreas Gohr public function testMdListInsideQuote() 319309a0852SAndreas Gohr { 320309a0852SAndreas Gohr // GfmListblock is loaded under MD-preferred syntax, so a list 321309a0852SAndreas Gohr // inside a quote parses as a real list. The sub-parser's list 322309a0852SAndreas Gohr // calls land inside the outer `nest` wrapper. 32313a62f81SAndreas Gohr $this->setSyntax('md'); 324309a0852SAndreas Gohr // Add the registry's full mode set so gfm_listblock is reachable 325*47a02a10SAndreas Gohr // via the sub-parser (the sub-parser inherits this registry, whose 326*47a02a10SAndreas Gohr // syntax determines which modes load). 327*47a02a10SAndreas Gohr foreach ($this->registry->getModes() as $m) { 328309a0852SAndreas Gohr $this->P->addMode($m['mode'], $m['obj']); 329309a0852SAndreas Gohr } 330309a0852SAndreas Gohr 331309a0852SAndreas Gohr $this->P->parse("> - foo\n> - bar\n"); 332309a0852SAndreas Gohr 333309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 334309a0852SAndreas Gohr $this->assertContains('quote_open', $names); 335309a0852SAndreas Gohr $this->assertContains('listu_open', $names, 'list inside quote must parse'); 336309a0852SAndreas Gohr $this->assertContains('listu_close', $names); 337309a0852SAndreas Gohr $this->assertContains('quote_close', $names); 338309a0852SAndreas Gohr } 339309a0852SAndreas Gohr} 340