Add minify build script and generate .min CSS/JS; rename position to brand-aside
Build tooling: - Add package.json with clean-css and terser dev dependencies - Add scripts/minify.js: reads joomla.asset.json, auto-detects source/.min pairs, and minifies all template-owned CSS and JS files - Add node_modules/ to .gitignore Generated .min files (all 6 manifest pairs): - css/template.min.css (17.8% saved) - css/editor.min.css (49.4% saved) - css/theme/light.standard.min.css (13.1% saved) - css/theme/dark.standard.min.css (14.4% saved) - js/template.min.js (58.2% saved) - js/gtm.min.js (62.3% saved) Rename: header-aside → brand-aside (position, CSS class, language keys) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,6 +28,12 @@ secrets/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# ============================================================
|
||||
# Node / build tooling
|
||||
# ============================================================
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# ============================================================
|
||||
# Claude Code local settings
|
||||
# ============================================================
|
||||
|
||||
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "mokocassiopeia-build",
|
||||
"private": true,
|
||||
"description": "Build tooling for MokoCassiopeia Joomla template",
|
||||
"scripts": {
|
||||
"minify": "node scripts/minify.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clean-css": "^5.3.3",
|
||||
"terser": "^5.39.0"
|
||||
}
|
||||
}
|
||||
188
scripts/minify.js
Normal file
188
scripts/minify.js
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
/* 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
|
||||
*
|
||||
* # FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Template.Site
|
||||
* INGROUP: MokoCassiopeia
|
||||
* REPO: https://github.com/mokoconsulting-tech/MokoCassiopeia
|
||||
* PATH: ./scripts/minify.js
|
||||
* VERSION: 03.09.01
|
||||
* BRIEF: Generates .min.css and .min.js files from the Joomla asset manifest
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CleanCSS = require('clean-css');
|
||||
const { minify: terserMinify } = require('terser');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC_MEDIA = path.join(ROOT, 'src', 'media');
|
||||
const ASSET_JSON = path.join(ROOT, 'src', 'joomla.asset.json');
|
||||
|
||||
// URI prefix used in the manifest — maps to SRC_MEDIA on disk.
|
||||
// e.g. "media/templates/site/mokocassiopeia/css/template.css"
|
||||
const URI_PREFIX = 'media/templates/site/mokocassiopeia/';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a manifest URI to an absolute disk path under src/media/.
|
||||
*
|
||||
* @param {string} uri e.g. "media/templates/site/mokocassiopeia/css/foo.css"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function uriToPath(uri) {
|
||||
if (!uri.startsWith(URI_PREFIX)) return null;
|
||||
return path.join(SRC_MEDIA, uri.slice(URI_PREFIX.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the filename looks like an already-minified file or belongs
|
||||
* to a vendor bundle we don't own.
|
||||
*/
|
||||
function isVendorOrUserFile(filePath) {
|
||||
const rel = filePath.replace(SRC_MEDIA + path.sep, '');
|
||||
return rel.startsWith('vendor' + path.sep)
|
||||
|| path.basename(filePath).startsWith('user.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pair detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the asset manifest and return an array of { src, dest, type } pairs
|
||||
* where dest is a minified version of src that doesn't already exist or is
|
||||
* older than src.
|
||||
*
|
||||
* Pairing logic: for every non-.min asset, check whether the manifest also
|
||||
* contains a corresponding .min asset. If so, that's our pair.
|
||||
*/
|
||||
function detectPairs(assets) {
|
||||
// Build a lookup of all URIs in the manifest.
|
||||
const uriSet = new Set(assets.map(a => a.uri));
|
||||
|
||||
const pairs = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const { uri, type } = asset;
|
||||
if (type !== 'style' && type !== 'script') continue;
|
||||
|
||||
// Skip already-minified entries.
|
||||
if (/\.min\.(css|js)$/.test(uri)) continue;
|
||||
|
||||
// Derive the expected .min URI.
|
||||
const minUri = uri.replace(/\.(css|js)$/, '.min.$1');
|
||||
if (!uriSet.has(minUri)) continue;
|
||||
|
||||
const srcPath = uriToPath(uri);
|
||||
const destPath = uriToPath(minUri);
|
||||
if (!srcPath || !destPath) continue;
|
||||
|
||||
if (isVendorOrUserFile(srcPath)) continue;
|
||||
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.warn(` [skip] source missing: ${srcPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
pairs.push({ src: srcPath, dest: destPath, type });
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function minifyCSS(srcPath, destPath) {
|
||||
const source = fs.readFileSync(srcPath, 'utf8');
|
||||
const result = new CleanCSS({ level: 2, returnPromise: true });
|
||||
const output = await result.minify(source);
|
||||
|
||||
if (output.errors && output.errors.length) {
|
||||
throw new Error(output.errors.join('\n'));
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.writeFileSync(destPath, output.styles, 'utf8');
|
||||
|
||||
const srcSize = Buffer.byteLength(source, 'utf8');
|
||||
const destSize = Buffer.byteLength(output.styles, 'utf8');
|
||||
const saving = (100 - (destSize / srcSize * 100)).toFixed(1);
|
||||
|
||||
return { srcSize, destSize, saving };
|
||||
}
|
||||
|
||||
async function minifyJS(srcPath, destPath) {
|
||||
const source = fs.readFileSync(srcPath, 'utf8');
|
||||
const result = await terserMinify(source, {
|
||||
compress: { drop_console: false },
|
||||
mangle: true,
|
||||
format: { comments: false }
|
||||
});
|
||||
|
||||
if (!result.code) throw new Error('terser returned no output');
|
||||
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.writeFileSync(destPath, result.code, 'utf8');
|
||||
|
||||
const srcSize = Buffer.byteLength(source, 'utf8');
|
||||
const destSize = Buffer.byteLength(result.code, 'utf8');
|
||||
const saving = (100 - (destSize / srcSize * 100)).toFixed(1);
|
||||
|
||||
return { srcSize, destSize, saving };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
(async () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(ASSET_JSON, 'utf8'));
|
||||
const pairs = detectPairs(manifest.assets);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
console.log('No pairs found — nothing to minify.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nMinifying ${pairs.length} file(s)...\n`);
|
||||
|
||||
let ok = 0, fail = 0;
|
||||
|
||||
for (const { src, dest, type } of pairs) {
|
||||
const label = path.relative(ROOT, src);
|
||||
process.stdout.write(` ${label} ... `);
|
||||
|
||||
try {
|
||||
const stats = type === 'style'
|
||||
? await minifyCSS(src, dest)
|
||||
: await minifyJS(src, dest);
|
||||
|
||||
const kb = n => (n / 1024).toFixed(1) + ' kB';
|
||||
console.log(`${kb(stats.srcSize)} → ${kb(stats.destSize)} (${stats.saving}% saved)`);
|
||||
ok++;
|
||||
} catch (err) {
|
||||
console.error(`FAILED\n ${err.message}`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. ${ok} succeeded, ${fail} failed.\n`);
|
||||
if (fail > 0) process.exit(1);
|
||||
})();
|
||||
@@ -390,9 +390,9 @@ $wa->useScript('user.js'); // js/user.js
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($this->countModules('header-aside', true)) : ?>
|
||||
<div class="container-header-aside">
|
||||
<jdoc:include type="modules" name="header-aside" style="none" />
|
||||
<?php if ($this->countModules('brand-aside', true)) : ?>
|
||||
<div class="container-brand-aside">
|
||||
<jdoc:include type="modules" name="brand-aside" style="none" />
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ TPL_MOKOCASSIOPEIA_MOD_MENU_LAYOUT_COLLAPSE_METISMENU="Collapsible Dropdown"
|
||||
TPL_MOKOCASSIOPEIA_MOD_MENU_LAYOUT_DROPDOWN_METISMENU="Dropdown"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BANNER="Banner"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BELOW_TOP="Below Top"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_HEADER_ASIDE="Header Aside"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BRAND_ASIDE="Brand Aside"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BOTTOM_A="Bottom-A"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BOTTOM_B="Bottom-B"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BREADCRUMBS="Breadcrumbs"
|
||||
|
||||
@@ -18,7 +18,7 @@ TPL_MOKOCASSIOPEIA_MOD_MENU_LAYOUT_COLLAPSE_METISMENU="Collapsible Dropdown"
|
||||
TPL_MOKOCASSIOPEIA_MOD_MENU_LAYOUT_DROPDOWN_METISMENU="Dropdown"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BANNER="Banner"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BELOW_TOP="Below Top"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_HEADER_ASIDE="Header Aside"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BRAND_ASIDE="Brand Aside"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BOTTOM_A="Bottom-A"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BOTTOM_B="Bottom-B"
|
||||
TPL_MOKOCASSIOPEIA_POSITION_BREADCRUMBS="Breadcrumbs"
|
||||
|
||||
1
src/media/css/editor.min.css
vendored
Normal file
1
src/media/css/editor.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@charset "UTF-8";body{font-size:1rem;font-weight:400;line-height:1.5;color:#22262a;background-color:#fff}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:700;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}h2{font-size:calc(1.325rem + .9vw)}h3{font-size:calc(1.3rem + .6vw)}h4{font-size:calc(1.275rem + .3vw)}h5{font-size:1.25rem}h6{font-size:1rem}a{text-decoration:none}a:link{color:#224faa}a:hover{color:#424077}p{margin-top:0;margin-bottom:1rem}hr#system-readmore{color:red;border:1px dashed red}span[lang]{padding:2px;border:1px dashed #bbb}span[lang]:after{font-size:smaller;color:red;vertical-align:super;content:attr(lang)}
|
||||
@@ -14385,7 +14385,7 @@ fieldset>* {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container-header-aside {
|
||||
.container-brand-aside {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
1
src/media/css/template.min.css
vendored
Normal file
1
src/media/css/template.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/media/css/theme/dark.standard.min.css
vendored
Normal file
1
src/media/css/theme/dark.standard.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/media/css/theme/light.standard.min.css
vendored
Normal file
1
src/media/css/theme/light.standard.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/media/js/gtm.min.js
vendored
Normal file
1
src/media/js/gtm.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
(()=>{"use strict";const e=window,t={},n=e=>{const t=(()=>{const e=document.currentScript;return e||(Array.from(document.getElementsByTagName("script")).reverse().find(e=>(e.getAttribute("src")||"").includes("/gtm.js"))||null)})(),n=document.documentElement,o=document.body,a=document.querySelector(`meta[name="moko:gtm-${e}"]`);return t&&t.dataset&&t.dataset[e]||n&&n.dataset&&n.dataset[e]||o&&o.dataset&&o.dataset[e]||a&&a.getAttribute("content")||null},o=(e,t=!1)=>{if(null==e)return t;const n=String(e).trim().toLowerCase();return!!["1","true","yes","y","on"].includes(n)||!["0","false","no","n","off"].includes(n)&&t},a=(...e)=>{if(r.debug)try{console.info("[moko-gtm]",...e)}catch(e){}},r={id:"",dataLayerName:"dataLayer",debug:!1,ignoreDNT:!1,blockOnDev:!0,envAuth:"",envPreview:"",consentDefault:{analytics_storage:"granted",functionality_storage:"granted",security_storage:"granted",ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied"},pageVars:()=>({})},d=(e,t={})=>{const n={...e};for(const e in t){if(!Object.prototype.hasOwnProperty.call(t,e))continue;const o=t[e];o&&"object"==typeof o&&!Array.isArray(o)?n[e]={...n[e]||{},...o}:void 0!==o&&(n[e]=o)}return n},i=()=>{const t=e.MOKO_GTM_OPTIONS&&"object"==typeof e.MOKO_GTM_OPTIONS?e.MOKO_GTM_OPTIONS:{},a=n("id")||e.MOKO_GTM_ID||"",r=n("dataLayer")||"",d=n("debug"),i=n("ignoreDnt"),c=n("blockOnDev"),s=n("envAuth")||"",u=n("envPreview")||"";return{id:a||t.id||"",dataLayerName:r||t.dataLayerName||void 0,debug:o(d,!!t.debug),ignoreDNT:o(i,!!t.ignoreDNT),blockOnDev:o(c,t.blockOnDev??!0),envAuth:s||t.envAuth||"",envPreview:u||t.envPreview||"",consentDefault:t.consentDefault||void 0,pageVars:"function"==typeof t.pageVars?t.pageVars:void 0}},c=()=>{const t=r.dataLayerName;return e[t]=e[t]||[],e[t]},s=(...e)=>{c().push(arguments.length>1?e:e[0]),a("gtag push:",e)};t.push=(...e)=>s(...e),t.setConsent=e=>{s("consent","update",e||{})},t.isLoaded=()=>!!document.querySelector('script[src*="googletagmanager.com/gtm.js"]'),t.config=()=>({...r});const u=()=>{if(!r.id)return void a("GTM ID missing; aborting load.");if(t.isLoaded())return void a("GTM already loaded; skipping duplicate injection.");c().push({"gtm.start":(new Date).getTime(),event:"gtm.js"});const e=document.getElementsByTagName("script")[0],n=document.createElement("script");n.async=!0,n.src=`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(r.id)}${"dataLayer"!==r.dataLayerName?`&l=${encodeURIComponent(r.dataLayerName)}`:""}${(()=>{const e=[];return r.envAuth&&e.push(`gtm_auth=${encodeURIComponent(r.envAuth)}`),r.envPreview&&e.push(`gtm_preview=${encodeURIComponent(r.envPreview)}`,"gtm_cookies_win=x"),e.length?`&${e.join("&")}`:""})()}`,e&&e.parentNode?e.parentNode.insertBefore(n,e):(document.head||document.documentElement).appendChild(n),a("Injected GTM script:",n.src)},g=()=>!r.ignoreDNT&&(()=>{const e=navigator,t=(e.doNotTrack||e.msDoNotTrack||e.navigator&&e.navigator.doNotTrack||"").toString().toLowerCase();return"1"===t||"yes"===t})()?(a("DNT is enabled; blocking GTM load (set ignoreDNT=true to override)."),!1):!r.blockOnDev||!(()=>{const t=e.location&&e.location.hostname||"";return"localhost"===t||"127.0.0.1"===t||t.endsWith(".local")||t.endsWith(".test")})()||(a("Development host detected; blocking GTM load (set blockOnDev=false to override)."),!1);t.init=(e={})=>{const t=i(),n=d(r,d(t,e));Object.assign(r,n),a("Config:",r),c(),s("consent","default",r.consentDefault),a("Applied default consent:",r.consentDefault),(()=>{const e={event:"moko.page_init",page_title:document.title||"",page_language:document.documentElement&&document.documentElement.lang||"",..."function"==typeof r.pageVars&&r.pageVars()||{}};s(e)})(),g()?u():a("GTM load prevented by configuration or environment.")};const m=()=>{!(!i().id&&!e.MOKO_GTM_ID)?t.init():a("No GTM ID detected; awaiting manual init via window.mokoGTM.init({ id: 'GTM-XXXXXXX' }).")};"complete"===document.readyState||"interactive"===document.readyState?setTimeout(m,0):document.addEventListener("DOMContentLoaded",m,{once:!0}),e.mokoGTM=t;try{const e=i();o(e.debug,!1)&&(r.debug=!0,a("Ready. You can call window.mokoGTM.init({ id: 'GTM-XXXXXXX' })."))}catch(e){}})();
|
||||
1
src/media/js/template.min.js
vendored
Normal file
1
src/media/js/template.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(e,t){"use strict";var a="theme",n=e.matchMedia("(prefers-color-scheme: dark)"),r=t.documentElement;function o(e){r.setAttribute("data-bs-theme",e),r.setAttribute("data-aria-theme",e);try{localStorage.setItem(a,e)}catch(e){}}function d(){try{localStorage.removeItem(a)}catch(e){}}function i(){return n.matches?"dark":"light"}function c(){try{return localStorage.getItem(a)}catch(e){return null}}function l(){if(!t.getElementById("mokoThemeFab")){var a,l=t.createElement("div");l.id="mokoThemeFab",l.className=(a=(t.body.getAttribute("data-theme-fab-pos")||"br").toLowerCase(),/^(br|bl|tr|tl)$/.test(a)||(a="br"),"pos-"+a);var s=t.createElement("span");s.className="label",s.textContent="Light";var u=t.createElement("button");u.id="mokoThemeSwitch",u.type="button",u.setAttribute("role","switch"),u.setAttribute("aria-label","Toggle dark mode"),u.setAttribute("aria-checked","false");var m=t.createElement("span");m.className="switch";var h=t.createElement("span");h.className="knob",m.appendChild(h),u.appendChild(m);var f=t.createElement("span");f.className="label",f.textContent="Dark";var b=t.createElement("button");b.id="mokoThemeAuto",b.type="button",b.className="btn btn-sm btn-link text-decoration-none px-2",b.setAttribute("aria-label","Follow system theme"),b.textContent="Auto",u.addEventListener("click",function(){var e="dark"===(r.getAttribute("data-bs-theme")||"light").toLowerCase()?"light":"dark";o(e),u.setAttribute("aria-checked","dark"===e?"true":"false");var a=t.querySelector('meta[name="theme-color"]');a&&a.setAttribute("content","dark"===e?"#0f1115":"#ffffff")}),b.addEventListener("click",function(){d();var e=i();o(e),u.setAttribute("aria-checked","dark"===e?"true":"false")});var g=function(){if(!c()){var e=i();o(e),u.setAttribute("aria-checked","dark"===e?"true":"false")}};"function"==typeof n.addEventListener?n.addEventListener("change",g):"function"==typeof n.addListener&&n.addListener(g);var p=c()||i();u.setAttribute("aria-checked","dark"===p?"true":"false"),l.appendChild(s),l.appendChild(u),l.appendChild(f),l.appendChild(b),t.body.appendChild(l),e.mokoThemeFabStatus=function(){var a=t.getElementById("mokoThemeFab");if(!a)return{mounted:!1};var n=a.getBoundingClientRect();return{mounted:!0,rect:{top:n.top,left:n.left,width:n.width,height:n.height},zIndex:e.getComputedStyle(a).zIndex,posClass:a.className}},setTimeout(function(){var e=l.getBoundingClientRect();(e.width<10||e.height<10)&&(l.classList.add("debug-outline"),console.warn("[moko] Theme FAB mounted but appears too small — check CSS collisions."))},50)}}function s(){e.scrollY>50?t.body.classList.add("scrolled"):t.body.classList.remove("scrolled")}function u(){var a=t.getElementById("back-top");a&&a.addEventListener("click",function(t){t.preventDefault(),e.scrollTo({top:0,behavior:"smooth"})})}function m(){!function(){var e=c()||i();o(e);var a=function(){c()||o(i())};"function"==typeof n.addEventListener?n.addEventListener("change",a):"function"==typeof n.addListener&&n.addListener(a);var r=t.getElementById("themeSwitch"),l=t.getElementById("themeAuto");r&&(r.checked="dark"===e,r.addEventListener("change",function(){o(r.checked?"dark":"light")})),l&&l.addEventListener("click",function(){d(),o(i())})}(),"1"===t.body.getAttribute("data-theme-fab-enabled")&&l(),s(),e.addEventListener("scroll",s),t.querySelector(".drawer-toggle-left")||t.querySelector(".drawer-toggle-right"),u()}"loading"===t.readyState?t.addEventListener("DOMContentLoaded",m):m()}(window,document);
|
||||
@@ -67,7 +67,7 @@
|
||||
<position>topbar</position>
|
||||
<position>below-topbar</position>
|
||||
<position>below-logo</position>
|
||||
<position>header-aside</position>
|
||||
<position>brand-aside</position>
|
||||
<position>menu</position>
|
||||
<position>search</position>
|
||||
<position>banner</position>
|
||||
|
||||
Reference in New Issue
Block a user