#!/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()); return ($errors > 0) ? 1 : 0; } } $script = new CheckClientTheme('check_client_theme', 'Validates client WaaS theme packages'); exit($script->execute());