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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user