1<?php
2
3/**
4 * DokuWiki Plugin Mermaid (Syntax Component)
5 *
6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
7 * @author  Robert Weinmeister <develop@weinmeister.org>
8 */
9
10declare(strict_types=1);
11
12if (!defined('DOKU_INC'))
13{
14    die();
15}
16
17use dokuwiki\Parsing\Parser;
18
19class syntax_plugin_mermaid extends \dokuwiki\Extension\SyntaxPlugin
20{
21    // Constants for DokuWiki link handling
22    private const DOKUWIKI_LINK_START_MERMAID = '<code>DOKUWIKILINKSTARTMERMAID</code>';
23    private const DOKUWIKI_LINK_END_MERMAID = '<code>DOKUWIKILINKENDMERMAID</code>';
24    private const DOKUWIKI_LINK_SPLITTER ='--';
25
26    // SVG icons as constants
27    private const DOKUWIKI_SVG_SAVE = '<svg fill="#000000" viewBox="0 0 52 52" enable-background="new 0 0 52 52" xml:space="preserve" style="width: 24px; height: 24px;"><path d="M37.1,4v13.6c0,1-0.8,1.9-1.9,1.9H13.9c-1,0-1.9-0.8-1.9-1.9V4H8C5.8,4,4,5.8,4,8v36c0,2.2,1.8,4,4,4h36  c2.2,0,4-1.8,4-4V11.2L40.8,4H37.1z M44.1,42.1c0,1-0.8,1.9-1.9,1.9H9.9c-1,0-1.9-0.8-1.9-1.9V25.4c0-1,0.8-1.9,1.9-1.9h32.3  c1,0,1.9,0.8,1.9,1.9V42.1z"/><path d="M24.8,13.6c0,1,0.8,1.9,1.9,1.9h4.6c1,0,1.9-0.8,1.9-1.9V4h-8.3L24.8,13.6L24.8,13.6z"/></svg>';
28    private const DOKUWIKI_SVG_LOCKED = '<svg viewBox="0 0 16 16" fill="none" style="width: 24px; height: 24px;"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6V4C4 1.79086 5.79086 0 8 0C10.2091 0 12 1.79086 12 4V6H14V16H2V6H4ZM6 4C6 2.89543 6.89543 2 8 2C9.10457 2 10 2.89543 10 4V6H6V4ZM7 13V9H9V13H7Z" fill="#000000"/></svg>';
29    private const DOKUWIKI_SVG_UNLOCKED = '<svg style="width: 24px; height: 24px;" viewBox="0 0 16 16" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 2C10.6716 2 10 2.67157 10 3.5V6H13V16H1V6H8V3.5C8 1.567 9.567 0 11.5 0C13.433 0 15 1.567 15 3.5V4H13V3.5C13 2.67157 12.3284 2 11.5 2ZM9 10H5V12H9V10Z" fill="#000000"/></svg>';
30
31    private int $mermaidCounter = -1;
32    private string $mermaidContent = '';
33    private bool $currentMermaidIsLocked = false;
34    private string $mermaidContentIfLocked = '';
35
36    /**
37     * Processes Gantt chart links in Mermaid diagrams
38     *
39     * Converts DokuWiki internal and external links into Mermaid-compatible
40     * click events for Gantt chart tasks.
41     *
42     * @param array $instructions The parser instructions to process
43     * @return array Modified instructions with properly formatted Gantt links
44     */
45    private function processGanttLinks($instructions): array {
46        $modified_instructions = $instructions;
47
48        for ($i = 0; $i < count($modified_instructions); $i++)
49        {
50            if (!in_array($modified_instructions[$i][0], ['externallink', 'internallink'])) {
51                continue;
52            }
53
54            // use the appropriate link
55            $link = $modified_instructions[$i][0] === "externallink"
56                ? $modified_instructions[$i][1][0]
57                : wl($modified_instructions[$i][1][0], '', true);
58
59            // change link here to just the name
60            $modified_instructions[$i][0] = "cdata";
61            if($modified_instructions[$i][1][1] !== null) {
62                unset($modified_instructions[$i][1][0]);
63            }
64
65            // insert the click event
66            $click_reference = '';
67            if (preg_match('/(?<=:\s)\S+(?=,)/', $modified_instructions[$i+1][1][0], $output_array)) {
68                $click_reference = $output_array[0];
69            }
70
71            array_splice(
72                $modified_instructions,
73                $i + 2,
74                0,
75                [["cdata", ["\nclick {$click_reference} href \"{$link}\"\n"]]]
76            );
77
78            // encode colons
79            if (isset($modified_instructions[$i][1][0]) && is_string($modified_instructions[$i][1][0])) {
80                $modified_instructions[$i][1][0] = str_replace(":", "#colon;", $modified_instructions[$i][1][0]);
81            }
82        }
83
84        return $modified_instructions;
85    }
86
87    /**
88     * Protects DokuWiki link brackets from being processed
89     *
90     * @param string $text Text containing DokuWiki links
91     * @return string Text with protected DokuWiki link brackets
92     */
93    private function protectBracketsFromDokuWiki(string $text): string {
94        $splitText = explode(self::DOKUWIKI_LINK_SPLITTER, $text);
95        foreach ($splitText as $key => $line) {
96            $splitText[$key] = preg_replace(
97                '/(?<!["\[(\s])(\[\[)(.*)(\]\])/',
98                self::DOKUWIKI_LINK_START_MERMAID . '$2' . self::DOKUWIKI_LINK_END_MERMAID,
99                $line
100            ) ?? $line;
101        }
102        return implode(self::DOKUWIKI_LINK_SPLITTER, $splitText);
103    }
104
105    /**
106    * Reverts the protection of DokuWiki link brackets
107    *
108    * @param string $text Text containing protected DokuWiki links
109    * @return string Text with restored DokuWiki link brackets
110    */
111    private function removeProtectionOfBracketsFromDokuWiki(string $text): string {
112        return str_replace(
113            [self::DOKUWIKI_LINK_START_MERMAID, self::DOKUWIKI_LINK_END_MERMAID],
114            ['[[',  ']]'],
115            $text
116        );
117    }
118
119    /** @inheritDoc */
120    function getType(): string {
121        return 'container';
122    }
123
124    /** @inheritDoc */
125    function getSort(): int {
126        return 150;
127    }
128
129    /**
130    * Entry pattern for Mermaid
131    *
132    * @param string $mode Parser mode
133    * @return void
134    */
135    public function connectTo($mode): void {
136        $this->Lexer->addEntryPattern(
137            '<mermaid.*?>(?=.*?</mermaid>)',
138            $mode,
139            'plugin_mermaid');
140    }
141
142    /**
143     * Exit pattern for Mermaid
144     *
145     * @return void
146     */
147    function postConnect(): void {
148        $this->Lexer->addExitPattern(
149            '</mermaid>',
150            'plugin_mermaid'
151        );
152    }
153
154    /**
155     * Handles the matched text based on the lexer state
156     *
157     * @param string $match Matched text from the lexer
158     * @param int $state Current lexer state (DOKU_LEXER_ENTER, DOKU_LEXER_UNMATCHED, DOKU_LEXER_EXIT)
159     * @param Doku_Handler $handler DokuWiki handler instance
160     * @return array Array with the state and processed match
161     */
162    function handle($match, $state, $pos, Doku_Handler $handler): array {
163        return [$state, $match];
164    }
165
166    /**
167    * Render xhtml output or metadata
168    */
169    function render($mode, Doku_Renderer $renderer, $indata)
170    {
171        if($mode == 'xhtml'){
172            list($state, $match) = $indata;
173            switch ($state) {
174                case DOKU_LEXER_ENTER:
175                    $this->mermaidCounter++;
176                    $values = explode(" ", $match);
177                    $divwidth = count($values) < 2 ? 'auto' : $values[1];
178                    $divheight = count($values) < 3 ? 'auto' : substr($values[2], 0, -1);
179                    $this->mermaidContent .= '<div id="mermaidContainer'.$this->mermaidCounter.'" style="position: relative; width:'.$divwidth.'; height:'.$divheight.'">';
180                    $this->mermaidContentIfLocked = $this->mermaidContent . '<span class="mermaidlocked" id=mermaidContent'.$this->mermaidCounter.' style="width:'.$divwidth.'; height:'.$divheight.'">';
181                    $this->mermaidContent .= '<span class="mermaid" id=mermaidContent'.$this->mermaidCounter.' style="width:'.$divwidth.'; height:'.$divheight.'">';
182                break;
183                case DOKU_LEXER_UNMATCHED:
184                    $explodedMatch = explode("\n", $match);
185
186                    if(str_starts_with($explodedMatch[1], '%%<svg'))
187                    {
188                        $this->currentMermaidIsLocked = true;
189                        $this->mermaidContent = $this->mermaidContentIfLocked . substr($explodedMatch[1], 2);
190                        break;
191                    }
192                    else
193                    {
194                        $this->currentMermaidIsLocked = false;
195                    }
196
197                    $israwmode = isset($explodedMatch[1]) && strpos($explodedMatch[1], 'raw') !== false;
198                    if($israwmode)
199                    {
200                        array_shift($explodedMatch);
201                        array_shift($explodedMatch);
202                        $actualContent = implode("\n", $explodedMatch);
203                        $this->mermaidContent .= $actualContent;
204                    }
205                    else
206                    {
207                        $instructions = $this->p_get_instructions($this->protectBracketsFromDokuWiki($match));
208                        if (strpos($instructions[2][1][0], "gantt"))
209                        {
210                            $instructions = $this->processGanttLinks($instructions);
211                        }
212                        $xhtml = $this->removeProtectionOfBracketsFromDokuWiki($this->p_render($instructions));
213                        $this->mermaidContent .= preg_replace("/(^[\r\n]*|[\r\n]+)[\s\t]*[\r\n]+/", "\n", $xhtml);
214                    }
215                break;
216                case DOKU_LEXER_EXIT:
217                    $this->mermaidContent .= "\r\n</span>";
218                    if($this->getConf('showSaveButton') or $this->getConf('showLockButton'))
219                    {
220                        $this->mermaidContent .= '<fieldset id="mermaidFieldset'.$this->mermaidCounter.'" style="position: absolute; top: 0; left: 0; display: none; width:auto; border: none">';
221                        if($this->getConf('showSaveButton'))
222                        {
223                            $this->mermaidContent .= '<button id="mermaidButtonSave'.$this->mermaidCounter.'" style="z-index: 10; display: block; padding: 0; margin: 0; border: none; background: none; width: 24px; height: 24px;">'.self::DOKUWIKI_SVG_SAVE.'</button>';
224                        }
225                        if($this->getConf('showLockButton'))
226                        {
227                            $this->mermaidContent .= '<button id="mermaidButtonPermanent'.$this->mermaidCounter.'" style="z-index: 10; display: block; padding: 0; margin: 0; border: none; background: none; width: 24px; height: 24px;">'.($this->currentMermaidIsLocked ? self::DOKUWIKI_SVG_LOCKED : self::DOKUWIKI_SVG_UNLOCKED).'</button>';
228                        }
229                        $this->mermaidContent .= '</fieldset>';
230                    }
231                    $this->mermaidContent .= '</div>';
232
233                    $renderer->doc .= $this->mermaidContent;
234                    $this->mermaidContent = '';
235                break;
236            }
237            return true;
238        }
239        return false;
240    }
241
242    /*
243    * Get the parser instructions suitable for the mermaid
244    *
245    */
246    function p_get_instructions($text)
247    {
248        //import parser classes and mode definitions
249        require_once DOKU_INC . 'inc/parser/parser.php';
250
251        // https://www.dokuwiki.org/devel:parser
252        // https://www.dokuwiki.org/devel:parser#basic_invocation
253        // Create the parser and the handler
254        $Parser = new Parser(new Doku_Handler());
255
256        $modes = array();
257
258        // add default modes
259        $std_modes = array( 'internallink', 'media', 'externallink');
260
261        foreach($std_modes as $m)
262        {
263            $class = 'dokuwiki\\Parsing\\ParserMode\\'.ucfirst($m);
264            $obj   = new $class();
265            $modes[] = array(
266            'sort' => $obj->getSort(),
267            'mode' => $m,
268            'obj'  => $obj
269            );
270        }
271
272        // add formatting modes
273        $fmt_modes = array( 'strong', 'emphasis', 'underline', 'monospace', 'subscript', 'superscript', 'deleted');
274        foreach($fmt_modes as $m)
275        {
276          $obj   = new \dokuwiki\Parsing\ParserMode\Formatting($m);
277          $modes[] = array(
278            'sort' => $obj->getSort(),
279            'mode' => $m,
280            'obj'  => $obj
281          );
282        }
283
284        //add modes to parser
285        foreach($modes as $mode)
286        {
287            $Parser->addMode($mode['mode'],$mode['obj']);
288        }
289
290        // Do the parsing
291        $p = $Parser->parse($text);
292
293        return $p;
294    }
295
296    public function p_render($instructions)
297    {
298
299        $Renderer = p_get_renderer('mermaid');
300
301        // Loop through the instructions
302        foreach ($instructions as $instruction) {
303            if(method_exists($Renderer, $instruction[0])){
304            call_user_func_array(array(&$Renderer, $instruction[0]), $instruction[1] ? $instruction[1] : array());
305            }
306        }
307        return $Renderer->doc;
308    }
309}