From a8e1ddbb05c97c3c6f1833174e505f665cce6a36 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:05:09 -0500 Subject: [PATCH] Auto-minify CSS/JS: dev mode deletes .min, prod mode regenerates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - helper/minify.php: PHP-based CSS/JS minifier with timestamp caching - Dev mode ON: deletes all .min.css and .min.js files - Dev mode OFF: regenerates .min files from source if stale or missing - Covers template.css, light/dark standard/custom theme CSS, template.js - No external build tools needed — template is self-contained Co-Authored-By: Claude Opus 4.6 (1M context) --- src/helper/minify.php | 162 ++++++++++++++++++++++++++++++++++++++++++ src/index.php | 4 ++ 2 files changed, 166 insertions(+) create mode 100644 src/helper/minify.php diff --git a/src/helper/minify.php b/src/helper/minify.php new file mode 100644 index 0000000..0ea066a --- /dev/null +++ b/src/helper/minify.php @@ -0,0 +1,162 @@ + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * CSS/JS minifier — generates .min files from source when dev mode is off, + * deletes them when dev mode is on. + */ + +defined('_JEXEC') or die; + +class MokoMinifyHelper +{ + /** + * Files to minify: source path relative to template media root. + * The .min variant is derived automatically (template.css → template.min.css). + */ + private const CSS_FILES = [ + 'css/template.css', + 'css/theme/light.standard.css', + 'css/theme/dark.standard.css', + 'css/theme/light.custom.css', + 'css/theme/dark.custom.css', + ]; + + private const JS_FILES = [ + 'js/template.js', + ]; + + /** + * When dev mode is ON: delete all .min files. + * When dev mode is OFF: regenerate .min files if source is newer. + * + * @param string $mediaRoot Absolute path to the template media directory. + * @param bool $devMode Whether development mode is enabled. + */ + public static function sync(string $mediaRoot, bool $devMode): void + { + $mediaRoot = rtrim($mediaRoot, '/\\'); + + foreach (self::CSS_FILES as $relPath) { + $source = $mediaRoot . '/' . $relPath; + $min = self::minPath($source); + + if ($devMode) { + self::deleteIfExists($min); + } else { + self::buildIfStale($source, $min, 'css'); + } + } + + foreach (self::JS_FILES as $relPath) { + $source = $mediaRoot . '/' . $relPath; + $min = self::minPath($source); + + if ($devMode) { + self::deleteIfExists($min); + } else { + self::buildIfStale($source, $min, 'js'); + } + } + } + + /** + * Derive the .min path from a source path. + * template.css → template.min.css + */ + private static function minPath(string $path): string + { + $info = pathinfo($path); + return $info['dirname'] . '/' . $info['filename'] . '.min.' . $info['extension']; + } + + /** + * Delete a file if it exists. + */ + private static function deleteIfExists(string $path): void + { + if (is_file($path)) { + @unlink($path); + } + } + + /** + * Build the minified file if the source is newer or the min file is missing. + */ + private static function buildIfStale(string $source, string $min, string $type): void + { + if (!is_file($source)) { + return; + } + + // Skip if min file exists and is newer than source + if (is_file($min) && filemtime($min) >= filemtime($source)) { + return; + } + + $content = file_get_contents($source); + if ($content === false) { + return; + } + + $minified = ($type === 'css') + ? self::minifyCss($content) + : self::minifyJs($content); + + file_put_contents($min, $minified); + } + + /** + * Minify CSS by stripping comments, excess whitespace, and unnecessary characters. + */ + private static function minifyCss(string $css): string + { + // Remove comments (but keep IE hacks like /*\*/) + $css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css); + + // Remove whitespace around { } : ; , > + ~ + $css = preg_replace('/\s*([{}:;,>+~])\s*/', '$1', $css); + + // Remove remaining newlines and tabs + $css = preg_replace('/\s+/', ' ', $css); + + // Remove spaces around selectors + $css = str_replace(['{ ', ' {', '; ', ' ;'], ['{', '{', ';', ';'], $css); + + // Remove trailing semicolons before closing braces + $css = str_replace(';}', '}', $css); + + // Remove leading/trailing whitespace + return trim($css); + } + + /** + * Minify JS by stripping single-line comments, multi-line comments, + * and collapsing whitespace. Preserves string literals. + */ + private static function minifyJs(string $js): string + { + // Remove multi-line comments + $js = preg_replace('!/\*.*?\*/!s', '', $js); + + // Remove single-line comments (but not URLs like http://) + $js = preg_replace('!(?<=^|[\s;{}()\[\]])//[^\n]*!m', '', $js); + + // Collapse whitespace + $js = preg_replace('/\s+/', ' ', $js); + + // Remove spaces around operators and punctuation + $js = preg_replace('/\s*([{}();,=+\-*\/<>!&|?:])\s*/', '$1', $js); + + // Restore necessary spaces (after keywords) + $js = preg_replace('/(var|let|const|return|typeof|instanceof|new|delete|throw|case|in|of)([^\s;})><=!&|?:,])/', '$1 $2', $js); + + return trim($js); + } +} diff --git a/src/index.php b/src/index.php index b4ddd06..ac6ac24 100644 --- a/src/index.php +++ b/src/index.php @@ -70,6 +70,10 @@ if ($params_favicon_source) { } } +// Minification: dev mode ON → delete .min files; OFF → regenerate if stale +require_once __DIR__ . '/helper/minify.php'; +MokoMinifyHelper::sync(JPATH_ROOT . '/' . $templatePath, (bool) $params_developmentmode); + // Core template CSS + JS — use minified when not in development mode if ($params_developmentmode) { $wa->useStyle('template.base'); // css/template.css