xref: /dokuwiki/inc/Parsing/ParserMode/GfmQuote.php (revision 6a8e48eda246f872402bf5b85763f276cd4c319d)
1<?php
2
3namespace dokuwiki\Parsing\ParserMode;
4
5use dokuwiki\Parsing\Handler;
6use dokuwiki\Parsing\Handler\Nest;
7use dokuwiki\Parsing\ModeRegistry;
8
9/**
10 * Block quotes — single mode covering both DokuWiki and GFM dialects.
11 *
12 * Captures one or more consecutive column-0 `>`-prefixed lines via
13 * addSpecialPattern. Nesting is resolved at this level by counting
14 * leading `>` markers per line and emitting `quote_open` / `quote_close`
15 * pairs around per-depth body segments — sub-parser recursion is
16 * deliberately not used because each sub-parser invocation needs its
17 * own Handler instance and threading the nesting through the registry
18 * pool would only buy us back what depth-walking already provides.
19 *
20 * Each per-depth segment's body is sub-parsed via
21 * ModeRegistry::withSubParser() so block content (lists, fenced code,
22 * tables) works inside the body. The sub-parser excludes BASEONLY so
23 * headers do not fire inside a blockquote — same rationale as
24 * GfmListblock: header instructions drive TOC entries, section-edit
25 * anchors, and section_open/section_close ranges that don't compose
26 * with a `<blockquote>` container. The sub-parser also excludes
27 * gfm_quote itself; nesting is handled at this level, not via
28 * sub-parser recursion. When a list inside a quote re-fires gfm_quote
29 * during the list-item sub-parse, the registry's pool hands the
30 * inner call a different parser instance for the same exclusion key,
31 * so the outer parse state is not corrupted.
32 *
33 * Lazy continuation is deliberately not supported. Every quote line
34 * must begin with `>` at column 0; the first non-`>` line ends the
35 * quote. This matches the policy GfmListblock enforces for lists —
36 * markers required on every line. Trade-off: a few CommonMark
37 * blockquote spec examples that rely on lazy continuation stay red,
38 * but the parser stays single-pass and predictable.
39 *
40 * Rendering shape depends on syntax preference. Under MD-preferred
41 * (`md`, `md+dw`) the sub-parser's paragraph wrapping survives:
42 * a quote with one paragraph emits `<blockquote><p>...</p></blockquote>`.
43 * Under DW-preferred (`dw`, `dw+md`) a post-pass flattens
44 * paragraph wrapping into explicit `linebreak` calls so existing DW
45 * pages keep their `<blockquote>...line1<br/>line2...</blockquote>`
46 * rendering. Same `quote_open` / `quote_close` instructions in both
47 * modes — no renderer change required.
48 */
49class GfmQuote extends AbstractMode
50{
51    /** @inheritdoc */
52    public function getSort()
53    {
54        return 220;
55    }
56
57    /** @inheritdoc */
58    public function preConnect()
59    {
60        $this->registry->registerBlockEolMode('gfm_quote');
61    }
62
63    /**
64     * Capture an entire blockquote in one match.
65     *
66     * The pattern requires a column-0 `>` on every line. The first
67     * non-`>` line ends the capture (no lazy continuation). A bare `>`
68     * with no body is valid — it represents an empty paragraph break
69     * inside the quote (spec 240) or an empty quote (spec 239).
70     *
71     * The first line uses (?:^|\n)> rather than \n> so the blockquote
72     * can take over when a preceding block mode (a table or a list)
73     * consumed the boundary \n on its way out. Those modes exit on a
74     * consuming \n, so at the boundary the \n has already left the
75     * stream and a plain \n> opener would have nothing to anchor on.
76     * Accepting either a literal \n or a line start (^ in PCRE
77     * multiline mode, which also matches the position immediately
78     * after a consumed \n) lets the blockquote start regardless.
79     * Subsequent quote lines still anchor on \n> because the previous
80     * line consumed up to but not including the \n, so it is always
81     * available for them.
82     *
83     * @param string $mode the lexer state name to wire the pattern into
84     */
85    public function connectTo($mode)
86    {
87        $this->Lexer->addSpecialPattern('(?:^|\n)>[^\n]*(?:\n>[^\n]*)*', $mode, 'gfm_quote');
88    }
89
90    /** @inheritdoc */
91    public function handle($match, $state, $pos, Handler $handler)
92    {
93        $stripped = ltrim($match, "\n");
94        $cursor = strlen($match) - strlen($stripped);
95
96        $parsed = [];
97        foreach (explode("\n", $stripped) as $line) {
98            $parsed[] = $this->parseLine($line, $pos + $cursor);
99            $cursor += strlen($line) + 1; // +1 for the \n consumed by explode
100        }
101
102        $currentDepth = 0;
103        $buffer = [];
104        $segmentStart = $pos;
105
106        foreach ($parsed as $p) {
107            if ($p['depth'] !== $currentDepth) {
108                if ($buffer) {
109                    $this->emitBody($handler, $segmentStart, implode("\n", $buffer));
110                    $buffer = [];
111                }
112                while ($currentDepth < $p['depth']) {
113                    $handler->addCall('quote_open', [], $pos);
114                    $currentDepth++;
115                }
116                while ($currentDepth > $p['depth']) {
117                    $handler->addCall('quote_close', [], $pos);
118                    $currentDepth--;
119                }
120            }
121            if (!$buffer) $segmentStart = $p['offset'];
122            $buffer[] = $p['content'];
123        }
124
125        if ($buffer) {
126            $this->emitBody($handler, $segmentStart, implode("\n", $buffer));
127        }
128        while ($currentDepth > 0) {
129            $handler->addCall('quote_close', [], $pos + strlen($match));
130            $currentDepth--;
131        }
132
133        return true;
134    }
135
136    /**
137     * Parse one captured line into depth, content, and content offset.
138     *
139     * Counts leading `>` characters (each consuming one optional
140     * trailing space) to compute the depth. The remainder of the line
141     * is the content for that depth. The returned `offset` is the
142     * absolute byte position of the content's first character within
143     * the source (`$lineStart` plus the length of the consumed marker
144     * prefix).
145     *
146     * `> > foo` → depth 2, content `foo`. `>>foo` → depth 2, content
147     * `foo`. `>` alone → depth 1, content empty.
148     *
149     * @param string $line one line of captured blockquote text, with
150     *     no surrounding newlines
151     * @param int $lineStart absolute byte offset of the line's first
152     *     character within the source
153     * @return array{depth: int, content: string, offset: int}
154     */
155    protected function parseLine(string $line, int $lineStart): array
156    {
157        $depth = 0;
158        $i = 0;
159        $len = strlen($line);
160        while ($i < $len && $line[$i] === '>') {
161            $depth++;
162            $i++;
163            if ($i < $len && $line[$i] === ' ') $i++;
164        }
165        return [
166            'depth'   => $depth,
167            'content' => substr($line, $i),
168            'offset'  => $lineStart + $i,
169        ];
170    }
171
172    /**
173     * Sub-parse a body segment and emit its calls inside a Nest.
174     *
175     * Drops `document_start` / `document_end` from the sub-parser
176     * output. Under DW-preferred syntax, also runs the linebreak
177     * post-pass so paragraph wrapping is flattened into explicit
178     * `linebreak` calls. Empty bodies emit nothing.
179     *
180     * `$segmentStart` is the absolute byte offset of the segment's
181     * first content character within the source. Sub-handler positions
182     * are relative to the sub-parsed body, which begins at the first
183     * line of the segment, so adding `$segmentStart` to each
184     * sub-handler position lands the call back on the right byte in
185     * the source. Lines after the first drift slightly because the
186     * `>[ ]?` prefix between source lines collapses to a single `\n`
187     * in the sub-parsed body — drift is bounded by the prefix length
188     * (one or two bytes per line skipped).
189     *
190     * @param Handler $handler outer handler to emit calls on
191     * @param int $segmentStart absolute byte offset of the segment's
192     *     first content character within the source
193     * @param string $body concatenated content of the buffered lines,
194     *     separated by `\n`
195     */
196    protected function emitBody(Handler $handler, int $segmentStart, string $body): void
197    {
198        $registry = $this->registry;
199        $calls = $registry->withSubParser(
200            [ModeRegistry::CATEGORY_BASEONLY],
201            ['gfm_quote'],
202            static function ($subParser) use ($body) {
203                $subParser->getHandler()->reset();
204                $subParser->parse($body);
205                return $subParser->getHandler()->calls;
206            }
207        );
208
209        if ($calls && $calls[0][0] === 'document_start') array_shift($calls);
210        if ($calls && end($calls)[0] === 'document_end') array_pop($calls);
211
212        if ($registry->isDwPreferred()) {
213            $calls = $this->flattenForDwRendering($calls);
214        }
215
216        if (!$calls) return;
217
218        $outer = $handler->getCallWriter();
219        $nest = new Nest($outer);
220        $handler->setCallWriter($nest);
221        foreach ($calls as $call) {
222            $handler->addCall($call[0], $call[1], $segmentStart + $call[2]);
223        }
224        $handler->setCallWriter($nest->process());
225    }
226
227    /**
228     * Flatten paragraph structure into linebreak-separated cdata.
229     *
230     * DW Quote historically rendered each `>`-line as a separate visible
231     * line via an explicit `<br/>` between same-depth markers. To
232     * preserve that rendering for DW-preferred installs, this pass:
233     *
234     *   1. Replaces every `p_open` and `p_close` with a `linebreak`
235     *      call. After this, paragraph boundaries become two adjacent
236     *      linebreaks (the close-of-prev plus the open-of-next), which
237     *      matches the DW two-`<br/>`-for-blank-line shape.
238     *   2. Drops the first and last `linebreak` calls so the run starts
239     *      and ends with content, not break markers.
240     *   3. Splits any `cdata` containing `\n` into multiple `cdata`
241     *      calls separated by `linebreak` — sub-parsed paragraphs may
242     *      contain soft breaks that a renderer would otherwise collapse
243     *      to a single space.
244     *
245     * Block-level calls inside the body (list_open from a list inside
246     * a quote, code, etc.) are passed through unchanged.
247     *
248     * @param array $calls sub-parsed call list to flatten
249     * @return array the flattened call list
250     */
251    protected function flattenForDwRendering(array $calls): array
252    {
253        $stage = [];
254        foreach ($calls as $call) {
255            if ($call[0] === 'p_open' || $call[0] === 'p_close') {
256                $stage[] = ['linebreak', [], $call[2]];
257            } else {
258                $stage[] = $call;
259            }
260        }
261
262        while ($stage && $stage[0][0] === 'linebreak') array_shift($stage);
263        while ($stage && end($stage)[0] === 'linebreak') array_pop($stage);
264
265        $out = [];
266        foreach ($stage as $call) {
267            if ($call[0] === 'cdata' && str_contains($call[1][0], "\n")) {
268                $parts = explode("\n", $call[1][0]);
269                foreach ($parts as $i => $part) {
270                    if ($i > 0) $out[] = ['linebreak', [], $call[2]];
271                    if ($part !== '') $out[] = ['cdata', [$part], $call[2]];
272                }
273            } else {
274                $out[] = $call;
275            }
276        }
277
278        return $out;
279    }
280}
281