1<?php
2/**
3 * DokuWiki Plugin parserfunctions (Syntax Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Daniel "Nerun" Rodrigues <danieldiasr@gmail.com>
7 * @created: Sat, 09 Dec 2023 14:59 -0300
8 *
9 * This is my first plugin, and I don't even know PHP well, that's why it's full
10 * of comments, but I'll leave it that way so I can consult it in the future.
11 *
12 */
13use dokuwiki\Extension\SyntaxPlugin;
14use dokuwiki\Utf8\PhpString;
15
16class syntax_plugin_parserfunctions extends SyntaxPlugin
17{
18    /** @inheritDoc */
19    public function getType()
20    {
21        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#syntax_types
22         * substition  — modes where the token is simply replaced – they can not
23         * contain any other modes
24         */
25        return 'substition';
26    }
27
28    /** @inheritDoc */
29    public function getPType()
30    {
31        /* READ: https://www.dokuwiki.org/devel:syntax_plugins
32         * normal — Default value, will be used if the method is not overridden.
33         *          The plugin output will be inside a paragraph (or another
34         *          block element), no paragraphs will be inside.
35         */
36        return 'normal';
37    }
38
39    /** @inheritDoc */
40    public function getSort()
41    {
42        /* READ: https://www.dokuwiki.org/devel:parser:getsort_list
43         * Don't understand exactly what it does, need more study.
44         *
45         * Must go after Templater (302) and WST (319) plugin, to be able to
46         * render @parameter@ and {{{parameter}}}.
47         */
48        return 320;
49    }
50
51    /** @inheritDoc */
52    public function connectTo($mode)
53    {
54        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#patterns
55         * This pattern accepts any alphabetical function name but not nested
56         * functions.
57         *
58         * ChatGPT helped me to fix this pattern! (18 jan 2025)
59         */
60        $this->Lexer->addSpecialPattern('\{\{#[[:alpha:]]+:(?:(?!\{\{#|#\}\}).)*#\}\}', $mode, 'plugin_parserfunctions');
61    }
62
63    /** @inheritDoc */
64    public function handle($match, $state, $pos, Doku_Handler $handler)
65    {
66        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#handle_method
67         * This is the part of your plugin which should do all the work. Before
68         * DokuWiki renders the wiki page it creates a list of instructions for
69         * the renderer. The plugin's handle() method generates the render
70         * instructions for the plugin's own syntax mode. At some later time,
71         * these will be interpreted by the plugin's render() method.
72         *
73         * Parameters:
74         *
75         *   $match   (string)  — The text matched by the patterns
76         *
77         *   $state   (int)     — The lexer state for the match, representing
78         *                        the type of pattern which triggered this call
79         *                        to handle(): DOKU_LEXER_SPECIAL — a pattern
80         *                        set by addSpecialPattern().
81         *
82         *   $pos     (int)     — The character position of the matched text.
83         *
84         *   $handler           — Object Reference to the Doku_Handler object.
85         */
86
87        // Exit if no text matched by the patterns.
88        if (empty($match)) {
89            return false;
90        }
91
92        /* Function name: "if", "ifeq", "ifexpr" etc.
93         * strtolower converts only ASCII; PhpString::strtolower supports UTF-8,
94         * added by "use dokuwiki\Utf8\PhpString;" at line 15. The function
95         * names will probably only use ASCII characters, but it's a precaution.
96         * The 's' at the end of '/pattern/s' adds support to multiline strings.
97         */
98        $func_name = preg_replace('/\{\{#([[:alpha:](&#)]+):.*#\}\}/s', '\1', $match);
99        $func_name = PhpString::strtolower($func_name);
100
101        // Delete delimiters "{{#functionname:" and "#}}".
102        // The 's' at the end of '/pattern/s' adds support to multiline strings.
103        $parts = preg_replace('/\{\{#[[:alpha:]]+:(.*)#\}\}/s', '\1', $match);
104
105        // Create list with all parameters splited by "|" pipe
106        // 1st) Replace pipe '|' by a temporary marker
107        $parts = str_replace('%%|%%', '%%TEMP_MARKER%%', $parts);
108        // 2nd) Create list of parameters splited by pipe "|"
109        $params = explode('|', $parts);
110        //3rd) Restoring temporary marker to `%%|%%`
111        $params = str_replace('%%TEMP_MARKER%%', '%%|%%', $params);
112        /* This snippet above was necessary to allow the escape sequence of the
113         * pipe character "|" using the standard DokuWiki formatting syntax
114         * which is to wrap it in "%%".
115         */
116
117        // Stripping whitespace from the beginning and end of strings
118        $params = array_map('trim', $params);
119
120        // ==================== FINALLY: do the work! ====================
121        switch($func_name){
122            // To add a new function, first add a "case" below, make it call a
123            // function, then write the function.
124            case 'if':
125                $func_result = $this->_IF($params, $func_name);
126                break;
127            case 'ifeq':
128                $func_result = $this->_IFEQ($params, $func_name);
129                break;
130            case 'ifexist':
131                $func_result = $this->_IFEXIST($params, $func_name);
132                break;
133            case 'switch':
134                $func_result = $this->_SWITCH($params, $func_name);
135                break;
136            default:
137                $func_result = $this->_raise_error('important', $func_name,
138                'no_such_function');
139                break;
140        }
141
142        // The instructions provided to the render() method:
143        return $func_result;
144    }
145
146    /** @inheritDoc */
147    public function render($mode, Doku_Renderer $renderer, $data)
148    {
149        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#render_method
150         * The part of the plugin that provides the output for the final web
151         * page.
152         *
153         * Parameters:
154         *
155         *   $mode     — Name for the format mode of the final output produced
156         *               by the renderer.
157         *
158         *   $renderer — Give access to the object Doku_Renderer, which contains
159         *               useful functions and values.
160         *
161         *   $data     — An array containing the instructions previously
162         *               prepared and returned by the plugin's own handle()
163         *               method. The render() must interpret the instruction and
164         *               generate the appropriate output.
165         */
166
167        if ($mode !== 'xhtml') {
168            return false;
169        }
170
171        if (!$data) {
172            return false;
173        }
174
175        // escape sequences
176        $data = $this->_escape($data);
177
178        // Do not use <div></div> because we need inline substitution!
179		// Both substr() and preg_replace() do the same thing: remove the
180		// first '<p>' and the last '</p>'
181		$data = $renderer->render_text($data, 'xhtml');
182		//$data = substr( $data, 4, -5 );
183		$data = preg_replace( '/<p>((.|\n)*?)<\/p>/', '\1', $data );
184		$renderer->doc .= $data;
185
186        return true;
187    }
188
189    function _raise_error($wrap, $func_name, $msg){
190        if ( file_exists('lib/plugins/wrap') ){
191            $wrap_s = '<wrap ' . $wrap . '>';
192            $wrap_e = '</wrap>';
193        } else {
194            $wrap_s = $wrap_e = null;
195        }
196
197        return $wrap_s . '**' . $this->getLang('error') . ' "' . $func_name .
198               '": ' . $this->getLang($msg) . '**' . $wrap_e;
199    }
200
201    /**
202     * ========== #IF
203     * {{#if: 1st parameter | 2nd parameter | 3rd parameter #}}
204     * {{#if: test string | value if test string is not empty | value if test
205     * string is empty (or only white space) #}}
206     */
207    function _IF($params, $func_name)
208    {
209        if ( count($params) < 1 ) {
210            $result = $this->_raise_error('alert', $func_name,
211                      'not_enough_params');
212        } else {
213            if ( !empty($params[0]) ) {
214                $result = $params[1] ?? null;
215            } else {
216                $result = $params[2] ?? null;
217            }
218        }
219
220        return $result;
221    }
222
223    /**
224     * ========== #IFEQ
225     * {{#ifeq: 1st parameter | 2nd parameter | 3rd parameter | 4th parameter #}}
226     * {{#ifeq: string 1 | string 2 | value if identical | value if different #}}
227     */
228    function _IFEQ($params, $func_name)
229    {
230        if ( count($params) < 2 ) {
231            $result = $this->_raise_error('alert', $func_name,
232                      'not_enough_params');
233        } else {
234            if ( $params[0] == $params[1] ) {
235                $result = $params[2] ?? null;
236            } else {
237                $result = $params[3] ?? null;
238            }
239        }
240
241        return $result;
242    }
243
244    /**
245     * ========== #IFEXIST
246     * {{#ifexist: 1st parameter | 2nd parameter | 3rd parameter #}}
247     * {{#ifexist: page title or media, or file/folder path | value if exists
248     *  | value if doesn't exist #}}
249     */
250    function _IFEXIST($params, $func_name)
251    {
252        if ( count($params) < 1 ) {
253            $result = $this->_raise_error('alert', $func_name,
254                      'not_enough_params');
255        } else {
256            if ( str_contains($params[0], '/') ){
257                if ( str_starts_with($params[0], '/') ){
258                    $isMedia = substr($params[0], 1);
259                } else {
260                    $isMedia = $params[0];
261                }
262            } else {
263                $isMedia = 'data/media/' . str_replace(':', '/', $params[0]);
264            }
265
266            if ( page_exists($params[0]) or file_exists($isMedia) ){
267                $result = $params[1] ?? null;
268            } else {
269                $result = $params[2] ?? null;
270            }
271        }
272
273        return $result;
274    }
275
276    /**
277     * ========== #SWITCH
278     * {{#switch: comparison string
279     * | case = result
280     * | case = result
281     * | ...
282     * | case = result
283     * | default result
284     * #}}
285     */
286    function _SWITCH($params, $func_name)
287    {
288        if ( count($params) < 2 ) {
289            $result = $this->_raise_error('alert', $func_name,
290                      'not_enough_params');
291        } else {
292            /**
293             * Then:
294             *
295             * "$params":
296             *      (
297             *          [0] => test string
298             *          [1] => case 1 = value 1
299             *          [2] => case 2 = value 2
300             *          [3] => case 3 = value 3
301             *          [4] => default value
302             *      )
303             */
304
305            $cases_kv = [];
306            $test_and_default_string = [];
307
308            foreach ( $params as $value ){
309                // 1st) Replace escaped equal sign '%%|%%' by a temporary marker
310                $value = str_replace('%%=%%', '%%TEMP_MARKER%%', $value);
311                // 2nd) Create list of values splited by equal sign "="
312                $value = explode('=', $value);
313                //3rd) Restoring temporary marker to `%%=%%`
314                $value = str_replace('%%TEMP_MARKER%%', '%%=%%', $value);
315                /* This snippet above was necessary to allow the escape sequence of
316                 * the equal sign "=" using the standard DokuWiki formatting syntax
317                 * which is to wrap it in "%%".
318                 * (same as lines 105-115 above)
319                 */
320
321            	if ( isset($value[1]) ) {
322            		$cases_kv[trim($value[0])] = trim($value[1]);
323            	} else {
324		            if ( count($cases_kv) == 0 or count($cases_kv) == count($params) ) {
325			            $test_and_default_string[] = trim($value[0]);
326		            } else {
327			            $cases_kv[trim($value[0])] = '%%FALL_THROUGH_TEMP_MARKER%%';
328		            }
329	            }
330            }
331
332            $count = 0;
333
334            foreach ( $cases_kv as $key=>$value ){
335                $count++;
336	            if ( $value == '%%FALL_THROUGH_TEMP_MARKER%%' ){
337		            $subDict = array_slice($cases_kv, $count);
338		            foreach ( $subDict as $chave=>$valor ){
339			            if ( $valor != '%%FALL_THROUGH_TEMP_MARKER%%' ){
340				            $cases_kv[$key] = $valor;
341				            break;
342			            }
343		            }
344	            }
345            }
346
347            /**
348             * And now:
349             *
350             * "$cases_kv":
351             *      (
352             *          [case 1] => value 1
353             *          [case 2] => value 2
354             *          [case 3] => value 3
355             *      )
356             *
357             * "$test_and_default_string":
358             *      (
359             *          [0] => test string
360             *          [1] => default value
361             *      )
362             */
363
364            if ( array_key_exists($test_and_default_string[0], $cases_kv) ) {
365            	$result = $cases_kv[$test_and_default_string[0]];
366            } else {
367                /* Default value:
368                 * Explicit declaration (#default = default_value) takes precedence
369                 * over implicit one (just 'default_value').
370                 */
371            	$result = $cases_kv['#default'] ?? $test_and_default_string[1] ?? '';
372            }
373        }
374
375        return $result;
376    }
377
378    /**
379     * Escape sequence handling
380     */
381    function _escape($data){
382        /**
383         * To add more escapes, please refer to:
384         * https://www.freeformatter.com/html-entities.html
385         *
386         * Before 2025-01-18, escape sequences had to use "&&num;NUMBER;"
387         * instead of "&#;NUMBER;", because "#" was not escaped. But now "#" can
388         * be typed directly, and does not need to be escaped. So use the normal
389         * spelling for HTML entity codes, i.e., "&#61;" instead of "&&num;61;"
390         * when adding NEW escapes.
391         *
392         * Additionally, after 2025-01-18, '=', '|', '{' and '}' signs can be
393         * escaped only by wrapping them in '%%', following the standard
394         * DokuWiki syntax. So, the escapes below are DEPRECATED, but kept for
395         * backwards compatibility.
396         *
397         */
398        $escapes = array(
399            // DEPRECATED, but kept for backwards compatibility:
400            "&&num;61;"  => "=",
401            "&&num;123;" => "%%{%%",
402            "&&num;124;" => "|",
403            "&&num;125;" => "%%}%%",
404            "&num;"      => "#"  // Always leave this as the last element!
405        );
406
407        foreach ( $escapes as $key => $value ) {
408            $data = preg_replace("/$key/s", $value, $data);
409        }
410
411        return $data;
412    }
413}
414// vim:ts=4:sw=4:et:enc=utf-8:
415