From 96161f34aecd04b684ca64510d1a0f260daa81bd Mon Sep 17 00:00:00 2001
From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com>
Date: Thu, 2 Apr 2026 13:45:34 -0500
Subject: [PATCH] Add favicon configuration with auto-generation from PNG
upload
- New "Favicon" tab in template config with media picker for PNG upload
- helper/favicon.php generates all standard sizes from source PNG using GD:
- favicon.ico (16x16 + 32x32 embedded)
- apple-touch-icon.png (180x180)
- favicon-32x32.png, favicon-16x16.png
- android-chrome-192x192.png, android-chrome-512x512.png
- site.webmanifest for PWA icon discovery
- Generated files cached in images/favicons/ with timestamp checking
- Link tags auto-injected in
when favicon source is configured
- Language strings added for en-GB and en-US
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/helper/favicon.php | 173 ++++++++++++++++++++++
src/helper/index.html | 1 +
src/index.php | 17 +++
src/language/en-GB/tpl_mokocassiopeia.ini | 6 +
src/language/en-US/tpl_mokocassiopeia.ini | 6 +
src/templateDetails.xml | 7 +
6 files changed, 210 insertions(+)
create mode 100644 src/helper/favicon.php
create mode 100644 src/helper/index.html
diff --git a/src/helper/favicon.php b/src/helper/favicon.php
new file mode 100644
index 0000000..2567e86
--- /dev/null
+++ b/src/helper/favicon.php
@@ -0,0 +1,173 @@
+
+ *
+ * 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 PNG uploaded via the template config.
+ */
+
+defined('_JEXEC') or die;
+
+class MokoFaviconHelper
+{
+ /**
+ * Sizes to generate: filename => [width, height, format].
+ * ICO embeds 16×16 and 32×32 internally.
+ */
+ private const SIZES = [
+ 'apple-touch-icon.png' => [180, 180, 'png'],
+ 'favicon-32x32.png' => [32, 32, 'png'],
+ 'favicon-16x16.png' => [16, 16, 'png'],
+ 'android-chrome-192x192.png' => [192, 192, 'png'],
+ 'android-chrome-512x512.png' => [512, 512, 'png'],
+ ];
+
+ /**
+ * Generate all favicon files from a source PNG if they don't already exist
+ * or if the source has been modified since last generation.
+ *
+ * @param string $sourcePath Absolute path to the source PNG.
+ * @param string $outputDir Absolute path to the output directory.
+ *
+ * @return bool True if generation succeeded or files are up to date.
+ */
+ public static function generate(string $sourcePath, string $outputDir): bool
+ {
+ if (!is_file($sourcePath) || !extension_loaded('gd')) {
+ return false;
+ }
+
+ if (!is_dir($outputDir)) {
+ mkdir($outputDir, 0755, true);
+ }
+
+ $sourceTime = filemtime($sourcePath);
+ $stampFile = $outputDir . '/.favicon_generated';
+
+ // Skip if already up to date
+ if (is_file($stampFile) && filemtime($stampFile) >= $sourceTime) {
+ return true;
+ }
+
+ $source = imagecreatefrompng($sourcePath);
+ if (!$source) {
+ return false;
+ }
+
+ imagealphablending($source, false);
+ imagesavealpha($source, true);
+
+ $srcW = imagesx($source);
+ $srcH = imagesy($source);
+
+ // Generate PNG sizes
+ foreach (self::SIZES as $filename => [$w, $h]) {
+ $resized = imagecreatetruecolor($w, $h);
+ imagealphablending($resized, false);
+ imagesavealpha($resized, true);
+ $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
+ imagefill($resized, 0, 0, $transparent);
+
+ imagecopyresampled($resized, $source, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
+ imagepng($resized, $outputDir . '/' . $filename, 9);
+ imagedestroy($resized);
+ }
+
+ // Generate ICO (contains 16×16 and 32×32)
+ self::generateIco($source, $srcW, $srcH, $outputDir . '/favicon.ico');
+
+ // Generate site.webmanifest
+ self::generateManifest($outputDir);
+
+ imagedestroy($source);
+
+ // Write timestamp stamp
+ file_put_contents($stampFile, date('c'));
+
+ return true;
+ }
+
+ /**
+ * Build a minimal ICO file containing 16×16 and 32×32 PNG entries.
+ */
+ private static function generateIco(\GdImage $source, int $srcW, int $srcH, string $outPath): void
+ {
+ $entries = [];
+ foreach ([16, 32] as $size) {
+ $resized = imagecreatetruecolor($size, $size);
+ imagealphablending($resized, false);
+ imagesavealpha($resized, true);
+ $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
+ imagefill($resized, 0, 0, $transparent);
+ imagecopyresampled($resized, $source, 0, 0, 0, 0, $size, $size, $srcW, $srcH);
+
+ ob_start();
+ imagepng($resized, null, 9);
+ $pngData = ob_get_clean();
+ imagedestroy($resized);
+
+ $entries[] = ['size' => $size, 'data' => $pngData];
+ }
+
+ // ICO header: 2 bytes reserved, 2 bytes type (1=ICO), 2 bytes count
+ $count = count($entries);
+ $ico = pack('vvv', 0, 1, $count);
+
+ // Calculate offset: header (6) + directory entries (16 each)
+ $offset = 6 + ($count * 16);
+ $imageData = '';
+
+ foreach ($entries as $entry) {
+ $size = $entry['size'] >= 256 ? 0 : $entry['size'];
+ $dataLen = strlen($entry['data']);
+
+ // ICONDIRENTRY: width, height, colors, reserved, planes, bpp, size, offset
+ $ico .= pack('CCCCvvVV', $size, $size, 0, 0, 1, 32, $dataLen, $offset);
+ $imageData .= $entry['data'];
+ $offset += $dataLen;
+ }
+
+ file_put_contents($outPath, $ico . $imageData);
+ }
+
+ /**
+ * Write a site.webmanifest for Android/PWA icon discovery.
+ */
+ 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)
+ );
+ }
+
+ /**
+ * Return the tags to inject into .
+ *
+ * @param string $basePath URL path to the favicon directory (relative to site root).
+ *
+ * @return string HTML link tags.
+ */
+ public static function getHeadTags(string $basePath): string
+ {
+ $basePath = rtrim($basePath, '/');
+
+ return ' ' . "\n"
+ . ' ' . "\n"
+ . ' ' . "\n"
+ . ' ' . "\n"
+ . ' ' . "\n";
+ }
+}
diff --git a/src/helper/index.html b/src/helper/index.html
new file mode 100644
index 0000000..2efb97f
--- /dev/null
+++ b/src/helper/index.html
@@ -0,0 +1 @@
+
diff --git a/src/index.php b/src/index.php
index 69e7525..1b85efb 100644
--- a/src/index.php
+++ b/src/index.php
@@ -57,6 +57,7 @@ $params_googlesitekey = $this->params->get('googlesitekey', null);
$params_custom_head_start = $this->params->get('custom_head_start', null);
$params_custom_head_end = $this->params->get('custom_head_end', null);
$params_developmentmode = $this->params->get('developmentmode', false);
+$params_favicon_source = (string) $this->params->get('favicon_source', '');
// Theme params
$params_theme_enabled = $this->params->get('theme_enabled', 1);
@@ -78,6 +79,19 @@ $pageclass = $menu !== null ? $menu->getParams()->get('pageclass_sfx', '') : '';
// Template/Media path
$templatePath = 'media/templates/site/mokocassiopeia';
+// Favicon generation
+$faviconHeadTags = '';
+if ($params_favicon_source) {
+ require_once __DIR__ . '/helper/favicon.php';
+ $faviconSourceAbs = JPATH_ROOT . '/' . ltrim($params_favicon_source, '/');
+ $faviconOutputDir = JPATH_ROOT . '/images/favicons';
+ $faviconUrlBase = Uri::root(true) . '/images/favicons';
+
+ if (MokoFaviconHelper::generate($faviconSourceAbs, $faviconOutputDir)) {
+ $faviconHeadTags = MokoFaviconHelper::getHeadTags($faviconUrlBase);
+ }
+}
+
// Core template CSS
$wa->useStyle('template.base'); // css/template.css
@@ -246,6 +260,9 @@ $wa->useScript('user.js'); // js/user.js
+
+
+