From 464ebb1a254539f3a12a3b6f9018ace60386d489 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 23 May 2026 18:10:14 -0500 Subject: [PATCH] feat(validate): add check_client_theme.php for WaaS file packages New validator for client theme packages (Joomla type="file"): - Manifest: required elements, type="file", method="upgrade", version format - Required files: light.custom.css, dark.custom.css - PHP syntax check on script.php - CSS brace balance + BOM detection - Version consistency (manifest vs updates.xml vs CHANGELOG) - Image size warnings (>1MB) Also update auto_detect_platform.php to recognise type="file" manifests as client repos alongside legacy sftp-config detection. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- validate/auto_detect_platform.php | 55 ++++-- validate/check_client_theme.php | 278 ++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 validate/check_client_theme.php diff --git a/validate/auto_detect_platform.php b/validate/auto_detect_platform.php index 8b8d92b..f4d57d4 100755 --- a/validate/auto_detect_platform.php +++ b/validate/auto_detect_platform.php @@ -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('/]*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('/]*type="(component|module|plugin|template|package)"/', $content)) { + $score -= 50; + $indicators[] = "Has Joomla extension manifest — likely extension, not client"; + break; + } } } diff --git a/validate/check_client_theme.php b/validate/check_client_theme.php new file mode 100644 index 0000000..f29b89a --- /dev/null +++ b/validate/check_client_theme.php @@ -0,0 +1,278 @@ +#!/usr/bin/env php + + * + * 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 '); + $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>/', $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>/', $content, $m)) { + $manifestVer = $m[1]; + } + + $updatesFile = $path . '/updates.xml'; + if (is_file($updatesFile)) { + $updatesContent = (string) file_get_contents($updatesFile); + if (preg_match('/([^<]+)<\/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()); -- 2.52.0