From 1fe7c77fbfe0766683a45926acf19bc6cedc7274 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 12:37:29 -0500 Subject: [PATCH] feat(conditional-content): {show}/{hide} tag processing with conditions engine - processConditionalTags() handles {show}, {hide}, {else} blocks - Supports condition="alias_or_id" for saved condition sets - Supports inline rules: access_level, user_group, menu_item, home_page, date, day, url - Multiple inline rules per tag use AND logic - Nested tag support (processes innermost first, up to 10 iterations) - Runs in onContentPrepare (articles) and onAfterRender (final HTML) - ConditionsHelper: added passByAlias(), resolveAlias(), evaluateInlineRule() - Implements #164 --- .../admin/src/Helper/ConditionsHelper.php | 68 +++++ .../Extension/MokoSuiteClient.php | 270 ++++++++++++++++++ 2 files changed, 338 insertions(+) diff --git a/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php b/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php index cc6b04e6..4c9964d1 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php +++ b/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php @@ -420,6 +420,74 @@ class ConditionsHelper return true; } + /** + * Evaluate a condition by its alias string. + * + * @param string $alias The condition alias. + * + * @return bool True when the condition passes. + * + * @since 02.48.00 + */ + public static function passByAlias(string $alias): bool + { + $id = self::resolveAlias($alias); + + if ($id === null) { + return false; + } + + return self::pass($id); + } + + /** + * Resolve a condition reference that may be an integer ID or an alias string. + * + * @param string $ref The reference (numeric ID or alias). + * + * @return int|null The condition ID, or null if not found. + * + * @since 02.48.00 + */ + public static function resolveAlias(string $ref): ?int + { + if (is_numeric($ref)) { + return (int) $ref; + } + + $db = Factory::getContainer()->get('DatabaseDriver'); + + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokosuiteclient_conditions')) + ->where($db->quoteName('alias') . ' = :alias') + ->bind(':alias', $ref); + + $id = $db->setQuery($query)->loadResult(); + + return $id !== null ? (int) $id : null; + } + + /** + * Evaluate a single inline rule (public wrapper around passRule). + * + * @param string $type The rule type (e.g. 'visitor__access_level'). + * @param object $params The rule params object. + * + * @return bool + * + * @since 02.48.00 + */ + public static function evaluateInlineRule(string $type, object $params): bool + { + $rule = (object) [ + 'type' => $type, + 'params' => $params, + ]; + + return self::passRule($rule); + } + /** * Clear the evaluation cache (useful between requests in testing). * diff --git a/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php b/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php index 3d57b4da..a91a1ad2 100644 --- a/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php +++ b/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php @@ -2968,4 +2968,274 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface $modules = $filtered; } + + // ------------------------------------------------------------------ + // Conditional Content Tags ({show}/{hide}) + // ------------------------------------------------------------------ + + /** + * Process conditional content tags in article/content body. + * + * @param string $context The context of the content being passed. + * @param object $article The article object. + * @param object $params The article params. + * @param int $page The page number. + * + * @return void + * + * @since 02.48.00 + */ + public function onContentPrepare($context, &$article, &$params, $page = 0): void + { + // Skip admin, search indexer contexts, and empty text. + if (!$this->getApplication()->isClient('site')) + { + return; + } + + if ($context === 'com_finder.indexer') + { + return; + } + + $text = $article->text ?? ($article->introtext ?? ''); + + if ($text === '' || (strpos($text, '{show') === false && strpos($text, '{hide') === false)) + { + return; + } + + $this->processConditionalTags($text); + + // Write back to whichever property was populated. + if (isset($article->text)) + { + $article->text = $text; + } + else + { + $article->introtext = $text; + } + } + + /** + * Process conditional content tags in the final rendered HTML body. + * + * Catches tags in modules, template overrides, and other places that + * onContentPrepare does not reach. + * + * @return void + * + * @since 02.48.00 + */ + public function onAfterRender(): void + { + if (!$this->getApplication()->isClient('site')) + { + return; + } + + $body = $this->getApplication()->getBody(); + + if ($body === '' || (strpos($body, '{show') === false && strpos($body, '{hide') === false)) + { + return; + } + + $this->processConditionalTags($body); + $this->getApplication()->setBody($body); + } + + /** + * Process {show} and {hide} conditional content tags. + * + * Supported syntax: + * {show condition="alias_or_id"}content{/show} + * {hide condition="alias_or_id"}content{/hide} + * {show condition="alias"}shown{else}hidden{/show} + * {show access_level="1,2"}content{/show} + * {show user_group="8"}content{/show} + * {show menu_item="101,102"}content{/show} + * {show home_page="1"}content{/show} + * + * @param string &$text The text to process (modified in place). + * + * @return void + * + * @since 02.48.00 + */ + private function processConditionalTags(string &$text): void + { + // Process innermost tags first (handles nesting by repeating). + $maxIterations = 10; + $iteration = 0; + + while ($iteration < $maxIterations + && (strpos($text, '{show') !== false || strpos($text, '{hide') !== false)) + { + $pattern = '#\{(show|hide)\s+([^}]*)\}(.*?)(?:\{else\}(.*?))?\{/\1\}#si'; + $self = $this; + + $newText = preg_replace_callback($pattern, function ($matches) use ($self) { + $tag = strtolower($matches[1]); // 'show' or 'hide' + $attributes = $matches[2]; + $content = $matches[3]; + $elseBlock = $matches[4] ?? ''; + + $passes = $self->evaluateTagCondition($attributes); + + // For {hide}, invert the logic. + if ($tag === 'hide') + { + $passes = !$passes; + } + + return $passes ? $content : $elseBlock; + }, $text); + + // If nothing changed, stop iterating. + if ($newText === $text) + { + break; + } + + $text = $newText; + $iteration++; + } + } + + /** + * Evaluate the condition specified by tag attributes. + * + * @param string $attributes The raw attribute string from inside the tag. + * + * @return bool True if the condition passes. + * + * @since 02.48.00 + */ + private function evaluateTagCondition(string $attributes): bool + { + $helper = \Moko\Component\MokoSuiteClient\Administrator\Helper\ConditionsHelper::class; + + // Parse all key="value" pairs from the attribute string. + $attrs = []; + + if (preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $attributes, $attrMatches, PREG_SET_ORDER)) + { + foreach ($attrMatches as $m) + { + $attrs[strtolower($m[1])] = $m[2]; + } + } + + // 1. Saved condition reference: condition="alias_or_id" + if (!empty($attrs['condition'])) + { + $ref = trim($attrs['condition']); + + return $helper::passByAlias($ref); + } + + // 2. Inline rules — map attribute names to rule types. + $inlineMap = [ + 'access_level' => 'visitor__access_level', + 'user_group' => 'visitor__user_group', + 'menu_item' => 'menu__menu_item', + 'home_page' => 'menu__home_page', + 'date' => 'date__date', + 'day' => 'date__day', + 'url' => 'other__url', + ]; + + // Evaluate all inline attributes; ALL must pass (AND logic). + $hasInline = false; + + foreach ($inlineMap as $attrName => $ruleType) + { + if (!isset($attrs[$attrName])) + { + continue; + } + + $hasInline = true; + $rawValue = $attrs[$attrName]; + + $params = $this->buildInlineRuleParams($ruleType, $rawValue); + + if (!$helper::evaluateInlineRule($ruleType, $params)) + { + return false; + } + } + + // If we processed at least one inline rule and none failed, pass. + if ($hasInline) + { + return true; + } + + // No recognised attributes — default to not showing. + return false; + } + + /** + * Build a params object for an inline rule from the raw attribute value. + * + * @param string $ruleType The rule type identifier. + * @param string $rawValue The raw comma-separated value from the tag attribute. + * + * @return object A params object suitable for ConditionsHelper::evaluateInlineRule(). + * + * @since 02.48.00 + */ + private function buildInlineRuleParams(string $ruleType, string $rawValue): object + { + $params = new \stdClass(); + + switch ($ruleType) + { + case 'visitor__access_level': + case 'visitor__user_group': + case 'menu__menu_item': + case 'date__day': + // Comma-separated list of IDs. + $params->selection = array_map('trim', explode(',', $rawValue)); + $params->comparison = 'any'; + break; + + case 'menu__home_page': + // Single boolean-like value: "1" or "0". + $params->selection = [trim($rawValue)]; + break; + + case 'date__date': + // Supports "after:2026-01-01", "before:2026-12-31", + // "between:2026-01-01,2026-12-31", or plain date (defaults to 'after'). + $parts = explode(':', $rawValue, 2); + + if (\count($parts) === 2 && \in_array($parts[0], ['before', 'after', 'between'], true)) + { + $params->comparison = $parts[0]; + $params->selection = array_map('trim', explode(',', $parts[1])); + } + else + { + $params->comparison = 'after'; + $params->selection = array_map('trim', explode(',', $rawValue)); + } + + break; + + case 'other__url': + // Comma-separated regex patterns. + $params->selection = array_map('trim', explode(',', $rawValue)); + break; + + default: + $params->selection = array_map('trim', explode(',', $rawValue)); + break; + } + + return $params; + } }