Files
MokoCassiopeia/src/sync_custom_vars.php
Jonathan Miller 20493cf43e Chore/sync mokostandards v04.00.27 (#92)
* chore: update LICENSE from MokoStandards

* chore: update update.xml from MokoStandards

* chore: update phpstan.neon from MokoStandards

* chore: update Makefile from MokoStandards

* chore: update .gitignore from MokoStandards

* chore: update composer.json from MokoStandards

* chore: update .moko-standards from MokoStandards

* chore: update .github/copilot.yml from MokoStandards

* chore: update .github/copilot-instructions.md from MokoStandards

* chore: update .github/CLAUDE.md from MokoStandards

* chore: update .github/workflows/codeql-analysis.yml from MokoStandards

* chore: update .github/workflows/enterprise-firewall-setup.yml from MokoStandards

* feat: add hero variant system, block color system, theme test sheet, and CSS var sync script

Implements the two-variant hero system (.hero#primary / .hero#secondary) and
automatic block color palette for top-a/top-b/bottom-a/bottom-b positions.
Adds Bootstrap-style HTML test page for visual CSS variable reference, and a
PHP sync utility that detects missing variables in user custom palettes and
injects them on upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:39:46 -05:00

407 lines
13 KiB
PHP

<?php
/**
* @package Joomla.Site
* @subpackage Templates.MokoCassiopeia
*
* @copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*
* CSS Variable Sync Utility
*
* Compares a user's custom palette file against the template starter file and
* injects any missing CSS variable declarations. Existing user values are
* never overwritten — only genuinely new variables are added.
*
* Usage (CLI):
* php sync_custom_vars.php
*
* Usage (from Joomla script.php or plugin):
* require_once __DIR__ . '/sync_custom_vars.php';
* MokoCssVarSync::run();
*
* The script auto-detects Joomla's root by walking up from __DIR__.
*/
defined('_JEXEC') or define('MOKO_CLI', true);
final class MokoCssVarSync
{
/**
* Template name used in Joomla's media path.
*/
private const TPL = 'mokocassiopeia';
/**
* Palette pairs: [starter template path relative to this file, user file relative to Joomla root].
*/
private const PALETTES = [
[
'starter' => 'templates/light.custom.css',
'user' => 'media/templates/site/%s/css/theme/light.custom.css',
],
[
'starter' => 'templates/dark.custom.css',
'user' => 'media/templates/site/%s/css/theme/dark.custom.css',
],
];
/**
* Run the sync for all palette pairs.
*
* @param string|null $joomlaRoot Absolute path to Joomla root (auto-detected if null).
* @return array<string, array{added: string[], skipped: string[]}> Results keyed by file path.
*/
public static function run(?string $joomlaRoot = null): array
{
$tplDir = self::resolveTemplateDir();
$root = $joomlaRoot ?? self::detectJoomlaRoot();
$results = [];
foreach (self::PALETTES as $pair) {
$starterPath = $tplDir . '/' . $pair['starter'];
$userPath = $root . '/' . sprintf($pair['user'], self::TPL);
if (!is_file($starterPath)) {
self::log("SKIP starter not found: {$starterPath}");
continue;
}
if (!is_file($userPath)) {
self::log("SKIP user file not found (custom palette not deployed): {$userPath}");
continue;
}
$result = self::syncFile($starterPath, $userPath);
$results[$userPath] = $result;
$addedCount = count($result['added']);
if ($addedCount > 0) {
self::log("ADDED {$addedCount} variable(s) to {$userPath}");
foreach ($result['added'] as $var) {
self::log(" + {$var}");
}
} else {
self::log("OK {$userPath} — all variables present");
}
}
return $results;
}
/**
* Compare a starter file against a user file and inject missing variables.
*
* @param string $starterPath Absolute path to the starter template CSS.
* @param string $userPath Absolute path to the user's custom CSS.
* @return array{added: string[], skipped: string[]}
*/
private static function syncFile(string $starterPath, string $userPath): array
{
$starterVars = self::extractVarsWithContext($starterPath);
$userVars = self::extractVarNames($userPath);
$missing = [];
foreach ($starterVars as $name => $declaration) {
if (!isset($userVars[$name])) {
$missing[$name] = $declaration;
}
}
if (empty($missing)) {
return ['added' => [], 'skipped' => []];
}
// Group missing variables by their section comment header.
$sections = self::groupBySection($missing, $starterPath);
// Build the injection block.
$injection = self::buildInjectionBlock($sections);
// Insert before the closing } of the :root rule.
$userCss = file_get_contents($userPath);
$userCss = self::injectBeforeRootClose($userCss, $injection);
// Write back (atomic: write to .tmp then rename).
$tmpPath = $userPath . '.tmp';
file_put_contents($tmpPath, $userCss);
rename($tmpPath, $userPath);
return ['added' => array_keys($missing), 'skipped' => []];
}
/**
* Extract CSS custom property declarations with their full text (name: value).
* Only extracts from the first :root block.
*
* @return array<string, string> Variable name => full declaration line.
*/
private static function extractVarsWithContext(string $filePath): array
{
$css = file_get_contents($filePath);
$vars = [];
// Match --variable-name: value (possibly spanning multiple lines until ;)
if (preg_match_all('/^\s*(--[\w-]+)\s*:\s*([^;]+);/m', $css, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$name = trim($m[1]);
$value = trim($m[2]);
$vars[$name] = "{$name}: {$value};";
}
}
return $vars;
}
/**
* Extract just the variable names present in a CSS file.
*
* @return array<string, true>
*/
private static function extractVarNames(string $filePath): array
{
$css = file_get_contents($filePath);
$vars = [];
if (preg_match_all('/^\s*(--[\w-]+)\s*:/m', $css, $matches)) {
foreach ($matches[1] as $name) {
$vars[trim($name)] = true;
}
}
return $vars;
}
/**
* Group missing variables by the section comment they appear under in the starter file.
*
* @param array<string, string> $missing Variable name => declaration.
* @param string $starterPath Path to starter file.
* @return array<string, string[]> Section header => list of declarations.
*/
private static function groupBySection(array $missing, string $starterPath): array
{
$lines = file($starterPath, FILE_IGNORE_NEW_LINES);
$section = 'Uncategorised';
$map = []; // variable name => section
foreach ($lines as $line) {
// Detect section comment headers like /* ===== HERO VARIANTS ===== */
if (preg_match('/\/\*\s*=+\s*(.+?)\s*=+\s*\*\//', $line, $m)) {
$section = trim($m[1]);
}
// Detect variable declaration
if (preg_match('/^\s*(--[\w-]+)\s*:/', $line, $m)) {
$name = trim($m[1]);
if (isset($missing[$name])) {
$map[$name] = $section;
}
}
}
// Group by section
$sections = [];
foreach ($missing as $name => $declaration) {
$sec = $map[$name] ?? 'Uncategorised';
$sections[$sec][] = $declaration;
}
return $sections;
}
/**
* Build a CSS block from grouped sections ready for injection.
*/
private static function buildInjectionBlock(array $sections): string
{
$lines = [];
$lines[] = '';
$lines[] = '/* ===== VARIABLES ADDED BY SYNC (' . date('Y-m-d') . ') ===== */';
foreach ($sections as $sectionName => $declarations) {
$lines[] = '';
$lines[] = "/* -- {$sectionName} -- */";
foreach ($declarations as $decl) {
$lines[] = $decl;
}
}
$lines[] = '';
return implode("\n", $lines);
}
/**
* Inject a block of CSS just before the closing } of the :root[data-bs-theme] rule.
*/
private static function injectBeforeRootClose(string $css, string $block): string
{
// Find the :root block's closing brace. The :root rule is the first major
// rule in the file; its closing } is on its own line.
// Strategy: find the LAST } that is preceded only by CSS variable content.
// More robustly: find the first } that appears on its own line (possibly
// with whitespace), which closes the :root block.
// Walk backwards from each } to see if it's inside the :root block.
// Simple approach: the :root closing } is the first bare } on its own line.
$pos = self::findRootClosingBrace($css);
if ($pos === false) {
// Fallback: append before last }
$pos = strrpos($css, '}');
}
if ($pos === false) {
// Last resort: append to end
return $css . $block;
}
return substr($css, 0, $pos) . $block . substr($css, $pos);
}
/**
* Find the byte position of the closing } for the :root rule.
*/
private static function findRootClosingBrace(string $css): int|false
{
// Find where :root starts
$rootStart = preg_match('/:root\b/', $css, $m, PREG_OFFSET_CAPTURE);
if (!$rootStart) {
return false;
}
$offset = $m[0][1];
$depth = 0;
$len = strlen($css);
for ($i = $offset; $i < $len; $i++) {
if ($css[$i] === '{') {
$depth++;
} elseif ($css[$i] === '}') {
$depth--;
if ($depth === 0) {
return $i;
}
}
}
return false;
}
/**
* Resolve the template source directory (where this file lives).
*/
private static function resolveTemplateDir(): string
{
return dirname(__FILE__);
}
/**
* Auto-detect Joomla root by walking up from template dir looking for
* configuration.php or the media/templates directory structure.
*/
private static function detectJoomlaRoot(): string
{
$dir = dirname(__FILE__);
// Walk up max 10 levels
for ($i = 0; $i < 10; $i++) {
if (is_file($dir . '/configuration.php')) {
return $dir;
}
// Also check for the media/templates structure (works in dev too)
if (is_dir($dir . '/media/templates')) {
return $dir;
}
$parent = dirname($dir);
if ($parent === $dir) {
break;
}
$dir = $parent;
}
// Fallback for dev: if JPATH_ROOT is defined, use it
if (defined('JPATH_ROOT')) {
return JPATH_ROOT;
}
self::log('WARNING: Could not auto-detect Joomla root. Pass it explicitly.');
return dirname(__FILE__);
}
/**
* Log a message (CLI: stdout, web: Joomla enqueueMessage if available).
*/
private static function log(string $message): void
{
if (defined('MOKO_CLI') || PHP_SAPI === 'cli') {
echo $message . PHP_EOL;
}
}
/**
* Dry-run mode: report what would be added without writing.
*
* @return array<string, string[]> File path => list of missing variable names.
*/
public static function dryRun(?string $joomlaRoot = null): array
{
$tplDir = self::resolveTemplateDir();
$root = $joomlaRoot ?? self::detectJoomlaRoot();
$report = [];
foreach (self::PALETTES as $pair) {
$starterPath = $tplDir . '/' . $pair['starter'];
$userPath = $root . '/' . sprintf($pair['user'], self::TPL);
if (!is_file($starterPath) || !is_file($userPath)) {
continue;
}
$starterVars = self::extractVarsWithContext($starterPath);
$userVars = self::extractVarNames($userPath);
$missing = [];
foreach ($starterVars as $name => $declaration) {
if (!isset($userVars[$name])) {
$missing[] = $name;
}
}
if (!empty($missing)) {
$report[$userPath] = $missing;
}
}
return $report;
}
}
// CLI entry point
if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === realpath(__FILE__)) {
$dryRun = in_array('--dry-run', $argv, true);
echo "MokoCassiopeia CSS Variable Sync\n";
echo str_repeat('─', 40) . "\n\n";
if ($dryRun) {
echo "DRY RUN — no files will be modified\n\n";
$report = MokoCssVarSync::dryRun();
if (empty($report)) {
echo "All custom palettes are up to date.\n";
} else {
foreach ($report as $file => $vars) {
echo "MISSING in {$file}:\n";
foreach ($vars as $var) {
echo " - {$var}\n";
}
echo "\n";
}
}
} else {
MokoCssVarSync::run();
}
echo "\nDone.\n";
}