xref: /plugin/parserfunctions/syntax.php (revision bca4461f991170aa87d92750205a2b7ff2e29faa)
12f762695Snerun<?php
22f762695Snerun/**
32f762695Snerun * DokuWiki Plugin parserfunctions (Syntax Component)
42f762695Snerun *
52f762695Snerun * @license  GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
62f762695Snerun * @author   Daniel "Nerun" Rodrigues <danieldiasr@gmail.com>
7d0ddb69dSnerun * @created  Sat, 09 Dec 2023 14:59 -0300
82f762695Snerun *
92f762695Snerun * This is my first plugin, and I don't even know PHP well, that's why it's full
102f762695Snerun * of comments, but I'll leave it that way so I can consult it in the future.
112f762695Snerun *
122f762695Snerun */
132f762695Snerunuse dokuwiki\Extension\SyntaxPlugin;
1455bd7807Snerunuse dokuwiki\Utf8\PhpString;
152f762695Snerun
162f762695Snerunclass syntax_plugin_parserfunctions extends SyntaxPlugin
172f762695Snerun{
18d0ddb69dSnerun    /** @var helper_plugin_parserfunctions $helper */
19d0ddb69dSnerun    private $helper;
20d0ddb69dSnerun
21d0ddb69dSnerun    public function __construct() {
22d0ddb69dSnerun        $this->helper = plugin_load('helper', 'parserfunctions');
23d0ddb69dSnerun    }
24d0ddb69dSnerun
252f762695Snerun    /** @inheritDoc */
262f762695Snerun    public function getType()
272f762695Snerun    {
282f762695Snerun        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#syntax_types
292f762695Snerun         * substition  — modes where the token is simply replaced – they can not
302f762695Snerun         * contain any other modes
312f762695Snerun         */
322f762695Snerun        return 'substition';
332f762695Snerun    }
342f762695Snerun
352f762695Snerun    /** @inheritDoc */
362f762695Snerun    public function getPType()
372f762695Snerun    {
382f762695Snerun        /* READ: https://www.dokuwiki.org/devel:syntax_plugins
399f6f51efSnerun         * normal — Default value, will be used if the method is not overridden.
409f6f51efSnerun         *          The plugin output will be inside a paragraph (or another
419f6f51efSnerun         *          block element), no paragraphs will be inside.
422f762695Snerun         */
432f762695Snerun        return 'normal';
442f762695Snerun    }
452f762695Snerun
462f762695Snerun    /** @inheritDoc */
472f762695Snerun    public function getSort()
482f762695Snerun    {
492f762695Snerun        /* READ: https://www.dokuwiki.org/devel:parser:getsort_list
502f762695Snerun         * Don't understand exactly what it does, need more study.
512f762695Snerun         *
529f6f51efSnerun         * Must go after Templater (302) and WST (319) plugin, to be able to
530c9cdeecSnerun         * render @parameter@ and {{{parameter}}}.
542f762695Snerun         */
552f762695Snerun        return 320;
562f762695Snerun    }
572f762695Snerun
582f762695Snerun    /** @inheritDoc */
592f762695Snerun    public function connectTo($mode)
602f762695Snerun    {
612f762695Snerun        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#patterns
628bd4c45fSnerun         * This pattern accepts any alphanumeric function AND nested functions.
639f6f51efSnerun         *
648bd4c45fSnerun         * $this->Lexer->addSpecialPattern('\{\{#.+?#\}\}', $mode, 'plugin_parserfunctions');
658bd4c45fSnerun         * Captures nested functions up to level-1:
668bd4c45fSnerun         * $this->Lexer->addSpecialPattern('\{\{#[[:alnum:]]+:(?:(?:[^\{#]*?\{\{.*?#\}\})|.*?)+?#\}\}', $mode, 'plugin_parserfunctions');
678bd4c45fSnerun         *
688bd4c45fSnerun         * SEE action.php
692f762695Snerun         */
708bd4c45fSnerun    }
718bd4c45fSnerun
728bd4c45fSnerun    // @author  ChatGPT -- Wed, 02 jul 2025 12:04:42 -0300
738bd4c45fSnerun    public function resolveFunction($text)
748bd4c45fSnerun    {
758bd4c45fSnerun        // Remove {{# and #}} delimiters if present
768bd4c45fSnerun        if (substr($text, 0, 3) === '{{#' && substr($text, -3) === '#}}') {
778bd4c45fSnerun            $text = substr($text, 3, -3);
788bd4c45fSnerun        }
798bd4c45fSnerun
808bd4c45fSnerun        // Recursively resolves all nested functions from the inside out
818bd4c45fSnerun        $text = $this->resolveNestedFunctions($text);
828bd4c45fSnerun
838bd4c45fSnerun        // Separates function name and parameters
848bd4c45fSnerun        preg_match('/^([[:alnum:]]+):(.*)$/s', $text, $m);
858bd4c45fSnerun
868bd4c45fSnerun        if (empty($m[1])) {
878bd4c45fSnerun            return $this->helper->formatError('important', 'function_name', 'invalid_syntax');
888bd4c45fSnerun        }
898bd4c45fSnerun
908bd4c45fSnerun        $funcName = PhpString::strtolower($m[1]);
918bd4c45fSnerun        $paramsText = $m[2] ?? '';
928bd4c45fSnerun
938bd4c45fSnerun        $params = $this->helper->parseParameters($paramsText);
948bd4c45fSnerun
958bd4c45fSnerun        switch ($funcName) {
968bd4c45fSnerun            case 'if':
978bd4c45fSnerun                return $this->_IF($params, $funcName);
988bd4c45fSnerun            case 'ifeq':
998bd4c45fSnerun                return $this->_IFEQ($params, $funcName);
1008bd4c45fSnerun            case 'ifexist':
1018bd4c45fSnerun                return $this->_IFEXIST($params, $funcName);
1028bd4c45fSnerun            case 'switch':
1038bd4c45fSnerun                return $this->_SWITCH($params, $funcName);
104*bca4461fSnerun            case 'expr':
105*bca4461fSnerun                return $this->_EXPR($params, $funcName);
1068bd4c45fSnerun            default:
1078bd4c45fSnerun                return $this->helper->formatError('important', $funcName, 'no_such_function');
1088bd4c45fSnerun        }
1098bd4c45fSnerun    }
1108bd4c45fSnerun
1118bd4c45fSnerun    // @author  ChatGPT -- Wed, 02 jul 2025 12:04:42 -0300
1128bd4c45fSnerun    private function resolveNestedFunctions($text)
1138bd4c45fSnerun    {
1148bd4c45fSnerun        $offset = 0;
1158bd4c45fSnerun
1168bd4c45fSnerun        while (($start = strpos($text, '{{#', $offset)) !== false) {
1178bd4c45fSnerun            $level = 0;
1188bd4c45fSnerun            $length = strlen($text);
1198bd4c45fSnerun
1208bd4c45fSnerun            for ($i = $start; $i < $length - 2; $i++) {
1218bd4c45fSnerun                if (substr($text, $i, 3) === '{{#') {
1228bd4c45fSnerun                    $level++;
1238bd4c45fSnerun                    $i += 2;
1248bd4c45fSnerun                } elseif (substr($text, $i, 3) === '#}}') {
1258bd4c45fSnerun                    $level--;
1268bd4c45fSnerun                    $i += 2;
1278bd4c45fSnerun
1288bd4c45fSnerun                    if ($level === 0) {
1298bd4c45fSnerun                        $full = substr($text, $start, $i - $start + 1);
1308bd4c45fSnerun                        $resolved = $this->resolveFunction($full);
1318bd4c45fSnerun                        $text = substr_replace($text, $resolved, $start, strlen($full));
1328bd4c45fSnerun                        // Start from the beginning because the text has changed
1338bd4c45fSnerun                        $offset = 0;
1348bd4c45fSnerun                        continue 2;
1358bd4c45fSnerun                    }
1368bd4c45fSnerun                }
1378bd4c45fSnerun            }
1388bd4c45fSnerun
1398bd4c45fSnerun            // If you got here, invalid syntax (no #}})
1408bd4c45fSnerun            break;
1418bd4c45fSnerun        }
1428bd4c45fSnerun
1438bd4c45fSnerun        return $text;
1442f762695Snerun    }
1452f762695Snerun
1462f762695Snerun    /** @inheritDoc */
1478bd4c45fSnerun    public function handle($match, $state, $pos, Doku_Handler $handler) {
1488bd4c45fSnerun        /* This method is only called if the Lexer, in the connectTo() method,
1498bd4c45fSnerun         * finds a $match.
1508bd4c45fSnerun         *
1518bd4c45fSnerun         * READ: https://www.dokuwiki.org/devel:syntax_plugins#handle_method
1522f762695Snerun         * This is the part of your plugin which should do all the work. Before
1532f762695Snerun         * DokuWiki renders the wiki page it creates a list of instructions for
1542f762695Snerun         * the renderer. The plugin's handle() method generates the render
1552f762695Snerun         * instructions for the plugin's own syntax mode. At some later time,
1562f762695Snerun         * these will be interpreted by the plugin's render() method.
1572f762695Snerun         *
1582f762695Snerun         * Parameters:
1592f762695Snerun         *
1602f762695Snerun         *   $match   (string)  — The text matched by the patterns
1612f762695Snerun         *
1629f6f51efSnerun         *   $state   (int)     — The lexer state for the match, representing
1639f6f51efSnerun         *                        the type of pattern which triggered this call
1649f6f51efSnerun         *                        to handle(): DOKU_LEXER_SPECIAL — a pattern
1659f6f51efSnerun         *                        set by addSpecialPattern().
1662f762695Snerun         *
1672f762695Snerun         *   $pos     (int)     — The character position of the matched text.
1682f762695Snerun         *
1699f6f51efSnerun         *   $handler           — Object Reference to the Doku_Handler object.
1702f762695Snerun         */
1718bd4c45fSnerun        return $this->resolveFunction($match);
1722f762695Snerun    }
1732f762695Snerun
1742f762695Snerun    /** @inheritDoc */
1752f762695Snerun    public function render($mode, Doku_Renderer $renderer, $data)
1762f762695Snerun    {
1772f762695Snerun        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#render_method
1789f6f51efSnerun         * The part of the plugin that provides the output for the final web
1799f6f51efSnerun         * page.
1802f762695Snerun         *
1812f762695Snerun         * Parameters:
1822f762695Snerun         *
1839f6f51efSnerun         *   $mode     — Name for the format mode of the final output produced
1849f6f51efSnerun         *               by the renderer.
1852f762695Snerun         *
1869f6f51efSnerun         *   $renderer — Give access to the object Doku_Renderer, which contains
1879f6f51efSnerun         *               useful functions and values.
1882f762695Snerun         *
1899f6f51efSnerun         *   $data     — An array containing the instructions previously
1909f6f51efSnerun         *               prepared and returned by the plugin's own handle()
1919f6f51efSnerun         *               method. The render() must interpret the instruction and
1929f6f51efSnerun         *               generate the appropriate output.
1932f762695Snerun         */
1942f762695Snerun
1952f762695Snerun        if ($mode !== 'xhtml') {
1962f762695Snerun            return false;
1972f762695Snerun        }
1982f762695Snerun
1992f762695Snerun        if (!$data) {
2002f762695Snerun            return false;
2012f762695Snerun        }
2022f762695Snerun
2030c9cdeecSnerun        // escape sequences
204d0ddb69dSnerun        $data = $this->helper->processEscapes($data);
2050c9cdeecSnerun
2062f762695Snerun        // Do not use <div></div> because we need inline substitution!
207bf533778Snerun		$data = $renderer->render_text($data, 'xhtml');
208fb66d8abSnerun		// Remove the first '<p>' and the last '</p>'
209fb66d8abSnerun		if (substr($data, 0, 3) === '<p>' && substr($data, -4) === '</p>') {
210fb66d8abSnerun            $data = substr($data, 3, -4);
211fb66d8abSnerun        }
2122f762695Snerun		$renderer->doc .= $data;
2132f762695Snerun
2142f762695Snerun        return true;
2152f762695Snerun    }
21655bd7807Snerun
21755bd7807Snerun    /**
21855bd7807Snerun     * ========== #IF
2193e9f31f7Snerun     * {{#if: 1st parameter | 2nd parameter | 3rd parameter #}}
2209f6f51efSnerun     * {{#if: test string | value if test string is not empty | value if test
2219f6f51efSnerun     * string is empty (or only white space) #}}
22255bd7807Snerun     */
223*bca4461fSnerun    function _IF($params, $funcName)
22455bd7807Snerun    {
2253e9f31f7Snerun        if ( count($params) < 1 ) {
226*bca4461fSnerun            $result = $this->helper->formatError('alert', $funcName, 'not_enough_params');
22755bd7807Snerun        } else {
22855bd7807Snerun            if ( !empty($params[0]) ) {
229fb66d8abSnerun                $result = $params[1] ?? '';
230877148aeSnerun            } else {
231fb66d8abSnerun                $result = $params[2] ?? '';
23255bd7807Snerun            }
233877148aeSnerun        }
234877148aeSnerun
235877148aeSnerun        return $result;
236877148aeSnerun    }
237877148aeSnerun
238877148aeSnerun    /**
239877148aeSnerun     * ========== #IFEQ
240877148aeSnerun     * {{#ifeq: 1st parameter | 2nd parameter | 3rd parameter | 4th parameter #}}
241877148aeSnerun     * {{#ifeq: string 1 | string 2 | value if identical | value if different #}}
242877148aeSnerun     */
243*bca4461fSnerun    function _IFEQ($params, $funcName)
244877148aeSnerun    {
2453e9f31f7Snerun        if ( count($params) < 2 ) {
246*bca4461fSnerun            $result = $this->helper->formatError('alert', $funcName, 'not_enough_params');
247877148aeSnerun        } else {
248877148aeSnerun            if ( $params[0] == $params[1] ) {
249fb66d8abSnerun                $result = $params[2] ?? '';
250877148aeSnerun            } else {
251fb66d8abSnerun                $result = $params[3] ?? '';
2523e9f31f7Snerun            }
2533e9f31f7Snerun        }
2543e9f31f7Snerun
2553e9f31f7Snerun        return $result;
2563e9f31f7Snerun    }
2573e9f31f7Snerun
2583e9f31f7Snerun    /**
259fb66d8abSnerun     * ======= #IFEXIST
260fb66d8abSnerun     * Syntax: {{#ifexist: target | if-true | if-false #}}
261fb66d8abSnerun     *
262fb66d8abSnerun     * Accepts:
263fb66d8abSnerun     * - DokuWiki page/media IDs (e.g. "wiki:start", "wiki:image.png")
264fb66d8abSnerun     * - Namespaces (must end with ":", e.g. "wiki:")
265fb66d8abSnerun     * - Absolute or relative filesystem paths
266fb66d8abSnerun     *
267fb66d8abSnerun     * @param array $params [
268fb66d8abSnerun     *     0 => string $target   Path or ID to check (required)
269fb66d8abSnerun     *     1 => string $ifTrue   Value to return if target exists (optional)
270fb66d8abSnerun     *     2 => string $ifFalse  Value to return if target doesn't exist (optional)
271fb66d8abSnerun     * ]
272*bca4461fSnerun     * @param string $funcName Name of the parser function (for error messages)
273fb66d8abSnerun     * @return string Rendered output based on existence check
2743e9f31f7Snerun     */
275*bca4461fSnerun    function _IFEXIST($params, $funcName)
2763e9f31f7Snerun    {
2773e9f31f7Snerun        if (count($params) < 1) {
278*bca4461fSnerun            return $this->helper->formatError('alert', $funcName, 'not_enough_params');
2795f65861aSnerun        }
2805f65861aSnerun
281d0ddb69dSnerun        $target = trim($params[0]);
282d0ddb69dSnerun        if ($target === '') {
283*bca4461fSnerun            return $this->helper->formatError('alert', $funcName, 'empty_test_parameter');
284877148aeSnerun        }
28555bd7807Snerun
286d0ddb69dSnerun        $exists = $this->helper->checkExistence($target);
287d0ddb69dSnerun
288d0ddb69dSnerun        return $exists
289d0ddb69dSnerun            ? ($params[1] ?? '')
290d0ddb69dSnerun            : ($params[2] ?? '');
29155bd7807Snerun    }
2921c0304a1Snerun
2931c0304a1Snerun    /**
2941c0304a1Snerun     * ========== #SWITCH
2951c0304a1Snerun     * {{#switch: comparison string
296d0ddb69dSnerun     * | case1 = result1
2971c0304a1Snerun     * | ...
298d0ddb69dSnerun     * | caseN = resultN
2991c0304a1Snerun     * | default result
3001c0304a1Snerun     * #}}
3011c0304a1Snerun     */
302*bca4461fSnerun    function _SWITCH($params, $funcName) {
3033e9f31f7Snerun        if (count($params) < 2) {
304*bca4461fSnerun            return $this->helper->formatError('alert', $funcName, 'not_enough_params');
305643981a6Snerun        }
306d0ddb69dSnerun
307d0ddb69dSnerun        $parsed = $this->helper->parseSwitchCases($params);
308d0ddb69dSnerun
309d0ddb69dSnerun        // Checks if the test string exists as a key in the switch cases array
310d0ddb69dSnerun        if (array_key_exists($parsed['test'], $parsed['cases'])) {
311d0ddb69dSnerun            return $parsed['cases'][$parsed['test']]; // ← May return empty string
312d0ddb69dSnerun        }
313d0ddb69dSnerun
314d0ddb69dSnerun        // Returns the default (explicit or implicit) only if the case does not exist
315d0ddb69dSnerun        return $parsed['default'] ?? '';
316643981a6Snerun    }
317*bca4461fSnerun
318*bca4461fSnerun    /**
319*bca4461fSnerun     * ========== #EXPR
320*bca4461fSnerun     * This function evaluates a mathematical expression and returns the
321*bca4461fSnerun     * calculated value.
322*bca4461fSnerun     */
323*bca4461fSnerun    private function _EXPR($params, $funcName) {
324*bca4461fSnerun        if (!isset($params[0])) {
325*bca4461fSnerun            return $this->helper->formatError('alert', $funcName, 'empty_test_parameter');
326*bca4461fSnerun        }
327*bca4461fSnerun
328*bca4461fSnerun        return $this->helper->evaluateMathExpression($params[0]);
329*bca4461fSnerun    }
330643981a6Snerun}
331643981a6Snerun
332