feat(conditional-content): {show}/{hide} tag processing with conditions engine
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 19s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 14s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 38s
Generic: Project CI / Lint & Validate (pull_request) Successful in 51s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 52s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 47s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 59s

- 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
This commit is contained in:
Jonathan Miller
2026-06-23 12:37:29 -05:00
parent 3c94ffeff3
commit 1fe7c77fbf
2 changed files with 338 additions and 0 deletions
@@ -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).
*
@@ -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;
}
}