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