* * 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/offline.css', 'css/editor.css', 'css/a11y-high-contrast.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); } }