Merge pull request 'feat(ci): client theme CI with CLI validators' (#67) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 4s
Generic: Repo Health / Repository health (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 9s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled

This commit was merged in pull request #67.
This commit is contained in:
2026-05-24 03:48:31 +00:00
2 changed files with 321 additions and 12 deletions
+43 -12
View File
@@ -171,15 +171,45 @@ class AutoDetectPlatform extends CLIApp
/**
* Detect client site repository.
* Client repos have src/ with Joomla site structure PLUS deployment
* configs (sftp-config/, monitoring/). They are NOT Joomla extensions.
* Client repos have either:
* (a) src/ with Joomla site structure + deployment configs (legacy)
* (b) src/templateDetails.xml with type="file" (theme package)
* They are NOT Joomla extensions (component/module/plugin/template).
*/
private function detectClient(string $repoPath): void
{
$score = 0;
$indicators = [];
// Strong indicators: deployment/monitoring configs
// Strong indicator: type="file" manifest (client theme package)
$manifests = glob($repoPath . '/src/*.xml') ?: [];
$isFilePackage = false;
foreach ($manifests as $xml) {
$content = @file_get_contents($xml);
if ($content && preg_match('/<extension\s+[^>]*type="file"/', $content)) {
$score += 60;
$indicators[] = 'Found Joomla type="file" manifest (theme package)';
$isFilePackage = true;
break;
}
}
// Theme package files
$themeMarkers = [
'src/media/templates/site/mokoonyx/css/theme/light.custom.css' => 15,
'src/media/templates/site/mokoonyx/css/theme/dark.custom.css' => 15,
'src/script.php' => 10,
'updates.xml' => 10,
];
foreach ($themeMarkers as $path => $weight) {
$full = $repoPath . '/' . $path;
if (is_file($full)) {
$score += $weight;
$indicators[] = "Found: {$path} (+{$weight})";
}
}
// Legacy indicators: deployment/monitoring configs
$clientMarkers = [
'scripts/sftp-config' => 30,
'scripts/sftp-config/sftp-config.dev.json' => 10,
@@ -198,7 +228,7 @@ class AutoDetectPlatform extends CLIApp
}
}
// Site structure inside src/ (not at root — that would be a Joomla extension)
// Legacy: site structure inside src/
$siteDirs = ['src/administrator', 'src/components', 'src/plugins', 'src/templates', 'src/media'];
$siteDirCount = 0;
foreach ($siteDirs as $dir) {
@@ -211,14 +241,15 @@ class AutoDetectPlatform extends CLIApp
$indicators[] = "Joomla site structure in src/ ({$siteDirCount}/5 dirs)";
}
// Negative: if there's a Joomla manifest XML in src/, it's an extension not a client
$manifests = glob($repoPath . '/src/*.xml');
foreach ($manifests ?: [] as $xml) {
$content = @file_get_contents($xml);
if ($content && preg_match('/<extension\s+type="(component|module|plugin|template|package)"/', $content)) {
$score -= 50;
$indicators[] = "Has Joomla extension manifest — likely extension, not client";
break;
// Negative: if there's a Joomla extension manifest (not type="file"), it's an extension
if (!$isFilePackage) {
foreach ($manifests as $xml) {
$content = @file_get_contents($xml);
if ($content && preg_match('/<extension\s+[^>]*type="(component|module|plugin|template|package)"/', $content)) {
$score -= 50;
$indicators[] = "Has Joomla extension manifest — likely extension, not client";
break;
}
}
}
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Validate
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /validate/check_client_theme.php
* BRIEF: Validates client WaaS theme packages (Joomla type="file")
*/
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use MokoEnterprise\CliFramework;
/**
* Validates client theme packages that deliver CSS, JS, and images
* to the MokoOnyx template via Joomla's file package installer.
*
* Checks:
* - Manifest structure (src/templateDetails.xml)
* - Required elements: name, element, version, updateservers, scriptfile, fileset
* - Extension type is "file" with method="upgrade"
* - Version format (XX.YY.ZZ)
* - Required theme files (light.custom.css, dark.custom.css)
* - PHP syntax of script.php
* - CSS brace balance
* - updates.xml at repo root
* - Image size warnings
*/
class CheckClientTheme extends CliFramework
{
/** Required XML elements in the manifest. */
private const REQUIRED_ELEMENTS = ['name', 'element', 'version'];
/** Recommended XML elements. */
private const RECOMMENDED_ELEMENTS = ['updateservers', 'scriptfile', 'description', 'fileset'];
/** Required theme CSS files relative to repo root. */
private const REQUIRED_THEME_FILES = [
'src/media/templates/site/mokoonyx/css/theme/light.custom.css',
'src/media/templates/site/mokoonyx/css/theme/dark.custom.css',
];
/** Optional but expected files. */
private const EXPECTED_FILES = [
'src/media/templates/site/mokoonyx/css/user.css',
'src/media/templates/site/mokoonyx/js/user.js',
'src/script.php',
'updates.xml',
];
/** Maximum image size before warning (1 MB). */
private const IMAGE_WARN_SIZE = 1048576;
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Validates client WaaS theme packages (type="file")');
$this->addArgument('--path', 'Repository path to check', '.');
}
/**
* Run all validation checks.
*/
protected function run(): int
{
$path = rtrim($this->getArgument('--path'), '/');
$errors = 0;
$warns = 0;
// ── Manifest ──────────────────────────────────────────
$this->section('Manifest validation');
$manifest = $path . '/src/templateDetails.xml';
if (!is_file($manifest)) {
$this->status(false, 'Missing src/templateDetails.xml');
$this->printSummary(0, 1, $this->elapsed());
return 1;
}
$content = (string) file_get_contents($manifest);
// Extension type
if (preg_match('/type="([^"]*)"/', $content, $m)) {
if ($m[1] !== 'file') {
$this->status(false, "Extension type is '{$m[1]}', expected 'file'");
$errors++;
} else {
$this->status(true, 'Extension type: file');
}
} else {
$this->status(false, 'No type attribute on <extension>');
$errors++;
}
// method="upgrade"
if (str_contains($content, 'method="upgrade"')) {
$this->status(true, 'method="upgrade" present');
} else {
$this->warning('Missing method="upgrade" — updates may fail');
$warns++;
}
// Required elements
foreach (self::REQUIRED_ELEMENTS as $el) {
if (str_contains($content, "<{$el}>")) {
$this->status(true, "<{$el}> present");
} else {
$this->status(false, "Missing <{$el}>");
$errors++;
}
}
// Recommended elements
foreach (self::RECOMMENDED_ELEMENTS as $el) {
if (!str_contains($content, "<{$el}>") && !str_contains($content, "<{$el} ")) {
$this->warning("Missing <{$el}>");
$warns++;
}
}
// Version format
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
$version = $m[1];
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $version)) {
$this->status(true, "Version: {$version}");
} else {
$this->status(false, "Version '{$version}' does not match XX.YY.ZZ format");
$errors++;
}
}
// ── Required files ────────────────────────────────────
$this->section('Required files');
foreach (self::REQUIRED_THEME_FILES as $file) {
$full = $path . '/' . $file;
if (is_file($full)) {
$this->status(true, basename($file));
} else {
$this->status(false, "Missing: {$file}");
$errors++;
}
}
foreach (self::EXPECTED_FILES as $file) {
$full = $path . '/' . $file;
if (is_file($full)) {
$this->status(true, basename($file));
} else {
$this->warning("Missing: {$file}");
$warns++;
}
}
// ── PHP syntax ────────────────────────────────────────
$this->section('PHP syntax');
$phpFiles = glob($path . '/src/*.php') ?: [];
foreach ($phpFiles as $phpFile) {
$output = [];
$ret = 0;
$escaped = escapeshellarg($phpFile);
exec("php -l {$escaped} 2>&1", $output, $ret);
if ($ret !== 0) {
$this->status(false, 'Syntax error: ' . basename($phpFile));
$errors++;
} else {
$this->status(true, basename($phpFile));
}
}
if (empty($phpFiles)) {
$this->warning('No PHP files in src/');
}
// ── CSS validation ────────────────────────────────────
$this->section('CSS validation');
$cssFiles = array_merge(
glob($path . '/src/media/templates/site/mokoonyx/css/theme/*.css') ?: [],
glob($path . '/src/media/templates/site/mokoonyx/css/*.css') ?: [],
);
foreach ($cssFiles as $cssFile) {
$css = (string) file_get_contents($cssFile);
$open = substr_count($css, '{');
$close = substr_count($css, '}');
$name = str_replace($path . '/src/', '', $cssFile);
if ($open !== $close) {
$this->status(false, "Unbalanced braces in {$name} (open: {$open}, close: {$close})");
$errors++;
} else {
$this->status(true, "{$name} ({$open} rules)");
}
// BOM check
if (str_starts_with($css, "\xEF\xBB\xBF")) {
$this->status(false, "BOM detected in {$name}");
$errors++;
}
}
// ── Version consistency ───────────────────────────────
$this->section('Version consistency');
$manifestVer = '';
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
$manifestVer = $m[1];
}
$updatesFile = $path . '/updates.xml';
if (is_file($updatesFile)) {
$updatesContent = (string) file_get_contents($updatesFile);
if (preg_match('/<version>([^<]+)<\/version>/', $updatesContent, $m)) {
if ($m[1] !== $manifestVer) {
$this->warning("Version drift: manifest={$manifestVer}, updates.xml={$m[1]}");
$warns++;
} else {
$this->status(true, "Versions match: {$manifestVer}");
}
}
}
if (is_file($path . '/CHANGELOG.md')) {
$cl = (string) file_get_contents($path . '/CHANGELOG.md');
if (!str_contains($cl, "[{$manifestVer}]")) {
$this->warning("Version {$manifestVer} not in CHANGELOG.md");
$warns++;
} else {
$this->status(true, "CHANGELOG has [{$manifestVer}]");
}
}
// ── Image sizes ───────────────────────────────────────
$this->section('Image optimization');
$largeImages = 0;
$imageDir = $path . '/src/images';
if (is_dir($imageDir)) {
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($imageDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iter as $file) {
if (!$file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])) {
continue;
}
if ($file->getSize() > self::IMAGE_WARN_SIZE) {
$kb = (int) ($file->getSize() / 1024);
$this->warning("{$kb}KB: " . str_replace($path . '/', '', $file->getPathname()));
$largeImages++;
}
}
}
if ($largeImages > 0) {
$this->warning("{$largeImages} image(s) over 1MB — consider optimizing");
} else {
$this->status(true, 'All images under 1MB');
}
// ── Summary ───────────────────────────────────────────
$passed = ($errors === 0) ? 1 : 0;
$this->printSummary($passed, $errors, $this->elapsed(), $warns);
return ($errors > 0) ? 1 : 0;
}
}
$script = new CheckClientTheme('check_client_theme', 'Validates client WaaS theme packages');
exit($script->execute());