1*309a0852SAndreas Gohr<?php 2*309a0852SAndreas Gohr 3*309a0852SAndreas Gohrnamespace dokuwiki\test\Parsing\ParserMode; 4*309a0852SAndreas Gohr 5*309a0852SAndreas Gohruse dokuwiki\Parsing\ModeRegistry; 6*309a0852SAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmQuote; 7*309a0852SAndreas Gohr 8*309a0852SAndreas Gohr/** 9*309a0852SAndreas Gohr * Tests for GFM-style block quotes. 10*309a0852SAndreas Gohr * 11*309a0852SAndreas Gohr * GfmQuote is the unified blockquote implementation covering both DW and 12*309a0852SAndreas Gohr * GFM dialects. The mode captures the entire quote via addSpecialPattern 13*309a0852SAndreas Gohr * and sub-parses the stripped body, so the outer parser only needs 14*309a0852SAndreas Gohr * gfm_quote attached; inline modes and block modes (lists, code blocks, 15*309a0852SAndreas Gohr * nested quotes) are picked up by the sub-parser. 16*309a0852SAndreas Gohr * 17*309a0852SAndreas Gohr * Two rendering shapes are exercised. Under DW-preferred syntax, a 18*309a0852SAndreas Gohr * post-pass flattens the sub-parser's paragraph wrapping into linebreak- 19*309a0852SAndreas Gohr * separated cdata so existing DW pages keep their `<br/>`-between-lines 20*309a0852SAndreas Gohr * rendering. Under MD-preferred syntax the sub-parser's paragraph 21*309a0852SAndreas Gohr * wrapping survives — a quote with one paragraph emits 22*309a0852SAndreas Gohr * `<blockquote><p>...</p></blockquote>`. 23*309a0852SAndreas Gohr */ 24*309a0852SAndreas Gohrclass GfmQuoteTest extends ParserTestBase 25*309a0852SAndreas Gohr{ 26*309a0852SAndreas Gohr public function tearDown(): void 27*309a0852SAndreas Gohr { 28*309a0852SAndreas Gohr ModeRegistry::reset(); 29*309a0852SAndreas Gohr parent::tearDown(); 30*309a0852SAndreas Gohr } 31*309a0852SAndreas Gohr 32*309a0852SAndreas Gohr private function setSyntax(string $syntax): void 33*309a0852SAndreas Gohr { 34*309a0852SAndreas Gohr global $conf; 35*309a0852SAndreas Gohr $conf['syntax'] = $syntax; 36*309a0852SAndreas Gohr ModeRegistry::reset(); 37*309a0852SAndreas Gohr } 38*309a0852SAndreas Gohr 39*309a0852SAndreas Gohr /** 40*309a0852SAndreas Gohr * Recursively flatten call lists, descending into `nest` content. 41*309a0852SAndreas Gohr * Useful for tests that just check whether an instruction appears 42*309a0852SAndreas Gohr * somewhere in the rendered output regardless of nesting depth. 43*309a0852SAndreas Gohr */ 44*309a0852SAndreas Gohr private function flatNames(array $calls): array 45*309a0852SAndreas Gohr { 46*309a0852SAndreas Gohr $names = []; 47*309a0852SAndreas Gohr foreach ($calls as $call) { 48*309a0852SAndreas Gohr $names[] = $call[0]; 49*309a0852SAndreas Gohr if ($call[0] === 'nest') { 50*309a0852SAndreas Gohr $names = array_merge($names, $this->flatNames($call[1][0])); 51*309a0852SAndreas Gohr } 52*309a0852SAndreas Gohr } 53*309a0852SAndreas Gohr return $names; 54*309a0852SAndreas Gohr } 55*309a0852SAndreas Gohr 56*309a0852SAndreas Gohr public function testSortValue() 57*309a0852SAndreas Gohr { 58*309a0852SAndreas Gohr $mode = new GfmQuote(); 59*309a0852SAndreas Gohr $this->assertSame(220, $mode->getSort()); 60*309a0852SAndreas Gohr } 61*309a0852SAndreas Gohr 62*309a0852SAndreas Gohr // ----- DW-preferred rendering: linebreak-separated, no <p> ------------ 63*309a0852SAndreas Gohr 64*309a0852SAndreas Gohr public function testDwSingleLine() 65*309a0852SAndreas Gohr { 66*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 67*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 68*309a0852SAndreas Gohr $this->P->parse("> foo\n"); 69*309a0852SAndreas Gohr 70*309a0852SAndreas Gohr $expected = [ 71*309a0852SAndreas Gohr ['document_start', []], 72*309a0852SAndreas Gohr ['quote_open', []], 73*309a0852SAndreas Gohr ['nest', [[ ['cdata', ['foo']] ]]], 74*309a0852SAndreas Gohr ['quote_close', []], 75*309a0852SAndreas Gohr ['document_end', []], 76*309a0852SAndreas Gohr ]; 77*309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 78*309a0852SAndreas Gohr } 79*309a0852SAndreas Gohr 80*309a0852SAndreas Gohr public function testDwSpaceAfterMarkerOptional() 81*309a0852SAndreas Gohr { 82*309a0852SAndreas Gohr // GFM allows omitting the space after `>`; DW always did. Strip 83*309a0852SAndreas Gohr // logic removes one optional space after the `>`, so `>foo` and 84*309a0852SAndreas Gohr // `> foo` both produce cdata "foo". 85*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 86*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 87*309a0852SAndreas Gohr $this->P->parse(">foo\n"); 88*309a0852SAndreas Gohr 89*309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 90*309a0852SAndreas Gohr $this->assertContains('quote_open', $names); 91*309a0852SAndreas Gohr $this->assertContains('cdata', $names); 92*309a0852SAndreas Gohr } 93*309a0852SAndreas Gohr 94*309a0852SAndreas Gohr public function testDwTwoLinesEmitLinebreak() 95*309a0852SAndreas Gohr { 96*309a0852SAndreas Gohr // The DW-preferred post-pass converts the sub-parser's paragraph 97*309a0852SAndreas Gohr // wrapping into a linebreak between the two cdata calls, matching 98*309a0852SAndreas Gohr // the historical `<blockquote>foo<br/>bar</blockquote>` shape. 99*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 100*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 101*309a0852SAndreas Gohr $this->P->parse("> foo\n> bar\n"); 102*309a0852SAndreas Gohr 103*309a0852SAndreas Gohr $expected = [ 104*309a0852SAndreas Gohr ['document_start', []], 105*309a0852SAndreas Gohr ['quote_open', []], 106*309a0852SAndreas Gohr ['nest', [[ 107*309a0852SAndreas Gohr ['cdata', ['foo']], 108*309a0852SAndreas Gohr ['linebreak', []], 109*309a0852SAndreas Gohr ['cdata', ['bar']], 110*309a0852SAndreas Gohr ]]], 111*309a0852SAndreas Gohr ['quote_close', []], 112*309a0852SAndreas Gohr ['document_end', []], 113*309a0852SAndreas Gohr ]; 114*309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 115*309a0852SAndreas Gohr } 116*309a0852SAndreas Gohr 117*309a0852SAndreas Gohr public function testDwBlankMarkerLineEmitsTwoLinebreaks() 118*309a0852SAndreas Gohr { 119*309a0852SAndreas Gohr // `>` alone between content lines is a paragraph break in GFM. 120*309a0852SAndreas Gohr // The DW post-pass replaces each p_open and each p_close with a 121*309a0852SAndreas Gohr // linebreak, producing two adjacent linebreak calls between the 122*309a0852SAndreas Gohr // two content cdata — matches the historical DW two-`<br/>` shape. 123*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 124*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 125*309a0852SAndreas Gohr $this->P->parse("> foo\n>\n> bar\n"); 126*309a0852SAndreas Gohr 127*309a0852SAndreas Gohr $expected = [ 128*309a0852SAndreas Gohr ['document_start', []], 129*309a0852SAndreas Gohr ['quote_open', []], 130*309a0852SAndreas Gohr ['nest', [[ 131*309a0852SAndreas Gohr ['cdata', ['foo']], 132*309a0852SAndreas Gohr ['linebreak', []], 133*309a0852SAndreas Gohr ['linebreak', []], 134*309a0852SAndreas Gohr ['cdata', ['bar']], 135*309a0852SAndreas Gohr ]]], 136*309a0852SAndreas Gohr ['quote_close', []], 137*309a0852SAndreas Gohr ['document_end', []], 138*309a0852SAndreas Gohr ]; 139*309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 140*309a0852SAndreas Gohr } 141*309a0852SAndreas Gohr 142*309a0852SAndreas Gohr public function testDwNested() 143*309a0852SAndreas Gohr { 144*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 145*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 146*309a0852SAndreas Gohr $this->P->parse("> > foo\n"); 147*309a0852SAndreas Gohr 148*309a0852SAndreas Gohr // The outer captures a single line `> > foo`. Stripping the 149*309a0852SAndreas Gohr // outer marker leaves `> foo`, which the sub-parser feeds back 150*309a0852SAndreas Gohr // through GfmQuote — recursion produces a nested quote_open / 151*309a0852SAndreas Gohr // quote_close pair carrying the cdata. 152*309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 153*309a0852SAndreas Gohr $opens = array_filter($names, static fn($n) => $n === 'quote_open'); 154*309a0852SAndreas Gohr $closes = array_filter($names, static fn($n) => $n === 'quote_close'); 155*309a0852SAndreas Gohr $this->assertCount(2, $opens, 'two levels of quote_open expected'); 156*309a0852SAndreas Gohr $this->assertCount(2, $closes, 'two levels of quote_close expected'); 157*309a0852SAndreas Gohr } 158*309a0852SAndreas Gohr 159*309a0852SAndreas Gohr public function testDwNoLazyContinuation() 160*309a0852SAndreas Gohr { 161*309a0852SAndreas Gohr // GfmQuote does not implement lazy continuation: every quote 162*309a0852SAndreas Gohr // line must begin with `>`. `bar` without a `>` prefix terminates 163*309a0852SAndreas Gohr // the quote, so it ends up as a separate paragraph — matching 164*309a0852SAndreas Gohr // today's DW behavior. 165*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 166*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 167*309a0852SAndreas Gohr $this->P->parse("> foo\nbar\n"); 168*309a0852SAndreas Gohr 169*309a0852SAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 170*309a0852SAndreas Gohr $this->assertCount(1, $opens, 'quote opens once and stops at the non-`>` line'); 171*309a0852SAndreas Gohr 172*309a0852SAndreas Gohr // `bar` is outside the quote — find a top-level cdata after the close 173*309a0852SAndreas Gohr $afterClose = false; 174*309a0852SAndreas Gohr $sawBarOutside = false; 175*309a0852SAndreas Gohr foreach ($this->H->calls as $call) { 176*309a0852SAndreas Gohr if ($call[0] === 'quote_close') $afterClose = true; 177*309a0852SAndreas Gohr if ($afterClose && $call[0] === 'cdata' && str_contains($call[1][0], 'bar')) { 178*309a0852SAndreas Gohr $sawBarOutside = true; 179*309a0852SAndreas Gohr } 180*309a0852SAndreas Gohr } 181*309a0852SAndreas Gohr $this->assertTrue($sawBarOutside, '`bar` must appear as cdata outside the quote'); 182*309a0852SAndreas Gohr } 183*309a0852SAndreas Gohr 184*309a0852SAndreas Gohr public function testDwBlankLineSeparatesQuotes() 185*309a0852SAndreas Gohr { 186*309a0852SAndreas Gohr // A truly blank line ends the quote. The next `>` starts a new 187*309a0852SAndreas Gohr // quote, producing two distinct quote_open / quote_close pairs. 188*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 189*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 190*309a0852SAndreas Gohr $this->P->parse("> foo\n\n> bar\n"); 191*309a0852SAndreas Gohr 192*309a0852SAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 193*309a0852SAndreas Gohr $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_close'); 194*309a0852SAndreas Gohr $this->assertCount(2, $opens, 'two distinct quote blocks'); 195*309a0852SAndreas Gohr $this->assertCount(2, $closes); 196*309a0852SAndreas Gohr } 197*309a0852SAndreas Gohr 198*309a0852SAndreas Gohr public function testDwHeaderInsideQuoteStaysCdata() 199*309a0852SAndreas Gohr { 200*309a0852SAndreas Gohr // Sub-parser excludes BASEONLY (Header / GfmHeader). Header 201*309a0852SAndreas Gohr // instructions drive section-edit anchors and TOC entries that 202*309a0852SAndreas Gohr // do not compose with `<blockquote>`. `# Foo` therefore stays 203*309a0852SAndreas Gohr // as plain cdata text. 204*309a0852SAndreas Gohr $this->setSyntax('dokuwiki'); 205*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 206*309a0852SAndreas Gohr $this->P->parse("> # Foo\n"); 207*309a0852SAndreas Gohr 208*309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 209*309a0852SAndreas Gohr $this->assertNotContains('header', $names); 210*309a0852SAndreas Gohr $this->assertNotContains('section_open', $names); 211*309a0852SAndreas Gohr $this->assertContains('cdata', $names); 212*309a0852SAndreas Gohr } 213*309a0852SAndreas Gohr 214*309a0852SAndreas Gohr // ----- MD-preferred rendering: paragraph wrapping survives ------------ 215*309a0852SAndreas Gohr 216*309a0852SAndreas Gohr public function testMdSingleParagraph() 217*309a0852SAndreas Gohr { 218*309a0852SAndreas Gohr $this->setSyntax('markdown'); 219*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 220*309a0852SAndreas Gohr $this->P->parse("> foo\n> bar\n"); 221*309a0852SAndreas Gohr 222*309a0852SAndreas Gohr // Sub-parser wraps the body in `p_open` / `p_close`. The outer 223*309a0852SAndreas Gohr // wraps them inside a `nest`, and Block treats the nest as 224*309a0852SAndreas Gohr // opaque. Two `>`-content lines join into one paragraph. 225*309a0852SAndreas Gohr $expected = [ 226*309a0852SAndreas Gohr ['document_start', []], 227*309a0852SAndreas Gohr ['quote_open', []], 228*309a0852SAndreas Gohr ['nest', [[ 229*309a0852SAndreas Gohr ['p_open', []], 230*309a0852SAndreas Gohr ['cdata', ["foo\nbar"]], 231*309a0852SAndreas Gohr ['p_close', []], 232*309a0852SAndreas Gohr ]]], 233*309a0852SAndreas Gohr ['quote_close', []], 234*309a0852SAndreas Gohr ['document_end', []], 235*309a0852SAndreas Gohr ]; 236*309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 237*309a0852SAndreas Gohr } 238*309a0852SAndreas Gohr 239*309a0852SAndreas Gohr public function testMdMultiParagraph() 240*309a0852SAndreas Gohr { 241*309a0852SAndreas Gohr // `>` alone between content lines creates two paragraphs in one 242*309a0852SAndreas Gohr // blockquote — under MD-preferred the post-pass does not run, so 243*309a0852SAndreas Gohr // the sub-parser's `p_open` / `p_close` pairs survive intact. 244*309a0852SAndreas Gohr $this->setSyntax('markdown'); 245*309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 246*309a0852SAndreas Gohr $this->P->parse("> foo\n>\n> bar\n"); 247*309a0852SAndreas Gohr 248*309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 249*309a0852SAndreas Gohr $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 250*309a0852SAndreas Gohr $pCloses = array_filter($names, static fn($n) => $n === 'p_close'); 251*309a0852SAndreas Gohr $this->assertCount(2, $pOpens, 'two paragraphs inside one blockquote'); 252*309a0852SAndreas Gohr $this->assertCount(2, $pCloses); 253*309a0852SAndreas Gohr } 254*309a0852SAndreas Gohr 255*309a0852SAndreas Gohr public function testMdListInsideQuote() 256*309a0852SAndreas Gohr { 257*309a0852SAndreas Gohr // GfmListblock is loaded under MD-preferred syntax, so a list 258*309a0852SAndreas Gohr // inside a quote parses as a real list. The sub-parser's list 259*309a0852SAndreas Gohr // calls land inside the outer `nest` wrapper. 260*309a0852SAndreas Gohr $this->setSyntax('markdown'); 261*309a0852SAndreas Gohr ModeRegistry::reset(); 262*309a0852SAndreas Gohr // Add the registry's full mode set so gfm_listblock is reachable 263*309a0852SAndreas Gohr // via the sub-parser (the sub-parser uses ModeRegistry::getModes, 264*309a0852SAndreas Gohr // which honors $conf['syntax']). 265*309a0852SAndreas Gohr foreach (ModeRegistry::getInstance()->getModes() as $m) { 266*309a0852SAndreas Gohr $this->P->addMode($m['mode'], $m['obj']); 267*309a0852SAndreas Gohr } 268*309a0852SAndreas Gohr 269*309a0852SAndreas Gohr $this->P->parse("> - foo\n> - bar\n"); 270*309a0852SAndreas Gohr 271*309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 272*309a0852SAndreas Gohr $this->assertContains('quote_open', $names); 273*309a0852SAndreas Gohr $this->assertContains('listu_open', $names, 'list inside quote must parse'); 274*309a0852SAndreas Gohr $this->assertContains('listu_close', $names); 275*309a0852SAndreas Gohr $this->assertContains('quote_close', $names); 276*309a0852SAndreas Gohr } 277*309a0852SAndreas Gohr} 278