From 6a56d59f5d8e9377fbe299b7c7121f81f485d43f Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 26 Feb 2026 09:42:07 +0600 Subject: [PATCH 1/2] feat(parser): JSON properties, [[~context]] links, [[@if]] conditional blocks - modParser: parsePropertyString() accepts JSON object/array for element properties - modParser: processConditionalBlocks() for [[@if (expr)]]...[[@else]]...[[@endif]] - modLinkTag: [[~contextKey]] resolves to context site_start URL (e.g. [[~web]]) - Add tests: testParsePropertiesJson, testConditionalBlocks, testLinkTagContextKey Resolves #15200 --- _build/test/Tests/Model/modParserTest.php | 71 ++++++ core/src/Revolution/modLinkTag.php | 53 ++++- core/src/Revolution/modParser.php | 270 ++++++++++++++++++++++ 3 files changed, 384 insertions(+), 10 deletions(-) diff --git a/_build/test/Tests/Model/modParserTest.php b/_build/test/Tests/Model/modParserTest.php index eee4359aef9..b7149a3c7f0 100644 --- a/_build/test/Tests/Model/modParserTest.php +++ b/_build/test/Tests/Model/modParserTest.php @@ -1060,9 +1060,80 @@ public function providerParsePropertyString() { " &property=`value with nested backticks```", true ], + [ + ['prop' => 'value', 'prop2' => 100], + '{"prop":"value","prop2":100}', + true + ], + [ + [ + 'prop' => [ + 'name' => 'prop', + 'desc' => '', + 'type' => 'textfield', + 'options' => [], + 'value' => 'value' + ], + 'prop2' => [ + 'name' => 'prop2', + 'desc' => '', + 'type' => 'textfield', + 'options' => [], + 'value' => 100 + ], + ], + '{"prop":"value","prop2":100}', + false + ], ]; } + /** + * Test modParser->parseProperties() with JSON string. + */ + public function testParsePropertiesJson() + { + $parser = $this->modx->parser; + $props = $parser->parseProperties('{"a":1,"b":"two"}'); + $this->assertIsArray($props); + $this->assertSame(1, $props['a']); + $this->assertSame('two', $props['b']); + } + + /** + * Test [[@if]]...[[@else]]...[[@endif]] conditional blocks. + */ + public function testConditionalBlocks() + { + $content = '[[@if ($modx->user->id == 1)]]yes[[@else]]no[[@endif]]'; + $this->modx->parser->processElementTags('', $content, true, false, '[[', ']]', [], 1); + $userId = $this->modx->user ? (int) $this->modx->user->get('id') : 0; + $this->assertSame($userId === 1 ? 'yes' : 'no', trim($content)); + } + + /** + * Test [[@if]] without [[@else]] - empty when false. + */ + public function testConditionalBlocksNoElse() + { + $content = '[[@if ($modx->user->id == 99999)]]show[[@endif]]'; + $this->modx->parser->processElementTags('', $content, true, false, '[[', ']]', [], 1); + $this->assertSame('', trim($content)); + } + + /** + * Test [[~contextKey]] link tag (e.g. [[~web]]) resolves to context site_start URL or empty. + */ + public function testLinkTagContextKey() + { + $content = '[[~web]]'; + $this->modx->parser->processElementTags('', $content, true, false, '[[', ']]', [], 1); + $this->assertIsString($content); + if ($content !== '' && $content !== '[[~web]]') { + $this->assertStringContainsString('/', $content, 'Context link should produce a URL'); + } + } + /** * Test modParser->realname(). * diff --git a/core/src/Revolution/modLinkTag.php b/core/src/Revolution/modLinkTag.php index 0026090b7b0..ac935b4beb6 100644 --- a/core/src/Revolution/modLinkTag.php +++ b/core/src/Revolution/modLinkTag.php @@ -1,4 +1,5 @@ setToken('~'); } @@ -36,11 +38,10 @@ function __constructor(modX & $modx) */ public function process($properties = null, $content = null) { - parent:: process($properties, $content); + parent::process($properties, $content); if (!$this->_processed) { $this->_output = $this->_content; - if (is_string($this->_output) && !empty ($this->_output)) { - /* collect element tags in the content and process them */ + if (is_string($this->_output) && !empty($this->_output)) { $maxIterations = intval($this->modx->getOption('parser_max_iterations', null, 10)); $this->modx->parser->processElementTags( $this->_tag, @@ -52,8 +53,8 @@ public function process($properties = null, $content = null) [], $maxIterations ); - $context = ''; - if ($this->modx->getOption('friendly_urls', null, false)) { + $context = $this->resolveContextLinkTarget(); + if ($this->modx->getOption('friendly_urls', null, false) && $context === '' && !empty($this->_output)) { if (array_key_exists('context', $this->_properties)) { $context = $this->_properties['context']; } @@ -116,8 +117,40 @@ public function process($properties = null, $content = null) } } - /* finally, return the processed element content */ - return $this->_output; } + + /** + * Resolves [[~contextKey]] to site_start resource id and returns the context key. + * If link target is numeric or empty, leaves _output unchanged and returns ''. + * + * @return string Context key or empty string + */ + private function resolveContextLinkTarget() + { + $linkTarget = $this->_output; + if (is_numeric($linkTarget) || $linkTarget === '') { + return ''; + } + $ctx = $this->modx->getContext($linkTarget); + if (!$ctx instanceof modContext) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + 'Link tag unknown context: `' . $this->_tag . '`' + ); + $this->_output = ''; + return ''; + } + $siteStart = $ctx->getOption('site_start'); + if ($siteStart !== null && $siteStart !== '') { + $this->_output = (string) $siteStart; + return $linkTarget; + } + $this->modx->log( + modX::LOG_LEVEL_WARN, + 'Link tag context has no site_start: `' . $this->_tag . '`' + ); + $this->_output = ''; + return ''; + } } diff --git a/core/src/Revolution/modParser.php b/core/src/Revolution/modParser.php index 1bd2024215b..25c23094af2 100644 --- a/core/src/Revolution/modParser.php +++ b/core/src/Revolution/modParser.php @@ -200,6 +200,7 @@ public function processElementTags($parentTag, & $content, $processUncacheable= $this->modx->invokeEvent('OnParseDocument', ['content' => &$content]); $content = $this->modx->documentOutput; unset($this->modx->documentOutput); + $this->processConditionalBlocks($content); if ($collected= $this->collectElementTags($content, $tags, $prefix, $suffix, $tokens)) { $tagMap= []; foreach ($tags as $tag) { @@ -254,6 +255,253 @@ public function mergeTagOutput(array $tagMap, & $content) { } } + /** + * Processes [[@if (expr)]]...[[@else]]...[[@endif]] blocks in content. + * Replaces each block with the content or else branch based on the evaluated expression. + * + * @param string $content Content to process (by reference). + */ + protected function processConditionalBlocks(&$content) + { + $prefix = '[['; + $suffix = ']]'; + $ifOpen = '[[@if'; + $elseTag = '[[@else]]'; + $endifTag = '[[@endif]]'; + + while (($ifPos = strpos($content, $ifOpen)) !== false) { + $exprStart = $ifPos + strlen($ifOpen); + $parenOpen = strpos($content, '(', $exprStart); + if ($parenOpen === false || $parenOpen > $exprStart + 2) { + break; + } + $parenClose = $this->findMatchingParen($content, $parenOpen); + if ($parenClose === false) { + break; + } + $expr = trim(substr($content, $parenOpen + 1, $parenClose - $parenOpen - 1)); + $tagEnd = strpos($content, $suffix, $parenClose); + if ($tagEnd === false) { + break; + } + $blockStart = $tagEnd + strlen($suffix); + $depth = 1; + $elsePos = false; + $searchStart = $blockStart; + $endifPos = false; + + while ($depth > 0) { + $nextIf = strpos($content, $ifOpen, $searchStart); + $nextElse = strpos($content, $elseTag, $searchStart); + $nextEndif = strpos($content, $endifTag, $searchStart); + $nearestIf = $nextIf !== false ? $nextIf : PHP_INT_MAX; + $nearestEndif = $nextEndif !== false ? $nextEndif : PHP_INT_MAX; + if ($nextElse !== false && $nextElse < $nearestIf && $nextElse < $nearestEndif && $depth === 1) { + $elsePos = $nextElse; + $searchStart = $nextElse + strlen($elseTag); + continue; + } + if ($nextIf !== false && ($nextEndif === false || $nextIf < $nextEndif)) { + $depth++; + $searchStart = $nextIf + strlen($ifOpen); + continue; + } + if ($nextEndif !== false) { + $depth--; + if ($depth === 0) { + $endifPos = $nextEndif; + } + $searchStart = $nextEndif + strlen($endifTag); + continue; + } + break; + } + + if ($endifPos === false) { + break; + } + + $condition = $this->evaluateConditionalExpression($expr); + if ($elsePos !== false) { + $contentBlock = substr($content, $blockStart, $elsePos - $blockStart); + $elseBlock = substr($content, $elsePos + strlen($elseTag), $endifPos - ($elsePos + strlen($elseTag))); + $replacement = $condition ? $contentBlock : $elseBlock; + } else { + $contentBlock = substr($content, $blockStart, $endifPos - $blockStart); + $replacement = $condition ? $contentBlock : ''; + } + $fullBlock = substr($content, $ifPos, $endifPos - $ifPos + strlen($endifTag)); + $content = str_replace($fullBlock, $replacement, $content); + } + } + + /** + * Finds the position of the closing parenthesis matching the open paren at $openPos. + * + * @param string $content + * @param int $openPos + * @return int|false + */ + protected function findMatchingParen($content, $openPos) + { + $depth = 1; + $len = strlen($content); + for ($i = $openPos + 1; $i < $len; $i++) { + $c = $content[$i]; + if ($c === '(') { + $depth++; + } elseif ($c === ')') { + $depth--; + if ($depth === 0) { + return $i; + } + } + } + return false; + } + + /** + * Evaluates a conditional expression using a whitelist (no eval). + * Supports: $modx->user->id, $modx->resource->field, ==, !=, >, <, >=, <=, empty(), isset(). + * + * @param string $expr Expression string, e.g. "$modx->user->id == 1" + * @return bool + */ + protected function evaluateConditionalExpression($expr) + { + $expr = trim($expr); + if ($expr === '') { + return false; + } + if (preg_match('/^empty\s*\(\s*(\$modx->(?:user|resource)(?:->\w+)*)\s*\)$/u', $expr, $m)) { + $val = $this->getWhitelistedValue(trim($m[1])); + return empty($val); + } + if (preg_match('/^isset\s*\(\s*(\$modx->(?:user|resource)(?:->\w+)*)\s*\)$/u', $expr, $m)) { + $val = $this->getWhitelistedValue(trim($m[1])); + return isset($val); + } + if (preg_match('/^(\$modx->(?:user|resource)(?:->\w+)*)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/us', $expr, $m)) { + $left = $this->getWhitelistedValue(trim($m[1])); + $right = $this->parseConditionalComparisonValue(trim($m[3])); + $op = $m[2]; + switch ($op) { + case '==': + return $left == $right; + case '===': + return $left === $right; + case '!=': + return $left != $right; + case '!==': + return $left !== $right; + case '>': + return $left > $right; + case '<': + return $left < $right; + case '>=': + return $left >= $right; + case '<=': + return $left <= $right; + default: + return false; + } + } + $val = $this->getWhitelistedValueFromExpression($expr); + if ($val !== null) { + return (bool) $val; + } + return false; + } + + /** + * Parses the right-hand side of a conditional comparison (number, quoted string, true/false/null). + * + * @param string $right Raw string from expression + * @return mixed + */ + protected function parseConditionalComparisonValue($right) + { + $len = strlen($right); + $isQuoted = $len >= 2 + && (($right[0] === "'" && $right[$len - 1] === "'") || ($right[0] === '"' && $right[$len - 1] === '"')); + if ($isQuoted) { + $right = substr($right, 1, -1); + } + if (is_numeric($right)) { + return strpos($right, '.') !== false ? (float) $right : (int) $right; + } + $lower = strtolower($right); + if ($lower === 'true') { + return true; + } + if ($lower === 'false') { + return false; + } + if ($lower === 'null') { + return null; + } + return $right; + } + + /** + * Returns the value of a whitelisted path like $modx->user->id or $modx->resource->pagetitle. + * + * @param string $path + * @return mixed + */ + protected function getWhitelistedValue($path) + { + return $this->getWhitelistedValueFromExpression($path); + } + + /** + * Resolves whitelisted variable path to value. Only $modx->user->* and $modx->resource->* allowed. + * + * @param string $expr + * @return mixed|null + */ + protected function getWhitelistedValueFromExpression($expr) + { + $expr = trim($expr); + if (!preg_match('/^\$modx->(user|resource)(->\w+)*$/u', $expr)) { + return null; + } + $parts = array_filter(explode('->', str_replace('$modx->', '', $expr))); + if (empty($parts)) { + return null; + } + $obj = $this->modx; + foreach ($parts as $part) { + if ($obj === null) { + return null; + } + if (is_object($obj) && isset($obj->$part)) { + $obj = $obj->$part; + } elseif (is_array($obj) && array_key_exists($part, $obj)) { + $obj = $obj[$part]; + } else { + return null; + } + } + return $obj; + } + + /** + * Returns true if the string looks like a JSON object or array (starts with { or [). + * + * @param string $string Property string to check. + * @return bool + */ + protected function isJsonPropertyString($string) + { + if (!is_string($string) || $string === '') { + return false; + } + $trimmed = trim($string); + $first = substr($trimmed, 0, 1); + return $first === '{' || $first === '['; + } + /** * Parses an element/tag property string or array definition. * @@ -282,6 +530,7 @@ public function parseProperties($propSource) { /** * Parses an element/tag property string and returns an array of properties. + * Supports JSON object syntax, e.g. {"prop":"value","prop2":100}. * * @param string $string The property string to parse. * @param boolean $valuesOnly Indicates only the property value should be @@ -290,6 +539,27 @@ public function parseProperties($propSource) { */ public function parsePropertyString($string, $valuesOnly = false) { $properties = []; + if ($this->isJsonPropertyString($string)) { + $decoded = json_decode(trim($string), true); + if (is_array($decoded)) { + foreach ($decoded as $k => $v) { + if (is_scalar($v) || $v === null) { + if ($valuesOnly) { + $properties[$k] = $v; + } else { + $properties[$k] = [ + 'name' => $k, + 'desc' => '', + 'type' => 'textfield', + 'options' => [], + 'value' => $v, + ]; + } + } + } + return $properties; + } + } $tagProps= xPDO :: escSplit("&", $string); foreach ($tagProps as $prop) { $property= xPDO :: escSplit('=', $prop); From 72d708c7d09ecc1b779cd684eaf7ec55ab78a346 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 26 Feb 2026 10:27:24 +0600 Subject: [PATCH 2/2] refactor(modParserTest): improve URL validation for context links - Updated the assertion for context links to check for the presence of a path or query string in the generated URL, enhancing the test's reliability. --- _build/test/Tests/Model/modParserTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/_build/test/Tests/Model/modParserTest.php b/_build/test/Tests/Model/modParserTest.php index b7149a3c7f0..28b4fcc0597 100644 --- a/_build/test/Tests/Model/modParserTest.php +++ b/_build/test/Tests/Model/modParserTest.php @@ -1130,7 +1130,8 @@ public function testLinkTagContextKey() $this->modx->parser->processElementTags('', $content, true, false, '[[', ']]', [], 1); $this->assertIsString($content); if ($content !== '' && $content !== '[[~web]]') { - $this->assertStringContainsString('/', $content, 'Context link should produce a URL'); + $hasPathOrQuery = strpos($content, '/') !== false || strpos($content, '?') !== false; + $this->assertTrue($hasPathOrQuery, 'Context link should produce a URL (path or query string)'); } }