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 /** @var helper_plugin_parserfunctions $helper */ 19 private $helper; 20 21 public function __construct() { 22 $this->helper = plugin_load('helper', 'parserfunctions'); 23 } 24 25 /** @inheritDoc */ 26 public function getType() 27 { 28 /* READ: https://www.dokuwiki.org/devel:syntax_plugins#syntax_types 29 * substition — modes where the token is simply replaced – they can not 30 * contain any other modes 31 */ 32 return 'substition'; 33 } 34 35 /** @inheritDoc */ 36 public function getPType() 37 { 38 /* READ: https://www.dokuwiki.org/devel:syntax_plugins 39 * normal — Default value, will be used if the method is not overridden. 40 * The plugin output will be inside a paragraph (or another 41 * block element), no paragraphs will be inside. 42 */ 43 return 'normal'; 44 } 45 46 /** @inheritDoc */ 47 public function getSort() 48 { 49 /* READ: https://www.dokuwiki.org/devel:parser:getsort_list 50 * Don't understand exactly what it does, need more study. 51 * 52 * Must go after Templater (302) and WST (319) plugin, to be able to 53 * render @parameter@ and {{{parameter}}}. 54 */ 55 return 320; 56 } 57 58 /** @inheritDoc */ 59 public function connectTo($mode) 60 { 61 /* READ: https://www.dokuwiki.org/devel:syntax_plugins#patterns 62 * This pattern accepts any alphanumeric function AND nested functions. 63 * 64 * $this->Lexer->addSpecialPattern('\{\{#.+?#\}\}', $mode, 'plugin_parserfunctions'); 65 * Captures nested functions up to level-1: 66 * $this->Lexer->addSpecialPattern('\{\{#[[:alnum:]]+:(?:(?:[^\{#]*?\{\{.*?#\}\})|.*?)+?#\}\}', $mode, 'plugin_parserfunctions'); 67 * 68 * SEE action.php 69 */ 70 } 71 72 // @author ChatGPT -- Wed, 02 jul 2025 12:04:42 -0300 73 public function resolveFunction($text) 74 { 75 // Remove {{# and #}} delimiters if present 76 if (substr($text, 0, 3) === '{{#' && substr($text, -3) === '#}}') { 77 $text = substr($text, 3, -3); 78 } 79 80 // Recursively resolves all nested functions from the inside out 81 $text = $this->resolveNestedFunctions($text); 82 83 // Separates function name and parameters 84 preg_match('/^([[:alnum:]]+):(.*)$/s', $text, $m); 85 86 if (empty($m[1])) { 87 return $this->helper->formatError('important', 'function_name', 'invalid_syntax'); 88 } 89 90 $funcName = PhpString::strtolower($m[1]); 91 $paramsText = $m[2] ?? ''; 92 93 $params = $this->helper->parseParameters($paramsText); 94 95 switch ($funcName) { 96 case 'if': 97 return $this->_IF($params, $funcName); 98 case 'ifeq': 99 return $this->_IFEQ($params, $funcName); 100 case 'ifexist': 101 return $this->_IFEXIST($params, $funcName); 102 case 'switch': 103 return $this->_SWITCH($params, $funcName); 104 case 'expr': 105 return $this->_EXPR($params, $funcName); 106 default: 107 return $this->helper->formatError('important', $funcName, 'no_such_function'); 108 } 109 } 110 111 // @author ChatGPT -- Wed, 02 jul 2025 12:04:42 -0300 112 private function resolveNestedFunctions($text) 113 { 114 $offset = 0; 115 116 while (($start = strpos($text, '{{#', $offset)) !== false) { 117 $level = 0; 118 $length = strlen($text); 119 120 for ($i = $start; $i < $length - 2; $i++) { 121 if (substr($text, $i, 3) === '{{#') { 122 $level++; 123 $i += 2; 124 } elseif (substr($text, $i, 3) === '#}}') { 125 $level--; 126 $i += 2; 127 128 if ($level === 0) { 129 $full = substr($text, $start, $i - $start + 1); 130 $resolved = $this->resolveFunction($full); 131 $text = substr_replace($text, $resolved, $start, strlen($full)); 132 // Start from the beginning because the text has changed 133 $offset = 0; 134 continue 2; 135 } 136 } 137 } 138 139 // If you got here, invalid syntax (no #}}) 140 break; 141 } 142 143 return $text; 144 } 145 146 /** @inheritDoc */ 147 public function handle($match, $state, $pos, Doku_Handler $handler) { 148 /* This method is only called if the Lexer, in the connectTo() method, 149 * finds a $match. 150 * 151 * READ: https://www.dokuwiki.org/devel:syntax_plugins#handle_method 152 * This is the part of your plugin which should do all the work. Before 153 * DokuWiki renders the wiki page it creates a list of instructions for 154 * the renderer. The plugin's handle() method generates the render 155 * instructions for the plugin's own syntax mode. At some later time, 156 * these will be interpreted by the plugin's render() method. 157 * 158 * Parameters: 159 * 160 * $match (string) — The text matched by the patterns 161 * 162 * $state (int) — The lexer state for the match, representing 163 * the type of pattern which triggered this call 164 * to handle(): DOKU_LEXER_SPECIAL — a pattern 165 * set by addSpecialPattern(). 166 * 167 * $pos (int) — The character position of the matched text. 168 * 169 * $handler — Object Reference to the Doku_Handler object. 170 */ 171 return $this->resolveFunction($match); 172 } 173 174 /** @inheritDoc */ 175 public function render($mode, Doku_Renderer $renderer, $data) 176 { 177 /* READ: https://www.dokuwiki.org/devel:syntax_plugins#render_method 178 * The part of the plugin that provides the output for the final web 179 * page. 180 * 181 * Parameters: 182 * 183 * $mode — Name for the format mode of the final output produced 184 * by the renderer. 185 * 186 * $renderer — Give access to the object Doku_Renderer, which contains 187 * useful functions and values. 188 * 189 * $data — An array containing the instructions previously 190 * prepared and returned by the plugin's own handle() 191 * method. The render() must interpret the instruction and 192 * generate the appropriate output. 193 */ 194 195 if ($mode !== 'xhtml') { 196 return false; 197 } 198 199 if (!$data) { 200 return false; 201 } 202 203 // escape sequences 204 $data = $this->helper->processEscapes($data); 205 206 // Do not use <div></div> because we need inline substitution! 207 $data = $renderer->render_text($data, 'xhtml'); 208 // Remove the first '<p>' and the last '</p>' 209 if (substr($data, 0, 3) === '<p>' && substr($data, -4) === '</p>') { 210 $data = substr($data, 3, -4); 211 } 212 $renderer->doc .= $data; 213 214 return true; 215 } 216 217 /** 218 * ========== #IF 219 * {{#if: 1st parameter | 2nd parameter | 3rd parameter #}} 220 * {{#if: test string | value if test string is not empty | value if test 221 * string is empty (or only white space) #}} 222 */ 223 function _IF($params, $funcName) 224 { 225 if ( count($params) < 1 ) { 226 $result = $this->helper->formatError('alert', $funcName, 'not_enough_params'); 227 } else { 228 if ( !empty($params[0]) ) { 229 $result = $params[1] ?? ''; 230 } else { 231 $result = $params[2] ?? ''; 232 } 233 } 234 235 return $result; 236 } 237 238 /** 239 * ========== #IFEQ 240 * {{#ifeq: 1st parameter | 2nd parameter | 3rd parameter | 4th parameter #}} 241 * {{#ifeq: string 1 | string 2 | value if identical | value if different #}} 242 */ 243 function _IFEQ($params, $funcName) 244 { 245 if ( count($params) < 2 ) { 246 $result = $this->helper->formatError('alert', $funcName, 'not_enough_params'); 247 } else { 248 if ( $params[0] == $params[1] ) { 249 $result = $params[2] ?? ''; 250 } else { 251 $result = $params[3] ?? ''; 252 } 253 } 254 255 return $result; 256 } 257 258 /** 259 * ======= #IFEXIST 260 * Syntax: {{#ifexist: target | if-true | if-false #}} 261 * 262 * Accepts: 263 * - DokuWiki page/media IDs (e.g. "wiki:start", "wiki:image.png") 264 * - Namespaces (must end with ":", e.g. "wiki:") 265 * - Absolute or relative filesystem paths 266 * 267 * @param array $params [ 268 * 0 => string $target Path or ID to check (required) 269 * 1 => string $ifTrue Value to return if target exists (optional) 270 * 2 => string $ifFalse Value to return if target doesn't exist (optional) 271 * ] 272 * @param string $funcName Name of the parser function (for error messages) 273 * @return string Rendered output based on existence check 274 */ 275 function _IFEXIST($params, $funcName) 276 { 277 if (count($params) < 1) { 278 return $this->helper->formatError('alert', $funcName, 'not_enough_params'); 279 } 280 281 $target = trim($params[0]); 282 if ($target === '') { 283 return $this->helper->formatError('alert', $funcName, 'empty_test_parameter'); 284 } 285 286 $exists = $this->helper->checkExistence($target); 287 288 return $exists 289 ? ($params[1] ?? '') 290 : ($params[2] ?? ''); 291 } 292 293 /** 294 * ========== #SWITCH 295 * {{#switch: comparison string 296 * | case1 = result1 297 * | ... 298 * | caseN = resultN 299 * | default result 300 * #}} 301 */ 302 function _SWITCH($params, $funcName) { 303 if (count($params) < 2) { 304 return $this->helper->formatError('alert', $funcName, 'not_enough_params'); 305 } 306 307 $parsed = $this->helper->parseSwitchCases($params); 308 309 // Checks if the test string exists as a key in the switch cases array 310 if (array_key_exists($parsed['test'], $parsed['cases'])) { 311 return $parsed['cases'][$parsed['test']]; // ← May return empty string 312 } 313 314 // Returns the default (explicit or implicit) only if the case does not exist 315 return $parsed['default'] ?? ''; 316 } 317 318 /** 319 * ========== #EXPR 320 * This function evaluates a mathematical expression and returns the 321 * calculated value. 322 */ 323 private function _EXPR($params, $funcName) { 324 if (!isset($params[0])) { 325 return $this->helper->formatError('alert', $funcName, 'empty_test_parameter'); 326 } 327 328 return $this->helper->evaluateMathExpression($params[0]); 329 } 330} 331 332