xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmQuoteTest.php (revision 309a08521b24a6fff00f318e061096f69771bbad)
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