1309a0852SAndreas Gohr<?php 2309a0852SAndreas Gohr 3309a0852SAndreas Gohrnamespace dokuwiki\test\Parsing\ParserMode; 4309a0852SAndreas Gohr 5309a0852SAndreas Gohruse dokuwiki\Parsing\ModeRegistry; 6309a0852SAndreas Gohruse dokuwiki\Parsing\ParserMode\GfmQuote; 7309a0852SAndreas Gohr 8309a0852SAndreas Gohr/** 9309a0852SAndreas Gohr * Tests for GFM-style block quotes. 10309a0852SAndreas Gohr * 11309a0852SAndreas Gohr * GfmQuote is the unified blockquote implementation covering both DW and 12309a0852SAndreas Gohr * GFM dialects. The mode captures the entire quote via addSpecialPattern 13309a0852SAndreas Gohr * and sub-parses the stripped body, so the outer parser only needs 14309a0852SAndreas Gohr * gfm_quote attached; inline modes and block modes (lists, code blocks, 15309a0852SAndreas Gohr * nested quotes) are picked up by the sub-parser. 16309a0852SAndreas Gohr * 17309a0852SAndreas Gohr * Two rendering shapes are exercised. Under DW-preferred syntax, a 18309a0852SAndreas Gohr * post-pass flattens the sub-parser's paragraph wrapping into linebreak- 19309a0852SAndreas Gohr * separated cdata so existing DW pages keep their `<br/>`-between-lines 20309a0852SAndreas Gohr * rendering. Under MD-preferred syntax the sub-parser's paragraph 21309a0852SAndreas Gohr * wrapping survives — a quote with one paragraph emits 22309a0852SAndreas Gohr * `<blockquote><p>...</p></blockquote>`. 23309a0852SAndreas Gohr */ 24309a0852SAndreas Gohrclass GfmQuoteTest extends ParserTestBase 25309a0852SAndreas Gohr{ 26309a0852SAndreas Gohr public function tearDown(): void 27309a0852SAndreas Gohr { 28309a0852SAndreas Gohr ModeRegistry::reset(); 29309a0852SAndreas Gohr parent::tearDown(); 30309a0852SAndreas Gohr } 31309a0852SAndreas Gohr 32309a0852SAndreas Gohr private function setSyntax(string $syntax): void 33309a0852SAndreas Gohr { 34309a0852SAndreas Gohr global $conf; 35309a0852SAndreas Gohr $conf['syntax'] = $syntax; 36309a0852SAndreas Gohr ModeRegistry::reset(); 37309a0852SAndreas Gohr } 38309a0852SAndreas Gohr 39309a0852SAndreas Gohr /** 40309a0852SAndreas Gohr * Recursively flatten call lists, descending into `nest` content. 41309a0852SAndreas Gohr * Useful for tests that just check whether an instruction appears 42309a0852SAndreas Gohr * somewhere in the rendered output regardless of nesting depth. 43309a0852SAndreas Gohr */ 44309a0852SAndreas Gohr private function flatNames(array $calls): array 45309a0852SAndreas Gohr { 46309a0852SAndreas Gohr $names = []; 47309a0852SAndreas Gohr foreach ($calls as $call) { 48309a0852SAndreas Gohr $names[] = $call[0]; 49309a0852SAndreas Gohr if ($call[0] === 'nest') { 50309a0852SAndreas Gohr $names = array_merge($names, $this->flatNames($call[1][0])); 51309a0852SAndreas Gohr } 52309a0852SAndreas Gohr } 53309a0852SAndreas Gohr return $names; 54309a0852SAndreas Gohr } 55309a0852SAndreas Gohr 56309a0852SAndreas Gohr public function testSortValue() 57309a0852SAndreas Gohr { 58309a0852SAndreas Gohr $mode = new GfmQuote(); 59309a0852SAndreas Gohr $this->assertSame(220, $mode->getSort()); 60309a0852SAndreas Gohr } 61309a0852SAndreas Gohr 62309a0852SAndreas Gohr // ----- DW-preferred rendering: linebreak-separated, no <p> ------------ 63309a0852SAndreas Gohr 64309a0852SAndreas Gohr public function testDwSingleLine() 65309a0852SAndreas Gohr { 66*13a62f81SAndreas Gohr $this->setSyntax('dw'); 67309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 68309a0852SAndreas Gohr $this->P->parse("> foo\n"); 69309a0852SAndreas Gohr 70309a0852SAndreas Gohr $expected = [ 71309a0852SAndreas Gohr ['document_start', []], 72309a0852SAndreas Gohr ['quote_open', []], 73309a0852SAndreas Gohr ['nest', [[ ['cdata', ['foo']] ]]], 74309a0852SAndreas Gohr ['quote_close', []], 75309a0852SAndreas Gohr ['document_end', []], 76309a0852SAndreas Gohr ]; 77309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 78309a0852SAndreas Gohr } 79309a0852SAndreas Gohr 80309a0852SAndreas Gohr public function testDwSpaceAfterMarkerOptional() 81309a0852SAndreas Gohr { 82309a0852SAndreas Gohr // GFM allows omitting the space after `>`; DW always did. Strip 83309a0852SAndreas Gohr // logic removes one optional space after the `>`, so `>foo` and 84309a0852SAndreas Gohr // `> foo` both produce cdata "foo". 85*13a62f81SAndreas Gohr $this->setSyntax('dw'); 86309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 87309a0852SAndreas Gohr $this->P->parse(">foo\n"); 88309a0852SAndreas Gohr 89309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 90309a0852SAndreas Gohr $this->assertContains('quote_open', $names); 91309a0852SAndreas Gohr $this->assertContains('cdata', $names); 92309a0852SAndreas Gohr } 93309a0852SAndreas Gohr 94309a0852SAndreas Gohr public function testDwTwoLinesEmitLinebreak() 95309a0852SAndreas Gohr { 96309a0852SAndreas Gohr // The DW-preferred post-pass converts the sub-parser's paragraph 97309a0852SAndreas Gohr // wrapping into a linebreak between the two cdata calls, matching 98309a0852SAndreas Gohr // the historical `<blockquote>foo<br/>bar</blockquote>` shape. 99*13a62f81SAndreas Gohr $this->setSyntax('dw'); 100309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 101309a0852SAndreas Gohr $this->P->parse("> foo\n> bar\n"); 102309a0852SAndreas Gohr 103309a0852SAndreas Gohr $expected = [ 104309a0852SAndreas Gohr ['document_start', []], 105309a0852SAndreas Gohr ['quote_open', []], 106309a0852SAndreas Gohr ['nest', [[ 107309a0852SAndreas Gohr ['cdata', ['foo']], 108309a0852SAndreas Gohr ['linebreak', []], 109309a0852SAndreas Gohr ['cdata', ['bar']], 110309a0852SAndreas Gohr ]]], 111309a0852SAndreas Gohr ['quote_close', []], 112309a0852SAndreas Gohr ['document_end', []], 113309a0852SAndreas Gohr ]; 114309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 115309a0852SAndreas Gohr } 116309a0852SAndreas Gohr 117309a0852SAndreas Gohr public function testDwBlankMarkerLineEmitsTwoLinebreaks() 118309a0852SAndreas Gohr { 119309a0852SAndreas Gohr // `>` alone between content lines is a paragraph break in GFM. 120309a0852SAndreas Gohr // The DW post-pass replaces each p_open and each p_close with a 121309a0852SAndreas Gohr // linebreak, producing two adjacent linebreak calls between the 122309a0852SAndreas Gohr // two content cdata — matches the historical DW two-`<br/>` shape. 123*13a62f81SAndreas Gohr $this->setSyntax('dw'); 124309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 125309a0852SAndreas Gohr $this->P->parse("> foo\n>\n> bar\n"); 126309a0852SAndreas Gohr 127309a0852SAndreas Gohr $expected = [ 128309a0852SAndreas Gohr ['document_start', []], 129309a0852SAndreas Gohr ['quote_open', []], 130309a0852SAndreas Gohr ['nest', [[ 131309a0852SAndreas Gohr ['cdata', ['foo']], 132309a0852SAndreas Gohr ['linebreak', []], 133309a0852SAndreas Gohr ['linebreak', []], 134309a0852SAndreas Gohr ['cdata', ['bar']], 135309a0852SAndreas Gohr ]]], 136309a0852SAndreas Gohr ['quote_close', []], 137309a0852SAndreas Gohr ['document_end', []], 138309a0852SAndreas Gohr ]; 139309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 140309a0852SAndreas Gohr } 141309a0852SAndreas Gohr 142309a0852SAndreas Gohr public function testDwNested() 143309a0852SAndreas Gohr { 144*13a62f81SAndreas Gohr $this->setSyntax('dw'); 145309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 146309a0852SAndreas Gohr $this->P->parse("> > foo\n"); 147309a0852SAndreas Gohr 148309a0852SAndreas Gohr // The outer captures a single line `> > foo`. Stripping the 149309a0852SAndreas Gohr // outer marker leaves `> foo`, which the sub-parser feeds back 150309a0852SAndreas Gohr // through GfmQuote — recursion produces a nested quote_open / 151309a0852SAndreas Gohr // quote_close pair carrying the cdata. 152309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 153309a0852SAndreas Gohr $opens = array_filter($names, static fn($n) => $n === 'quote_open'); 154309a0852SAndreas Gohr $closes = array_filter($names, static fn($n) => $n === 'quote_close'); 155309a0852SAndreas Gohr $this->assertCount(2, $opens, 'two levels of quote_open expected'); 156309a0852SAndreas Gohr $this->assertCount(2, $closes, 'two levels of quote_close expected'); 157309a0852SAndreas Gohr } 158309a0852SAndreas Gohr 159309a0852SAndreas Gohr public function testDwNoLazyContinuation() 160309a0852SAndreas Gohr { 161309a0852SAndreas Gohr // GfmQuote does not implement lazy continuation: every quote 162309a0852SAndreas Gohr // line must begin with `>`. `bar` without a `>` prefix terminates 163309a0852SAndreas Gohr // the quote, so it ends up as a separate paragraph — matching 164309a0852SAndreas Gohr // today's DW behavior. 165*13a62f81SAndreas Gohr $this->setSyntax('dw'); 166309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 167309a0852SAndreas Gohr $this->P->parse("> foo\nbar\n"); 168309a0852SAndreas Gohr 169309a0852SAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 170309a0852SAndreas Gohr $this->assertCount(1, $opens, 'quote opens once and stops at the non-`>` line'); 171309a0852SAndreas Gohr 172309a0852SAndreas Gohr // `bar` is outside the quote — find a top-level cdata after the close 173309a0852SAndreas Gohr $afterClose = false; 174309a0852SAndreas Gohr $sawBarOutside = false; 175309a0852SAndreas Gohr foreach ($this->H->calls as $call) { 176309a0852SAndreas Gohr if ($call[0] === 'quote_close') $afterClose = true; 177309a0852SAndreas Gohr if ($afterClose && $call[0] === 'cdata' && str_contains($call[1][0], 'bar')) { 178309a0852SAndreas Gohr $sawBarOutside = true; 179309a0852SAndreas Gohr } 180309a0852SAndreas Gohr } 181309a0852SAndreas Gohr $this->assertTrue($sawBarOutside, '`bar` must appear as cdata outside the quote'); 182309a0852SAndreas Gohr } 183309a0852SAndreas Gohr 184309a0852SAndreas Gohr public function testDwBlankLineSeparatesQuotes() 185309a0852SAndreas Gohr { 186309a0852SAndreas Gohr // A truly blank line ends the quote. The next `>` starts a new 187309a0852SAndreas Gohr // quote, producing two distinct quote_open / quote_close pairs. 188*13a62f81SAndreas Gohr $this->setSyntax('dw'); 189309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 190309a0852SAndreas Gohr $this->P->parse("> foo\n\n> bar\n"); 191309a0852SAndreas Gohr 192309a0852SAndreas Gohr $opens = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_open'); 193309a0852SAndreas Gohr $closes = array_filter($this->H->calls, static fn($c) => $c[0] === 'quote_close'); 194309a0852SAndreas Gohr $this->assertCount(2, $opens, 'two distinct quote blocks'); 195309a0852SAndreas Gohr $this->assertCount(2, $closes); 196309a0852SAndreas Gohr } 197309a0852SAndreas Gohr 198309a0852SAndreas Gohr public function testDwHeaderInsideQuoteStaysCdata() 199309a0852SAndreas Gohr { 200309a0852SAndreas Gohr // Sub-parser excludes BASEONLY (Header / GfmHeader). Header 201309a0852SAndreas Gohr // instructions drive section-edit anchors and TOC entries that 202309a0852SAndreas Gohr // do not compose with `<blockquote>`. `# Foo` therefore stays 203309a0852SAndreas Gohr // as plain cdata text. 204*13a62f81SAndreas Gohr $this->setSyntax('dw'); 205309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 206309a0852SAndreas Gohr $this->P->parse("> # Foo\n"); 207309a0852SAndreas Gohr 208309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 209309a0852SAndreas Gohr $this->assertNotContains('header', $names); 210309a0852SAndreas Gohr $this->assertNotContains('section_open', $names); 211309a0852SAndreas Gohr $this->assertContains('cdata', $names); 212309a0852SAndreas Gohr } 213309a0852SAndreas Gohr 214309a0852SAndreas Gohr // ----- MD-preferred rendering: paragraph wrapping survives ------------ 215309a0852SAndreas Gohr 216309a0852SAndreas Gohr public function testMdSingleParagraph() 217309a0852SAndreas Gohr { 218*13a62f81SAndreas Gohr $this->setSyntax('md'); 219309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 220309a0852SAndreas Gohr $this->P->parse("> foo\n> bar\n"); 221309a0852SAndreas Gohr 222309a0852SAndreas Gohr // Sub-parser wraps the body in `p_open` / `p_close`. The outer 223309a0852SAndreas Gohr // wraps them inside a `nest`, and Block treats the nest as 224309a0852SAndreas Gohr // opaque. Two `>`-content lines join into one paragraph. 225309a0852SAndreas Gohr $expected = [ 226309a0852SAndreas Gohr ['document_start', []], 227309a0852SAndreas Gohr ['quote_open', []], 228309a0852SAndreas Gohr ['nest', [[ 229309a0852SAndreas Gohr ['p_open', []], 230309a0852SAndreas Gohr ['cdata', ["foo\nbar"]], 231309a0852SAndreas Gohr ['p_close', []], 232309a0852SAndreas Gohr ]]], 233309a0852SAndreas Gohr ['quote_close', []], 234309a0852SAndreas Gohr ['document_end', []], 235309a0852SAndreas Gohr ]; 236309a0852SAndreas Gohr $this->assertCalls($expected, $this->H->calls); 237309a0852SAndreas Gohr } 238309a0852SAndreas Gohr 239309a0852SAndreas Gohr public function testMdMultiParagraph() 240309a0852SAndreas Gohr { 241309a0852SAndreas Gohr // `>` alone between content lines creates two paragraphs in one 242309a0852SAndreas Gohr // blockquote — under MD-preferred the post-pass does not run, so 243309a0852SAndreas Gohr // the sub-parser's `p_open` / `p_close` pairs survive intact. 244*13a62f81SAndreas Gohr $this->setSyntax('md'); 245309a0852SAndreas Gohr $this->P->addMode('gfm_quote', new GfmQuote()); 246309a0852SAndreas Gohr $this->P->parse("> foo\n>\n> bar\n"); 247309a0852SAndreas Gohr 248309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 249309a0852SAndreas Gohr $pOpens = array_filter($names, static fn($n) => $n === 'p_open'); 250309a0852SAndreas Gohr $pCloses = array_filter($names, static fn($n) => $n === 'p_close'); 251309a0852SAndreas Gohr $this->assertCount(2, $pOpens, 'two paragraphs inside one blockquote'); 252309a0852SAndreas Gohr $this->assertCount(2, $pCloses); 253309a0852SAndreas Gohr } 254309a0852SAndreas Gohr 255309a0852SAndreas Gohr public function testMdListInsideQuote() 256309a0852SAndreas Gohr { 257309a0852SAndreas Gohr // GfmListblock is loaded under MD-preferred syntax, so a list 258309a0852SAndreas Gohr // inside a quote parses as a real list. The sub-parser's list 259309a0852SAndreas Gohr // calls land inside the outer `nest` wrapper. 260*13a62f81SAndreas Gohr $this->setSyntax('md'); 261309a0852SAndreas Gohr ModeRegistry::reset(); 262309a0852SAndreas Gohr // Add the registry's full mode set so gfm_listblock is reachable 263309a0852SAndreas Gohr // via the sub-parser (the sub-parser uses ModeRegistry::getModes, 264309a0852SAndreas Gohr // which honors $conf['syntax']). 265309a0852SAndreas Gohr foreach (ModeRegistry::getInstance()->getModes() as $m) { 266309a0852SAndreas Gohr $this->P->addMode($m['mode'], $m['obj']); 267309a0852SAndreas Gohr } 268309a0852SAndreas Gohr 269309a0852SAndreas Gohr $this->P->parse("> - foo\n> - bar\n"); 270309a0852SAndreas Gohr 271309a0852SAndreas Gohr $names = $this->flatNames($this->H->calls); 272309a0852SAndreas Gohr $this->assertContains('quote_open', $names); 273309a0852SAndreas Gohr $this->assertContains('listu_open', $names, 'list inside quote must parse'); 274309a0852SAndreas Gohr $this->assertContains('listu_close', $names); 275309a0852SAndreas Gohr $this->assertContains('quote_close', $names); 276309a0852SAndreas Gohr } 277309a0852SAndreas Gohr} 278