xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmQuoteTest.php (revision 13a62f810fbd091d15ab734b467eaec0a6bf829a)
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('dw');
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('dw');
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('dw');
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('dw');
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('dw');
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('dw');
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('dw');
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('dw');
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('md');
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('md');
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('md');
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