xref: /plugin/mdimport/MarkdownToDokuWiki.php (revision 7cb424c90c7e1aca7edae5f79baaec2e55306143)
1*7cb424c9SSioc de Narf<?php
2*7cb424c9SSioc de Narf
3*7cb424c9SSioc de Narfdeclare(strict_types=1);
4*7cb424c9SSioc de Narf
5*7cb424c9SSioc de Narf/**
6*7cb424c9SSioc de Narf * Converts Markdown content to DokuWiki syntax.
7*7cb424c9SSioc de Narf *
8*7cb424c9SSioc de Narf * This class processes Markdown line by line, maintaining state for
9*7cb424c9SSioc de Narf * code blocks, tables, lists (with nesting), and paragraphs. It supports:
10*7cb424c9SSioc de Narf * - Headers (levels 1-6)
11*7cb424c9SSioc de Narf * - Bold, italic, inline code
12*7cb424c9SSioc de Narf * - Links and images
13*7cb424c9SSioc de Narf * - Unordered and ordered lists (with indentation)
14*7cb424c9SSioc de Narf * - Tables (with alignment detection for headers)
15*7cb424c9SSioc de Narf * - Code blocks (```)
16*7cb424c9SSioc de Narf * - Blockquotes (simple)
17*7cb424c9SSioc de Narf * - Horizontal rules
18*7cb424c9SSioc de Narf *
19*7cb424c9SSioc de Narf * @license GPL 3 http://www.gnu.org/licenses/gpl-3.0.html
20*7cb424c9SSioc de Narf * @author  sioc-de-narf
21*7cb424c9SSioc de Narf */
22*7cb424c9SSioc de Narfclass MarkdownToDokuWikiConverter
23*7cb424c9SSioc de Narf{
24*7cb424c9SSioc de Narf    /** @var bool Whether we are currently inside a code block */
25*7cb424c9SSioc de Narf    private bool $inCodeBlock = false;
26*7cb424c9SSioc de Narf
27*7cb424c9SSioc de Narf    /** @var bool Whether we are currently inside a table */
28*7cb424c9SSioc de Narf    private bool $inTable = false;
29*7cb424c9SSioc de Narf
30*7cb424c9SSioc de Narf    /** @var array<int, array<int, string>> Rows of the current table */
31*7cb424c9SSioc de Narf    private array $tableRows = [];
32*7cb424c9SSioc de Narf
33*7cb424c9SSioc de Narf    /** @var array<int, string> Alignments for each column of the current table */
34*7cb424c9SSioc de Narf    private array $tableAlignments = [];
35*7cb424c9SSioc de Narf
36*7cb424c9SSioc de Narf    /** @var array<int, array{indent: int, type: string}> Stack tracking list nesting (indentation and type) */
37*7cb424c9SSioc de Narf    private array $listStack = [];
38*7cb424c9SSioc de Narf
39*7cb424c9SSioc de Narf    /** @var array<int, string> Buffer for paragraph lines before they are flushed */
40*7cb424c9SSioc de Narf    private array $paragraphBuffer = [];
41*7cb424c9SSioc de Narf
42*7cb424c9SSioc de Narf    /**
43*7cb424c9SSioc de Narf     * Remove YAML front matter from the beginning of the document.
44*7cb424c9SSioc de Narf     *
45*7cb424c9SSioc de Narf     * Detects a block starting with '---' at the very first line,
46*7cb424c9SSioc de Narf     * followed by any lines, and ending with '---' or '...'.
47*7cb424c9SSioc de Narf     * If such a block is found, it is stripped.
48*7cb424c9SSioc de Narf     *
49*7cb424c9SSioc de Narf     * @param string $markdown The raw Markdown.
50*7cb424c9SSioc de Narf     * @return string Markdown without the front matter.
51*7cb424c9SSioc de Narf     */
52*7cb424c9SSioc de Narf    private function stripYamlFrontMatter(string $markdown): string
53*7cb424c9SSioc de Narf    {
54*7cb424c9SSioc de Narf        $lines = explode("\n", $markdown);
55*7cb424c9SSioc de Narf        if (count($lines) === 0) {
56*7cb424c9SSioc de Narf            return $markdown;
57*7cb424c9SSioc de Narf        }
58*7cb424c9SSioc de Narf
59*7cb424c9SSioc de Narf        // Trim leading empty lines to find the first non-empty line
60*7cb424c9SSioc de Narf        $firstNonEmpty = 0;
61*7cb424c9SSioc de Narf        while ($firstNonEmpty < count($lines) && trim($lines[$firstNonEmpty]) === '') {
62*7cb424c9SSioc de Narf            $firstNonEmpty++;
63*7cb424c9SSioc de Narf        }
64*7cb424c9SSioc de Narf
65*7cb424c9SSioc de Narf        // If the first non-empty line is exactly '---', we have a front matter candidate
66*7cb424c9SSioc de Narf        if ($firstNonEmpty < count($lines) && trim($lines[$firstNonEmpty]) === '---') {
67*7cb424c9SSioc de Narf            $endLine = null;
68*7cb424c9SSioc de Narf            // Look for the closing '---' or '...' after the opening
69*7cb424c9SSioc de Narf            for ($i = $firstNonEmpty + 1; $i < count($lines); $i++) {
70*7cb424c9SSioc de Narf                if (trim($lines[$i]) === '---' || trim($lines[$i]) === '...') {
71*7cb424c9SSioc de Narf                    $endLine = $i;
72*7cb424c9SSioc de Narf                    break;
73*7cb424c9SSioc de Narf                }
74*7cb424c9SSioc de Narf            }
75*7cb424c9SSioc de Narf            // If we found a closing delimiter, remove all lines from start to end (inclusive)
76*7cb424c9SSioc de Narf            if ($endLine !== null) {
77*7cb424c9SSioc de Narf                $lines = array_slice($lines, $endLine + 1);
78*7cb424c9SSioc de Narf                return implode("\n", $lines);
79*7cb424c9SSioc de Narf            }
80*7cb424c9SSioc de Narf        }
81*7cb424c9SSioc de Narf
82*7cb424c9SSioc de Narf        // No front matter detected, return original
83*7cb424c9SSioc de Narf        return $markdown;
84*7cb424c9SSioc de Narf    }
85*7cb424c9SSioc de Narf
86*7cb424c9SSioc de Narf    /**
87*7cb424c9SSioc de Narf     * Convert Markdown to DokuWiki syntax.
88*7cb424c9SSioc de Narf     *
89*7cb424c9SSioc de Narf     * @param string $markdown The input Markdown text.
90*7cb424c9SSioc de Narf     * @return string The converted DokuWiki text.
91*7cb424c9SSioc de Narf     */
92*7cb424c9SSioc de Narf    public function convert(string $markdown): string
93*7cb424c9SSioc de Narf    {
94*7cb424c9SSioc de Narf        // Strip YAML front matter
95*7cb424c9SSioc de Narf        $markdown = $this->stripYamlFrontMatter($markdown);
96*7cb424c9SSioc de Narf
97*7cb424c9SSioc de Narf        // Normalize line endings and replace tabs with 4 spaces
98*7cb424c9SSioc de Narf        $lines = explode("\n", str_replace(["\r\n", "\r", "\t"], ["\n", "\n", "    "], $markdown));
99*7cb424c9SSioc de Narf        $output = [];
100*7cb424c9SSioc de Narf        $this->reset();
101*7cb424c9SSioc de Narf
102*7cb424c9SSioc de Narf        $i = 0;
103*7cb424c9SSioc de Narf        while ($i < count($lines)) {
104*7cb424c9SSioc de Narf            $line = $lines[$i];
105*7cb424c9SSioc de Narf            $nextLine = $i + 1 < count($lines) ? $lines[$i + 1] : null;
106*7cb424c9SSioc de Narf
107*7cb424c9SSioc de Narf            // Code block handling
108*7cb424c9SSioc de Narf            if (str_starts_with(trim($line), '```')) {
109*7cb424c9SSioc de Narf                $this->handleCodeBlock($line, $output);
110*7cb424c9SSioc de Narf                $i++;
111*7cb424c9SSioc de Narf                continue;
112*7cb424c9SSioc de Narf            }
113*7cb424c9SSioc de Narf            if ($this->inCodeBlock) {
114*7cb424c9SSioc de Narf                $output[] = $line;
115*7cb424c9SSioc de Narf                $i++;
116*7cb424c9SSioc de Narf                continue;
117*7cb424c9SSioc de Narf            }
118*7cb424c9SSioc de Narf
119*7cb424c9SSioc de Narf            // Table detection
120*7cb424c9SSioc de Narf            if ($this->isTableStart($line, $nextLine)) {
121*7cb424c9SSioc de Narf                $this->parseTable($lines, $i);
122*7cb424c9SSioc de Narf                $output[] = $this->renderTable();
123*7cb424c9SSioc de Narf                continue;
124*7cb424c9SSioc de Narf            }
125*7cb424c9SSioc de Narf
126*7cb424c9SSioc de Narf            // Horizontal rule
127*7cb424c9SSioc de Narf            if ($this->isHorizontalRule($line)) {
128*7cb424c9SSioc de Narf                $this->flushParagraph($output);
129*7cb424c9SSioc de Narf                $output[] = '----';
130*7cb424c9SSioc de Narf                $i++;
131*7cb424c9SSioc de Narf                continue;
132*7cb424c9SSioc de Narf            }
133*7cb424c9SSioc de Narf
134*7cb424c9SSioc de Narf            // Blockquote
135*7cb424c9SSioc de Narf            if ($this->isBlockquote($line)) {
136*7cb424c9SSioc de Narf                $this->flushParagraph($output);
137*7cb424c9SSioc de Narf                $output[] = $this->renderBlockquote($line);
138*7cb424c9SSioc de Narf                $i++;
139*7cb424c9SSioc de Narf                continue;
140*7cb424c9SSioc de Narf            }
141*7cb424c9SSioc de Narf
142*7cb424c9SSioc de Narf            // List item
143*7cb424c9SSioc de Narf            if ($this->isListItem($line)) {
144*7cb424c9SSioc de Narf                $this->handleList($line, $output);
145*7cb424c9SSioc de Narf                $i++;
146*7cb424c9SSioc de Narf                continue;
147*7cb424c9SSioc de Narf            }
148*7cb424c9SSioc de Narf
149*7cb424c9SSioc de Narf            // Header
150*7cb424c9SSioc de Narf            if ($this->isTitle($line)) {
151*7cb424c9SSioc de Narf                $this->flushParagraph($output);
152*7cb424c9SSioc de Narf                $output[] = $this->renderTitle($line);
153*7cb424c9SSioc de Narf                $i++;
154*7cb424c9SSioc de Narf                continue;
155*7cb424c9SSioc de Narf            }
156*7cb424c9SSioc de Narf
157*7cb424c9SSioc de Narf            // Empty line
158*7cb424c9SSioc de Narf            if (trim($line) === '') {
159*7cb424c9SSioc de Narf                $this->flushParagraph($output);
160*7cb424c9SSioc de Narf                $output[] = '';
161*7cb424c9SSioc de Narf                $i++;
162*7cb424c9SSioc de Narf                continue;
163*7cb424c9SSioc de Narf            }
164*7cb424c9SSioc de Narf
165*7cb424c9SSioc de Narf            // Normal paragraph line
166*7cb424c9SSioc de Narf            $this->paragraphBuffer[] = $this->convertInline($line);
167*7cb424c9SSioc de Narf            $i++;
168*7cb424c9SSioc de Narf        }
169*7cb424c9SSioc de Narf
170*7cb424c9SSioc de Narf        $this->flushParagraph($output);
171*7cb424c9SSioc de Narf        $this->closeLists($output);
172*7cb424c9SSioc de Narf
173*7cb424c9SSioc de Narf        return implode("\n", $output);
174*7cb424c9SSioc de Narf    }
175*7cb424c9SSioc de Narf
176*7cb424c9SSioc de Narf    /**
177*7cb424c9SSioc de Narf     * Reset internal state.
178*7cb424c9SSioc de Narf     */
179*7cb424c9SSioc de Narf    private function reset(): void
180*7cb424c9SSioc de Narf    {
181*7cb424c9SSioc de Narf        $this->inCodeBlock = false;
182*7cb424c9SSioc de Narf        $this->inTable = false;
183*7cb424c9SSioc de Narf        $this->tableRows = [];
184*7cb424c9SSioc de Narf        $this->tableAlignments = [];
185*7cb424c9SSioc de Narf        $this->listStack = [];
186*7cb424c9SSioc de Narf        $this->paragraphBuffer = [];
187*7cb424c9SSioc de Narf    }
188*7cb424c9SSioc de Narf
189*7cb424c9SSioc de Narf    /**
190*7cb424c9SSioc de Narf     * Handle a code block delimiter (```).
191*7cb424c9SSioc de Narf     *
192*7cb424c9SSioc de Narf     * @param string   $line   The current line.
193*7cb424c9SSioc de Narf     * @param string[] &$output The output array being built.
194*7cb424c9SSioc de Narf     */
195*7cb424c9SSioc de Narf    private function handleCodeBlock(string $line, array &$output): void
196*7cb424c9SSioc de Narf    {
197*7cb424c9SSioc de Narf        if (!$this->inCodeBlock) {
198*7cb424c9SSioc de Narf            $lang = trim(substr(trim($line), 3));
199*7cb424c9SSioc de Narf            $output[] = "<code" . ($lang ? " $lang" : "") . ">";
200*7cb424c9SSioc de Narf            $this->inCodeBlock = true;
201*7cb424c9SSioc de Narf        } else {
202*7cb424c9SSioc de Narf            $output[] = "</code>";
203*7cb424c9SSioc de Narf            $this->inCodeBlock = false;
204*7cb424c9SSioc de Narf        }
205*7cb424c9SSioc de Narf    }
206*7cb424c9SSioc de Narf
207*7cb424c9SSioc de Narf    /**
208*7cb424c9SSioc de Narf     * Determine if a line starts a Markdown table.
209*7cb424c9SSioc de Narf     *
210*7cb424c9SSioc de Narf     * @param string      $line     The current line.
211*7cb424c9SSioc de Narf     * @param string|null $nextLine The next line (if any).
212*7cb424c9SSioc de Narf     * @return bool True if a table starts here.
213*7cb424c9SSioc de Narf     */
214*7cb424c9SSioc de Narf    private function isTableStart(string $line, ?string $nextLine): bool
215*7cb424c9SSioc de Narf    {
216*7cb424c9SSioc de Narf        return strpos($line, '|') !== false && $nextLine && preg_match('/^[\s\|:\-]+$/', $nextLine);
217*7cb424c9SSioc de Narf    }
218*7cb424c9SSioc de Narf
219*7cb424c9SSioc de Narf    /**
220*7cb424c9SSioc de Narf     * Parse a Markdown table from the current position.
221*7cb424c9SSioc de Narf     *
222*7cb424c9SSioc de Narf     * @param string[] $lines The whole array of lines.
223*7cb424c9SSioc de Narf     * @param int      &$i    Current index (will be advanced to after the table).
224*7cb424c9SSioc de Narf     */
225*7cb424c9SSioc de Narf    private function parseTable(array $lines, int &$i): void
226*7cb424c9SSioc de Narf    {
227*7cb424c9SSioc de Narf        $headerLine = $lines[$i++];
228*7cb424c9SSioc de Narf        $separatorLine = $lines[$i++];
229*7cb424c9SSioc de Narf
230*7cb424c9SSioc de Narf        // Detect column alignments from separator line
231*7cb424c9SSioc de Narf        $this->tableAlignments = array_map(
232*7cb424c9SSioc de Narf            fn($part) => match (true) {
233*7cb424c9SSioc de Narf                str_starts_with(trim($part), ':') && str_ends_with(trim($part), ':') => 'center',
234*7cb424c9SSioc de Narf                str_ends_with(trim($part), ':') => 'right',
235*7cb424c9SSioc de Narf                str_starts_with(trim($part), ':') => 'left',
236*7cb424c9SSioc de Narf                default => 'left',
237*7cb424c9SSioc de Narf            },
238*7cb424c9SSioc de Narf            explode('|', trim($separatorLine, '|'))
239*7cb424c9SSioc de Narf        );
240*7cb424c9SSioc de Narf
241*7cb424c9SSioc de Narf        $this->tableRows = [$this->parseTableRow($headerLine)];
242*7cb424c9SSioc de Narf        while ($i < count($lines) && strpos($lines[$i], '|') !== false && !preg_match('/^[\s\|:\-]+$/', $lines[$i])) {
243*7cb424c9SSioc de Narf            $this->tableRows[] = $this->parseTableRow($lines[$i]);
244*7cb424c9SSioc de Narf            $i++;
245*7cb424c9SSioc de Narf        }
246*7cb424c9SSioc de Narf    }
247*7cb424c9SSioc de Narf
248*7cb424c9SSioc de Narf    /**
249*7cb424c9SSioc de Narf     * Parse a single Markdown table row into an array of cells.
250*7cb424c9SSioc de Narf     *
251*7cb424c9SSioc de Narf     * @param string $line The table row line.
252*7cb424c9SSioc de Narf     * @return string[] Array of cell contents.
253*7cb424c9SSioc de Narf     */
254*7cb424c9SSioc de Narf    private function parseTableRow(string $line): array
255*7cb424c9SSioc de Narf    {
256*7cb424c9SSioc de Narf        return array_map('trim', explode('|', trim($line, '|')));
257*7cb424c9SSioc de Narf    }
258*7cb424c9SSioc de Narf
259*7cb424c9SSioc de Narf    /**
260*7cb424c9SSioc de Narf     * Render the parsed table as DokuWiki syntax.
261*7cb424c9SSioc de Narf     *
262*7cb424c9SSioc de Narf     * @return string DokuWiki table representation.
263*7cb424c9SSioc de Narf     */
264*7cb424c9SSioc de Narf    private function renderTable(): string
265*7cb424c9SSioc de Narf    {
266*7cb424c9SSioc de Narf        $output = [];
267*7cb424c9SSioc de Narf        foreach ($this->tableRows as $rowIndex => $row) {
268*7cb424c9SSioc de Narf            $dokuRow = [];
269*7cb424c9SSioc de Narf            foreach ($row as $colIndex => $cell) {
270*7cb424c9SSioc de Narf                $cell = $this->convertInline($cell);
271*7cb424c9SSioc de Narf                $dokuRow[] = ($rowIndex === 0 ? '^ ' : '| ') . $cell . ($rowIndex === 0 ? ' ^' : ' |');
272*7cb424c9SSioc de Narf            }
273*7cb424c9SSioc de Narf            $output[] = implode('', $dokuRow);
274*7cb424c9SSioc de Narf        }
275*7cb424c9SSioc de Narf        return implode("\n", $output);
276*7cb424c9SSioc de Narf    }
277*7cb424c9SSioc de Narf
278*7cb424c9SSioc de Narf    /**
279*7cb424c9SSioc de Narf     * Check if a line is a Markdown list item.
280*7cb424c9SSioc de Narf     *
281*7cb424c9SSioc de Narf     * @param string $line The line.
282*7cb424c9SSioc de Narf     * @return bool True if it's a list item.
283*7cb424c9SSioc de Narf     */
284*7cb424c9SSioc de Narf    private function isListItem(string $line): bool
285*7cb424c9SSioc de Narf    {
286*7cb424c9SSioc de Narf        return preg_match('/^\s*([\*\-\+]|\d+\.)\s/', $line) === 1;
287*7cb424c9SSioc de Narf    }
288*7cb424c9SSioc de Narf
289*7cb424c9SSioc de Narf    /**
290*7cb424c9SSioc de Narf     * Handle a list item line, managing nesting via indentation.
291*7cb424c9SSioc de Narf     *
292*7cb424c9SSioc de Narf     * @param string   $line   The list item line.
293*7cb424c9SSioc de Narf     * @param string[] &$output The output array.
294*7cb424c9SSioc de Narf     */
295*7cb424c9SSioc de Narf    private function handleList(string $line, array &$output): void
296*7cb424c9SSioc de Narf    {
297*7cb424c9SSioc de Narf        $this->flushParagraph($output);
298*7cb424c9SSioc de Narf        $indent = $this->calculateIndent($line);
299*7cb424c9SSioc de Narf        $type = preg_match('/^\s*\d+\.\s/', $line) ? 'ordered' : 'unordered';
300*7cb424c9SSioc de Narf
301*7cb424c9SSioc de Narf        // Close deeper lists if indentation decreased
302*7cb424c9SSioc de Narf        while (!empty($this->listStack) && $indent <= $this->listStack[count($this->listStack) - 1]['indent']) {
303*7cb424c9SSioc de Narf            array_pop($this->listStack);
304*7cb424c9SSioc de Narf        }
305*7cb424c9SSioc de Narf
306*7cb424c9SSioc de Narf        $this->listStack[] = ['indent' => $indent, 'type' => $type];
307*7cb424c9SSioc de Narf        $dokuIndent = str_repeat('  ', count($this->listStack) - 1);
308*7cb424c9SSioc de Narf
309*7cb424c9SSioc de Narf        // Remove the list marker and any leading spaces, then convert inline
310*7cb424c9SSioc de Narf        $content = $this->convertInline(preg_replace('/^\s*([\*\-\+]|\d+\.)\s+/', '', $line));
311*7cb424c9SSioc de Narf        $output[] = $dokuIndent . ($type === 'ordered' ? '- ' : '* ') . $content;
312*7cb424c9SSioc de Narf    }
313*7cb424c9SSioc de Narf
314*7cb424c9SSioc de Narf    /**
315*7cb424c9SSioc de Narf     * Calculate the indentation level (number of leading spaces) of a line.
316*7cb424c9SSioc de Narf     *
317*7cb424c9SSioc de Narf     * @param string $line The line.
318*7cb424c9SSioc de Narf     * @return int Number of leading spaces.
319*7cb424c9SSioc de Narf     */
320*7cb424c9SSioc de Narf    private function calculateIndent(string $line): int
321*7cb424c9SSioc de Narf    {
322*7cb424c9SSioc de Narf        return strlen($line) - strlen(ltrim($line));
323*7cb424c9SSioc de Narf    }
324*7cb424c9SSioc de Narf
325*7cb424c9SSioc de Narf    /**
326*7cb424c9SSioc de Narf     * Close any remaining open lists (reset stack).
327*7cb424c9SSioc de Narf     *
328*7cb424c9SSioc de Narf     * @param string[] &$output The output array (unused, kept for consistency).
329*7cb424c9SSioc de Narf     */
330*7cb424c9SSioc de Narf    private function closeLists(array &$output): void
331*7cb424c9SSioc de Narf    {
332*7cb424c9SSioc de Narf        $this->listStack = [];
333*7cb424c9SSioc de Narf    }
334*7cb424c9SSioc de Narf
335*7cb424c9SSioc de Narf    /**
336*7cb424c9SSioc de Narf     * Check if a line is a Markdown header (starts with #).
337*7cb424c9SSioc de Narf     *
338*7cb424c9SSioc de Narf     * @param string $line The line.
339*7cb424c9SSioc de Narf     * @return bool True if it's a header.
340*7cb424c9SSioc de Narf     */
341*7cb424c9SSioc de Narf    private function isTitle(string $line): bool
342*7cb424c9SSioc de Narf    {
343*7cb424c9SSioc de Narf        return preg_match('/^(#{1,6})\s+(.+)$/', trim($line)) === 1;
344*7cb424c9SSioc de Narf    }
345*7cb424c9SSioc de Narf
346*7cb424c9SSioc de Narf    /**
347*7cb424c9SSioc de Narf     * Render a Markdown header as a DokuWiki header.
348*7cb424c9SSioc de Narf     *
349*7cb424c9SSioc de Narf     * @param string $line The header line.
350*7cb424c9SSioc de Narf     * @return string DokuWiki header.
351*7cb424c9SSioc de Narf     */
352*7cb424c9SSioc de Narf    private function renderTitle(string $line): string
353*7cb424c9SSioc de Narf    {
354*7cb424c9SSioc de Narf        preg_match('/^(#{1,6})\s+(.+)$/', trim($line), $matches);
355*7cb424c9SSioc de Narf        $level = strlen($matches[1]);
356*7cb424c9SSioc de Narf        $title = trim($matches[2]);
357*7cb424c9SSioc de Narf        $equals = str_repeat('=', 7 - $level);
358*7cb424c9SSioc de Narf        return "$equals $title $equals";
359*7cb424c9SSioc de Narf    }
360*7cb424c9SSioc de Narf
361*7cb424c9SSioc de Narf    /**
362*7cb424c9SSioc de Narf     * Check if a line is a horizontal rule (three or more -, *, _).
363*7cb424c9SSioc de Narf     *
364*7cb424c9SSioc de Narf     * @param string $line The line.
365*7cb424c9SSioc de Narf     * @return bool True if it's a horizontal rule.
366*7cb424c9SSioc de Narf     */
367*7cb424c9SSioc de Narf    private function isHorizontalRule(string $line): bool
368*7cb424c9SSioc de Narf    {
369*7cb424c9SSioc de Narf        return preg_match('/^[-*_]{3,}\s*$/', trim($line)) === 1;
370*7cb424c9SSioc de Narf    }
371*7cb424c9SSioc de Narf
372*7cb424c9SSioc de Narf    /**
373*7cb424c9SSioc de Narf     * Check if a line is a blockquote (starts with >).
374*7cb424c9SSioc de Narf     *
375*7cb424c9SSioc de Narf     * @param string $line The line.
376*7cb424c9SSioc de Narf     * @return bool True if it's a blockquote.
377*7cb424c9SSioc de Narf     */
378*7cb424c9SSioc de Narf    private function isBlockquote(string $line): bool
379*7cb424c9SSioc de Narf    {
380*7cb424c9SSioc de Narf        return str_starts_with(ltrim($line), '>');
381*7cb424c9SSioc de Narf    }
382*7cb424c9SSioc de Narf
383*7cb424c9SSioc de Narf    /**
384*7cb424c9SSioc de Narf     * Render a blockquote line.
385*7cb424c9SSioc de Narf     *
386*7cb424c9SSioc de Narf     * @param string $line The blockquote line.
387*7cb424c9SSioc de Narf     * @return string DokuWiki blockquote (>> ...).
388*7cb424c9SSioc de Narf     */
389*7cb424c9SSioc de Narf    private function renderBlockquote(string $line): string
390*7cb424c9SSioc de Narf    {
391*7cb424c9SSioc de Narf        // Remove leading '>' and any following space, then convert inline
392*7cb424c9SSioc de Narf        return '>> ' . $this->convertInline(substr(ltrim($line), 1));
393*7cb424c9SSioc de Narf    }
394*7cb424c9SSioc de Narf
395*7cb424c9SSioc de Narf    /**
396*7cb424c9SSioc de Narf     * Convert inline Markdown formatting to DokuWiki.
397*7cb424c9SSioc de Narf     *
398*7cb424c9SSioc de Narf     * Handles bold, italic, inline code, images, and links.
399*7cb424c9SSioc de Narf     *
400*7cb424c9SSioc de Narf     * @param string $text The text to convert.
401*7cb424c9SSioc de Narf     * @return string Converted text.
402*7cb424c9SSioc de Narf     */
403*7cb424c9SSioc de Narf    private function convertInline(string $text): string
404*7cb424c9SSioc de Narf    {
405*7cb424c9SSioc de Narf        // Bold: **text** or __text__ → **text** (same in DokuWiki)
406*7cb424c9SSioc de Narf        $text = preg_replace('/\*\*(.+?)\*\*/', '**$1**', $text);
407*7cb424c9SSioc de Narf        $text = preg_replace('/__(.+?)__/', '**$1**', $text);
408*7cb424c9SSioc de Narf
409*7cb424c9SSioc de Narf        // Italic: *text* or _text_ → //text//
410*7cb424c9SSioc de Narf        $text = preg_replace('/\*(.+?)\*/', '//$1//', $text);
411*7cb424c9SSioc de Narf        $text = preg_replace('/_(.+?)_/', '//$1//', $text);
412*7cb424c9SSioc de Narf
413*7cb424c9SSioc de Narf        // Inline code: `code` → ''code''
414*7cb424c9SSioc de Narf        $text = preg_replace('/`(.+?)`/', "''$1''", $text);
415*7cb424c9SSioc de Narf
416*7cb424c9SSioc de Narf        // Images: ![alt](url) → {{url|alt}}
417*7cb424c9SSioc de Narf        $text = preg_replace('/!\[([^\]]*)\]\(([^)]+)\)/', '{{$2|$1}}', $text);
418*7cb424c9SSioc de Narf
419*7cb424c9SSioc de Narf        // Links: [text](url) → [[url|text]]
420*7cb424c9SSioc de Narf        $text = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '[[$2|$1]]', $text);
421*7cb424c9SSioc de Narf
422*7cb424c9SSioc de Narf        return $text;
423*7cb424c9SSioc de Narf    }
424*7cb424c9SSioc de Narf
425*7cb424c9SSioc de Narf    /**
426*7cb424c9SSioc de Narf     * Flush any buffered paragraph lines to the output.
427*7cb424c9SSioc de Narf     *
428*7cb424c9SSioc de Narf     * @param string[] &$output The output array.
429*7cb424c9SSioc de Narf     */
430*7cb424c9SSioc de Narf    private function flushParagraph(array &$output): void
431*7cb424c9SSioc de Narf    {
432*7cb424c9SSioc de Narf        if (!empty($this->paragraphBuffer)) {
433*7cb424c9SSioc de Narf            $output[] = implode(' ', $this->paragraphBuffer);
434*7cb424c9SSioc de Narf            $this->paragraphBuffer = [];
435*7cb424c9SSioc de Narf        }
436*7cb424c9SSioc de Narf    }
437*7cb424c9SSioc de Narf}
438