MokoOnyx v01.00.00 — initial release (successor to MokoCassiopeia)
Some checks failed
Standards Compliance / Secret Scanning (push) Successful in 3s
Standards Compliance / License Header Validation (push) Successful in 4s
Standards Compliance / Repository Structure Validation (push) Successful in 5s
Standards Compliance / Coding Standards Check (push) Failing after 3s
Standards Compliance / Version Consistency Check (push) Successful in 3s
Standards Compliance / Workflow Configuration Check (push) Failing after 2s
Standards Compliance / Documentation Quality Check (push) Successful in 3s
Standards Compliance / README Completeness Check (push) Successful in 3s
Standards Compliance / Git Repository Hygiene (push) Successful in 2s
Standards Compliance / Script Integrity Validation (push) Successful in 4s
Standards Compliance / Line Length Check (push) Failing after 4s
Standards Compliance / File Naming Standards (push) Successful in 2s
Standards Compliance / Insecure Code Pattern Detection (push) Successful in 3s
Standards Compliance / Code Complexity Analysis (push) Successful in 3s
Standards Compliance / Code Duplication Detection (push) Successful in 4s
Standards Compliance / Dead Code Detection (push) Successful in 3s
Standards Compliance / File Size Limits (push) Successful in 2s
CodeQL Security Scanning / Analyze (javascript) (push) Failing after 1m9s
Standards Compliance / Binary File Detection (push) Successful in 4s
CodeQL Security Scanning / Analyze (actions) (push) Failing after 1m11s
Standards Compliance / TODO/FIXME Tracking (push) Successful in 3s
Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 5s
Standards Compliance / Broken Link Detection (push) Successful in 5s
Standards Compliance / Unused Dependencies Check (push) Successful in 7s
Standards Compliance / API Documentation Coverage (push) Successful in 3s
Standards Compliance / Accessibility Check (push) Successful in 3s
Standards Compliance / Performance Metrics (push) Successful in 3s
Standards Compliance / Enterprise Readiness Check (push) Successful in 3s
Standards Compliance / Repository Health Check (push) Successful in 4s
Standards Compliance / Terraform Configuration Validation (push) Successful in 6s
CodeQL Security Scanning / Security Scan Summary (push) Successful in 1s
Standards Compliance / Compliance Summary (push) Successful in 1s
Repo Health / Access control (push) Successful in 1s
Auto-Update SHA Hash / Update SHA-256 Hash in updates.xml (release) Successful in 4s
Repo Health / Release configuration (push) Failing after 3s
Repo Health / Scripts governance (push) Successful in 3s
Repo Health / Repository health (push) Failing after 3s
Some checks failed
Standards Compliance / Secret Scanning (push) Successful in 3s
Standards Compliance / License Header Validation (push) Successful in 4s
Standards Compliance / Repository Structure Validation (push) Successful in 5s
Standards Compliance / Coding Standards Check (push) Failing after 3s
Standards Compliance / Version Consistency Check (push) Successful in 3s
Standards Compliance / Workflow Configuration Check (push) Failing after 2s
Standards Compliance / Documentation Quality Check (push) Successful in 3s
Standards Compliance / README Completeness Check (push) Successful in 3s
Standards Compliance / Git Repository Hygiene (push) Successful in 2s
Standards Compliance / Script Integrity Validation (push) Successful in 4s
Standards Compliance / Line Length Check (push) Failing after 4s
Standards Compliance / File Naming Standards (push) Successful in 2s
Standards Compliance / Insecure Code Pattern Detection (push) Successful in 3s
Standards Compliance / Code Complexity Analysis (push) Successful in 3s
Standards Compliance / Code Duplication Detection (push) Successful in 4s
Standards Compliance / Dead Code Detection (push) Successful in 3s
Standards Compliance / File Size Limits (push) Successful in 2s
CodeQL Security Scanning / Analyze (javascript) (push) Failing after 1m9s
Standards Compliance / Binary File Detection (push) Successful in 4s
CodeQL Security Scanning / Analyze (actions) (push) Failing after 1m11s
Standards Compliance / TODO/FIXME Tracking (push) Successful in 3s
Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 5s
Standards Compliance / Broken Link Detection (push) Successful in 5s
Standards Compliance / Unused Dependencies Check (push) Successful in 7s
Standards Compliance / API Documentation Coverage (push) Successful in 3s
Standards Compliance / Accessibility Check (push) Successful in 3s
Standards Compliance / Performance Metrics (push) Successful in 3s
Standards Compliance / Enterprise Readiness Check (push) Successful in 3s
Standards Compliance / Repository Health Check (push) Successful in 4s
Standards Compliance / Terraform Configuration Validation (push) Successful in 6s
CodeQL Security Scanning / Security Scan Summary (push) Successful in 1s
Standards Compliance / Compliance Summary (push) Successful in 1s
Repo Health / Access control (push) Successful in 1s
Auto-Update SHA Hash / Update SHA-256 Hash in updates.xml (release) Successful in 4s
Repo Health / Release configuration (push) Failing after 3s
Repo Health / Scripts governance (push) Successful in 3s
Repo Health / Repository health (push) Failing after 3s
All files renamed from mokocassiopeia to mokoonyx. Update server points to MokoOnyx repo. Bridge migration removed (clean standalone template). Version reset to 01.00.00. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
518
src/helper/favicon.php
Normal file
518
src/helper/favicon.php
Normal file
@@ -0,0 +1,518 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Favicon generator — creates ICO, Apple Touch Icon, and Android icons
|
||||
* from a single source image uploaded via the template config.
|
||||
*
|
||||
* Supports three backends in priority order:
|
||||
* 1. GD (fastest, most common)
|
||||
* 2. Imagick (common on shared hosting)
|
||||
* 3. Pure PHP (zero-dependency fallback using raw PNG manipulation)
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class MokoFaviconHelper
|
||||
{
|
||||
private const SIZES = [
|
||||
'apple-touch-icon.png' => [180, 180],
|
||||
'favicon-32x32.png' => [32, 32],
|
||||
'favicon-16x16.png' => [16, 16],
|
||||
'android-chrome-192x192.png' => [192, 192],
|
||||
'android-chrome-512x512.png' => [512, 512],
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate all favicon files from a source image.
|
||||
*/
|
||||
public static function generate(string $sourcePath, string $outputDir): bool
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
self::log('Favicon: source file not found: ' . $sourcePath, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
$sourceTime = filemtime($sourcePath);
|
||||
$stampFile = $outputDir . '/.favicon_generated';
|
||||
|
||||
if (is_file($stampFile) && filemtime($stampFile) >= $sourceTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strip #joomlaImage fragment if present
|
||||
$sourcePath = strtok($sourcePath, '#');
|
||||
|
||||
// Select backend
|
||||
if (extension_loaded('gd')) {
|
||||
$result = self::generateWithGd($sourcePath, $outputDir);
|
||||
} elseif (extension_loaded('imagick')) {
|
||||
$result = self::generateWithImagick($sourcePath, $outputDir);
|
||||
} else {
|
||||
$result = self::generatePurePHP($sourcePath, $outputDir);
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
self::generateManifest($outputDir);
|
||||
file_put_contents($stampFile, date('c'));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ── GD Backend ──────────────────────────────────────────────────
|
||||
|
||||
private static function generateWithGd(string $sourcePath, string $outputDir): bool
|
||||
{
|
||||
$imageInfo = @getimagesize($sourcePath);
|
||||
if ($imageInfo === false) {
|
||||
self::log('Favicon: cannot read image: ' . $sourcePath, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
$source = match ($imageInfo[2]) {
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($sourcePath),
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($sourcePath),
|
||||
IMAGETYPE_GIF => @imagecreatefromgif($sourcePath),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (!$source) {
|
||||
self::log('Favicon: unsupported image type', 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
imagealphablending($source, false);
|
||||
imagesavealpha($source, true);
|
||||
$srcW = imagesx($source);
|
||||
$srcH = imagesy($source);
|
||||
|
||||
foreach (self::SIZES as $filename => [$w, $h]) {
|
||||
$resized = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($resized, false);
|
||||
imagesavealpha($resized, true);
|
||||
imagefill($resized, 0, 0, imagecolorallocatealpha($resized, 0, 0, 0, 127));
|
||||
imagecopyresampled($resized, $source, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
|
||||
imagepng($resized, $outputDir . '/' . $filename, 9);
|
||||
imagedestroy($resized);
|
||||
}
|
||||
|
||||
// ICO from GD
|
||||
$icoEntries = [];
|
||||
foreach ([16, 32] as $size) {
|
||||
$resized = imagecreatetruecolor($size, $size);
|
||||
imagealphablending($resized, false);
|
||||
imagesavealpha($resized, true);
|
||||
imagefill($resized, 0, 0, imagecolorallocatealpha($resized, 0, 0, 0, 127));
|
||||
imagecopyresampled($resized, $source, 0, 0, 0, 0, $size, $size, $srcW, $srcH);
|
||||
ob_start();
|
||||
imagepng($resized, null, 9);
|
||||
$icoEntries[] = ['size' => $size, 'data' => ob_get_clean()];
|
||||
imagedestroy($resized);
|
||||
}
|
||||
self::writeIco($icoEntries, $outputDir . '/favicon.ico');
|
||||
|
||||
imagedestroy($source);
|
||||
self::log('Favicon: generated with GD');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Imagick Backend ─────────────────────────────────────────────
|
||||
|
||||
private static function generateWithImagick(string $sourcePath, string $outputDir): bool
|
||||
{
|
||||
try {
|
||||
foreach (self::SIZES as $filename => [$w, $h]) {
|
||||
$img = new \Imagick($sourcePath);
|
||||
$img->setImageFormat('png');
|
||||
$img->setImageCompressionQuality(95);
|
||||
$img->thumbnailImage($w, $h, true);
|
||||
// Center on transparent canvas if not square
|
||||
$canvas = new \Imagick();
|
||||
$canvas->newImage($w, $h, new \ImagickPixel('transparent'), 'png');
|
||||
$offsetX = (int)(($w - $img->getImageWidth()) / 2);
|
||||
$offsetY = (int)(($h - $img->getImageHeight()) / 2);
|
||||
$canvas->compositeImage($img, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY);
|
||||
$canvas->writeImage($outputDir . '/' . $filename);
|
||||
$img->destroy();
|
||||
$canvas->destroy();
|
||||
}
|
||||
|
||||
// ICO from Imagick
|
||||
$icoEntries = [];
|
||||
foreach ([16, 32] as $size) {
|
||||
$img = new \Imagick($sourcePath);
|
||||
$img->setImageFormat('png');
|
||||
$img->thumbnailImage($size, $size, true);
|
||||
$icoEntries[] = ['size' => $size, 'data' => (string) $img];
|
||||
$img->destroy();
|
||||
}
|
||||
self::writeIco($icoEntries, $outputDir . '/favicon.ico');
|
||||
|
||||
self::log('Favicon: generated with Imagick');
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::log('Favicon: Imagick failed: ' . $e->getMessage(), 'warning');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure PHP Backend (zero dependencies) ────────────────────────
|
||||
|
||||
private static function generatePurePHP(string $sourcePath, string $outputDir): bool
|
||||
{
|
||||
$pngData = @file_get_contents($sourcePath);
|
||||
if ($pngData === false) {
|
||||
self::log('Favicon: cannot read source file', 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect format — we can only resize PNG in pure PHP
|
||||
// For JPEG/other formats, just copy the source as-is for each size
|
||||
$isPng = (substr($pngData, 0, 8) === "\x89PNG\r\n\x1a\n");
|
||||
|
||||
if (!$isPng) {
|
||||
// Non-PNG: copy source file for all sizes (no resize capability without extensions)
|
||||
foreach (self::SIZES as $filename => [$w, $h]) {
|
||||
copy($sourcePath, $outputDir . '/' . $filename);
|
||||
}
|
||||
// ICO: embed the raw source for 16 and 32 entries
|
||||
self::writeIco([
|
||||
['size' => 16, 'data' => $pngData],
|
||||
['size' => 32, 'data' => $pngData],
|
||||
], $outputDir . '/favicon.ico');
|
||||
self::log('Favicon: non-PNG source copied without resize (no GD/Imagick)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse PNG dimensions from IHDR
|
||||
$ihdr = self::parsePngIhdr($pngData);
|
||||
if (!$ihdr) {
|
||||
self::log('Favicon: cannot parse PNG header', 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
$srcW = $ihdr['width'];
|
||||
$srcH = $ihdr['height'];
|
||||
|
||||
// Decode PNG to raw RGBA pixel array
|
||||
$pixels = self::decodePngToRgba($pngData, $srcW, $srcH, $ihdr);
|
||||
if ($pixels === null) {
|
||||
// Fallback: copy source for all sizes
|
||||
foreach (self::SIZES as $filename => [$w, $h]) {
|
||||
copy($sourcePath, $outputDir . '/' . $filename);
|
||||
}
|
||||
self::writeIco([
|
||||
['size' => 16, 'data' => $pngData],
|
||||
['size' => 32, 'data' => $pngData],
|
||||
], $outputDir . '/favicon.ico');
|
||||
self::log('Favicon: PNG decode failed, copied source without resize');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generate resized PNGs
|
||||
foreach (self::SIZES as $filename => [$w, $h]) {
|
||||
$resized = self::resizePixels($pixels, $srcW, $srcH, $w, $h);
|
||||
$png = self::encodePng($resized, $w, $h);
|
||||
file_put_contents($outputDir . '/' . $filename, $png);
|
||||
}
|
||||
|
||||
// ICO
|
||||
$icoEntries = [];
|
||||
foreach ([16, 32] as $size) {
|
||||
$resized = self::resizePixels($pixels, $srcW, $srcH, $size, $size);
|
||||
$icoEntries[] = ['size' => $size, 'data' => self::encodePng($resized, $size, $size)];
|
||||
}
|
||||
self::writeIco($icoEntries, $outputDir . '/favicon.ico');
|
||||
|
||||
self::log('Favicon: generated with pure PHP');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PNG IHDR chunk.
|
||||
*/
|
||||
private static function parsePngIhdr(string $data): ?array
|
||||
{
|
||||
if (strlen($data) < 33) return null;
|
||||
// Skip 8-byte signature, 4-byte chunk length, 4-byte "IHDR"
|
||||
$width = unpack('N', substr($data, 16, 4))[1];
|
||||
$height = unpack('N', substr($data, 20, 4))[1];
|
||||
$bitDepth = ord($data[24]);
|
||||
$colorType = ord($data[25]);
|
||||
return ['width' => $width, 'height' => $height, 'bitDepth' => $bitDepth, 'colorType' => $colorType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode PNG to flat RGBA array using zlib decompression.
|
||||
*
|
||||
* @return array|null Flat array of [r,g,b,a, r,g,b,a, ...] or null on failure.
|
||||
*/
|
||||
private static function decodePngToRgba(string $data, int $w, int $h, array $ihdr): ?array
|
||||
{
|
||||
// Only support 8-bit RGBA (color type 6) and RGB (color type 2) for simplicity
|
||||
$colorType = $ihdr['colorType'];
|
||||
$bitDepth = $ihdr['bitDepth'];
|
||||
|
||||
if ($bitDepth !== 8 || ($colorType !== 6 && $colorType !== 2 && $colorType !== 3)) {
|
||||
return null; // Unsupported format
|
||||
}
|
||||
|
||||
// Collect all IDAT chunks
|
||||
$idatData = '';
|
||||
$pos = 8; // Skip PNG signature
|
||||
$palette = null;
|
||||
$trns = null;
|
||||
|
||||
while ($pos < strlen($data) - 4) {
|
||||
$chunkLen = unpack('N', substr($data, $pos, 4))[1];
|
||||
$chunkType = substr($data, $pos + 4, 4);
|
||||
|
||||
if ($chunkType === 'IDAT') {
|
||||
$idatData .= substr($data, $pos + 8, $chunkLen);
|
||||
} elseif ($chunkType === 'PLTE') {
|
||||
$palette = substr($data, $pos + 8, $chunkLen);
|
||||
} elseif ($chunkType === 'tRNS') {
|
||||
$trns = substr($data, $pos + 8, $chunkLen);
|
||||
} elseif ($chunkType === 'IEND') {
|
||||
break;
|
||||
}
|
||||
|
||||
$pos += 12 + $chunkLen; // 4 len + 4 type + data + 4 crc
|
||||
}
|
||||
|
||||
$raw = @gzuncompress($idatData);
|
||||
if ($raw === false) {
|
||||
$raw = @gzinflate($idatData);
|
||||
}
|
||||
if ($raw === false) {
|
||||
// Try with zlib header
|
||||
$raw = @gzinflate(substr($idatData, 2));
|
||||
}
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bpp = $colorType === 6 ? 4 : ($colorType === 2 ? 3 : 1); // bytes per pixel
|
||||
$stride = 1 + $w * $bpp; // +1 for filter byte per row
|
||||
$pixels = [];
|
||||
|
||||
$prevRow = array_fill(0, $w * $bpp, 0);
|
||||
|
||||
for ($y = 0; $y < $h; $y++) {
|
||||
$rowStart = $y * $stride;
|
||||
if ($rowStart >= strlen($raw)) break;
|
||||
|
||||
$filter = ord($raw[$rowStart]);
|
||||
$row = [];
|
||||
|
||||
for ($x = 0; $x < $w * $bpp; $x++) {
|
||||
$rawByte = ord($raw[$rowStart + 1 + $x]);
|
||||
$a = ($x >= $bpp) ? $row[$x - $bpp] : 0;
|
||||
$b = $prevRow[$x];
|
||||
$c = ($x >= $bpp) ? $prevRow[$x - $bpp] : 0;
|
||||
|
||||
$val = match ($filter) {
|
||||
0 => $rawByte,
|
||||
1 => ($rawByte + $a) & 0xFF,
|
||||
2 => ($rawByte + $b) & 0xFF,
|
||||
3 => ($rawByte + (int)(($a + $b) / 2)) & 0xFF,
|
||||
4 => ($rawByte + self::paethPredictor($a, $b, $c)) & 0xFF,
|
||||
default => $rawByte,
|
||||
};
|
||||
|
||||
$row[] = $val;
|
||||
}
|
||||
|
||||
// Convert row to RGBA
|
||||
for ($x = 0; $x < $w; $x++) {
|
||||
if ($colorType === 6) { // RGBA
|
||||
$pixels[] = $row[$x * 4];
|
||||
$pixels[] = $row[$x * 4 + 1];
|
||||
$pixels[] = $row[$x * 4 + 2];
|
||||
$pixels[] = $row[$x * 4 + 3];
|
||||
} elseif ($colorType === 2) { // RGB
|
||||
$pixels[] = $row[$x * 3];
|
||||
$pixels[] = $row[$x * 3 + 1];
|
||||
$pixels[] = $row[$x * 3 + 2];
|
||||
$pixels[] = 255;
|
||||
} elseif ($colorType === 3 && $palette) { // Indexed
|
||||
$idx = $row[$x];
|
||||
$pixels[] = ord($palette[$idx * 3]);
|
||||
$pixels[] = ord($palette[$idx * 3 + 1]);
|
||||
$pixels[] = ord($palette[$idx * 3 + 2]);
|
||||
$pixels[] = ($trns && $idx < strlen($trns)) ? ord($trns[$idx]) : 255;
|
||||
}
|
||||
}
|
||||
|
||||
$prevRow = $row;
|
||||
}
|
||||
|
||||
return $pixels;
|
||||
}
|
||||
|
||||
private static function paethPredictor(int $a, int $b, int $c): int
|
||||
{
|
||||
$p = $a + $b - $c;
|
||||
$pa = abs($p - $a);
|
||||
$pb = abs($p - $b);
|
||||
$pc = abs($p - $c);
|
||||
if ($pa <= $pb && $pa <= $pc) return $a;
|
||||
if ($pb <= $pc) return $b;
|
||||
return $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilinear resize of RGBA pixel array.
|
||||
*/
|
||||
private static function resizePixels(array $src, int $srcW, int $srcH, int $dstW, int $dstH): array
|
||||
{
|
||||
$dst = [];
|
||||
$xRatio = $srcW / $dstW;
|
||||
$yRatio = $srcH / $dstH;
|
||||
|
||||
for ($y = 0; $y < $dstH; $y++) {
|
||||
$srcY = $y * $yRatio;
|
||||
$y0 = (int) $srcY;
|
||||
$y1 = min($y0 + 1, $srcH - 1);
|
||||
$yFrac = $srcY - $y0;
|
||||
|
||||
for ($x = 0; $x < $dstW; $x++) {
|
||||
$srcX = $x * $xRatio;
|
||||
$x0 = (int) $srcX;
|
||||
$x1 = min($x0 + 1, $srcW - 1);
|
||||
$xFrac = $srcX - $x0;
|
||||
|
||||
for ($c = 0; $c < 4; $c++) {
|
||||
$tl = $src[($y0 * $srcW + $x0) * 4 + $c];
|
||||
$tr = $src[($y0 * $srcW + $x1) * 4 + $c];
|
||||
$bl = $src[($y1 * $srcW + $x0) * 4 + $c];
|
||||
$br = $src[($y1 * $srcW + $x1) * 4 + $c];
|
||||
|
||||
$top = $tl + ($tr - $tl) * $xFrac;
|
||||
$bot = $bl + ($br - $bl) * $xFrac;
|
||||
$dst[] = (int) round($top + ($bot - $top) * $yFrac);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode RGBA pixel array to PNG binary.
|
||||
*/
|
||||
private static function encodePng(array $pixels, int $w, int $h): string
|
||||
{
|
||||
// Build raw image data with filter byte 0 (None) per row
|
||||
$raw = '';
|
||||
for ($y = 0; $y < $h; $y++) {
|
||||
$raw .= "\x00"; // filter: None
|
||||
for ($x = 0; $x < $w; $x++) {
|
||||
$i = ($y * $w + $x) * 4;
|
||||
$raw .= chr($pixels[$i]) . chr($pixels[$i + 1]) . chr($pixels[$i + 2]) . chr($pixels[$i + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
$compressed = gzcompress($raw);
|
||||
|
||||
// Build PNG
|
||||
$png = "\x89PNG\r\n\x1a\n";
|
||||
|
||||
// IHDR
|
||||
$ihdr = pack('NNCCCC', $w, $h, 8, 6, 0, 0, 0); // 8-bit RGBA
|
||||
$png .= self::pngChunk('IHDR', $ihdr);
|
||||
|
||||
// IDAT
|
||||
$png .= self::pngChunk('IDAT', $compressed);
|
||||
|
||||
// IEND
|
||||
$png .= self::pngChunk('IEND', '');
|
||||
|
||||
return $png;
|
||||
}
|
||||
|
||||
private static function pngChunk(string $type, string $data): string
|
||||
{
|
||||
$chunk = $type . $data;
|
||||
return pack('N', strlen($data)) . $chunk . pack('N', crc32($chunk));
|
||||
}
|
||||
|
||||
// ── Shared Utilities ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write ICO file from PNG data entries.
|
||||
*/
|
||||
private static function writeIco(array $entries, string $outPath): void
|
||||
{
|
||||
$count = count($entries);
|
||||
$ico = pack('vvv', 0, 1, $count);
|
||||
$offset = 6 + ($count * 16);
|
||||
$imageData = '';
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$size = $entry['size'] >= 256 ? 0 : $entry['size'];
|
||||
$dataLen = strlen($entry['data']);
|
||||
$ico .= pack('CCCCvvVV', $size, $size, 0, 0, 1, 32, $dataLen, $offset);
|
||||
$imageData .= $entry['data'];
|
||||
$offset += $dataLen;
|
||||
}
|
||||
|
||||
file_put_contents($outPath, $ico . $imageData);
|
||||
}
|
||||
|
||||
private static function generateManifest(string $outputDir): void
|
||||
{
|
||||
$manifest = [
|
||||
'icons' => [
|
||||
['src' => 'android-chrome-192x192.png', 'sizes' => '192x192', 'type' => 'image/png'],
|
||||
['src' => 'android-chrome-512x512.png', 'sizes' => '512x512', 'type' => 'image/png'],
|
||||
],
|
||||
];
|
||||
file_put_contents(
|
||||
$outputDir . '/site.webmanifest',
|
||||
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
public static function getHeadTags(string $basePath): string
|
||||
{
|
||||
$basePath = htmlspecialchars(rtrim($basePath, '/'), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<link rel="apple-touch-icon" sizes="180x180" href="' . $basePath . '/apple-touch-icon.png">' . "\n"
|
||||
. '<link rel="icon" type="image/png" sizes="32x32" href="' . $basePath . '/favicon-32x32.png">' . "\n"
|
||||
. '<link rel="icon" type="image/png" sizes="16x16" href="' . $basePath . '/favicon-16x16.png">' . "\n"
|
||||
. '<link rel="manifest" href="' . $basePath . '/site.webmanifest">' . "\n"
|
||||
. '<link rel="shortcut icon" href="' . $basePath . '/favicon.ico">' . "\n";
|
||||
}
|
||||
|
||||
private static function log(string $message, string $priority = 'info'): void
|
||||
{
|
||||
$priorities = [
|
||||
'info' => Log::INFO,
|
||||
'warning' => Log::WARNING,
|
||||
'error' => Log::ERROR,
|
||||
];
|
||||
|
||||
Log::addLogger(
|
||||
['text_file' => 'mokoonyx.log.php'],
|
||||
Log::ALL,
|
||||
['mokoonyx']
|
||||
);
|
||||
|
||||
Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokoonyx');
|
||||
}
|
||||
}
|
||||
1
src/helper/index.html
Normal file
1
src/helper/index.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
165
src/helper/minify.php
Normal file
165
src/helper/minify.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user