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 <head> when favicon source is configured
- Language strings added for en-GB and en-US

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:45:34 -05:00
parent de5b4395cd
commit 96161f34ae
6 changed files with 210 additions and 0 deletions

173
src/helper/favicon.php Normal file
View File

@@ -0,0 +1,173 @@
<?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
*/
/**
* 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 <link> tags to inject into <head>.
*
* @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 '<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";
}
}

1
src/helper/index.html Normal file
View File

@@ -0,0 +1 @@
<!DOCTYPE html><title></title>

View File

@@ -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
<head>
<?php if (trim($params_custom_head_start)) : ?><?php echo $params_custom_head_start; ?><?php endif; ?>
<jdoc:include type="head" />
<?php if ($faviconHeadTags) : ?>
<?php echo $faviconHeadTags; ?>
<?php endif; ?>
<?php if ($params_theme_enabled) : ?>
<script>

View File

@@ -36,6 +36,12 @@ TPL_MOKOCASSIOPEIA_DRAWER_LEFT_ICON_DESC="Enter the Font-Awesome class for the l
TPL_MOKOCASSIOPEIA_DRAWER_RIGHT_ICON_LABEL="Drawer Right Icon CSS"
TPL_MOKOCASSIOPEIA_DRAWER_RIGHT_ICON_DESC="Enter the Font-Awesome class for the right drawer toggle (e.g. 'fas fa-chevron-right')."
; ===== Favicon =====
TPL_MOKOCASSIOPEIA_FAVICON_FIELDSET_LABEL="Favicon"
TPL_MOKOCASSIOPEIA_FAVICON_NOTE="<p>Upload a square <strong>PNG image</strong> (recommended 512×512 or larger). The template will automatically generate all required favicon sizes including ICO, Apple Touch Icon (180×180), and Android icons (192×192, 512×512). Generated files are cached in <code>images/favicons/</code>.</p>"
TPL_MOKOCASSIOPEIA_FAVICON_SOURCE_LABEL="Favicon Source Image"
TPL_MOKOCASSIOPEIA_FAVICON_SOURCE_DESC="Select a square PNG image to use as the site favicon. Recommended size: 512×512 pixels or larger."
; ===== Google =====
TPL_MOKOCASSIOPEIA_GOOGLE_FIELDSET_LABEL="Google"
TPL_MOKOCASSIOPEIA_GOOGLE_NOTE_TEXT="<h3>PLEASE NOTE:</h3>If fields are left blank, relative Google features will not be used"

View File

@@ -36,6 +36,12 @@ TPL_MOKOCASSIOPEIA_DRAWER_LEFT_ICON_DESC="Enter the Font-Awesome class for the l
TPL_MOKOCASSIOPEIA_DRAWER_RIGHT_ICON_LABEL="Drawer Right Icon CSS"
TPL_MOKOCASSIOPEIA_DRAWER_RIGHT_ICON_DESC="Enter the Font-Awesome class for the right drawer toggle (e.g. 'fas fa-chevron-right')."
; ===== Favicon =====
TPL_MOKOCASSIOPEIA_FAVICON_FIELDSET_LABEL="Favicon"
TPL_MOKOCASSIOPEIA_FAVICON_NOTE="<p>Upload a square <strong>PNG image</strong> (recommended 512×512 or larger). The template will automatically generate all required favicon sizes including ICO, Apple Touch Icon (180×180), and Android icons (192×192, 512×512). Generated files are cached in <code>images/favicons/</code>.</p>"
TPL_MOKOCASSIOPEIA_FAVICON_SOURCE_LABEL="Favicon Source Image"
TPL_MOKOCASSIOPEIA_FAVICON_SOURCE_DESC="Select a square PNG image to use as the site favicon. Recommended size: 512×512 pixels or larger."
; ===== Google =====
TPL_MOKOCASSIOPEIA_GOOGLE_FIELDSET_LABEL="Google"
TPL_MOKOCASSIOPEIA_GOOGLE_NOTE_TEXT="<h3>PLEASE NOTE:</h3>If fields are left blank, relative Google features will not be used"

View File

@@ -53,6 +53,7 @@
<filename>script.php</filename>
<filename>sync_custom_vars.php</filename>
<filename>templateDetails.xml</filename>
<folder>helper</folder>
<folder>html</folder>
<folder>language</folder>
<folder>templates</folder>
@@ -116,6 +117,12 @@
</field>
</fieldset>
<!-- Favicon tab -->
<fieldset name="favicon" label="TPL_MOKOCASSIOPEIA_FAVICON_FIELDSET_LABEL">
<field name="favicon_note" type="note" description="TPL_MOKOCASSIOPEIA_FAVICON_NOTE" />
<field name="favicon_source" type="media" label="TPL_MOKOCASSIOPEIA_FAVICON_SOURCE_LABEL" description="TPL_MOKOCASSIOPEIA_FAVICON_SOURCE_DESC" directory="favicons" types="images" accept="image/png" />
</fieldset>
<!-- Google tab -->
<fieldset name="google" label="TPL_MOKOCASSIOPEIA_GOOGLE_FIELDSET_LABEL">
<field name="googletagmanager" type="radio" label="TPL_MOKOCASSIOPEIA_GOOGLETAGMANAGER_LABEL" description="TPL_MOKOCASSIOPEIA_GOOGLETAGMANAGER_DESC" layout="joomla.form.field.radio.switcher" filter="boolean">