/**
* KoalaSync Static Site Generator (i18n compiler)
* Build pipeline: esbuild + AVIF + hashing + SVG min.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const esbuild = require('esbuild');
const { optimize: svgoOptimize } = require('svgo');
// CSS minifier: simple regex-based (proven, 27% reduction, no deps)
function minifyCSS(code) {
return code
.replace(/\/\*[\s\S]*?\*\//g, '')
// Never strip whitespace before `:`: `.parent :is(...)` is a
// descendant selector, while `.parent:is(...)` targets the parent.
.replace(/\s*([{},;])\s*/g, '$1')
.replace(/:\s+/g, ':')
.replace(/\s+/g, ' ')
.replace(/;\}/g, '}')
.trim();
}
if (minifyCSS('.parent :is(.a, .b) { color: red; }') !== '.parent :is(.a,.b){color:red}') {
throw new Error('CSS minifier must preserve descendant combinators before functional pseudo-classes');
}
// HTML minifier: comments + indentation only. Newlines are preserved so
// inline scripts/JSON-LD stay valid;
blocks keep their formatting.
function minifyHTML(html) {
return html.split(/()/).map((part, i) => {
if (i % 2 === 1) return part;
return part
.replace(//g, '')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{2,}/g, '\n');
}).join('');
}
// ── Country-flag font subset (committed artifact, validated here) ────────
// The subset itself is generated by tools/subset-flag-font.mjs (Python
// fontTools — the harfbuzz-based JS subsetters drop the COLR/CPAL color
// tables and produce invisible flags, so the build never subsets itself).
// This step verifies the committed subset still matches the site and ships
// it under a content-hashed name.
function stageFlagFontSubset(websiteDir, wwwDir) {
const { collectFlagSourceFiles, extractUsedFlags, flagToCountryCode } = require('./flag-font-utils.cjs');
const assetsDir = path.join(websiteDir, 'assets');
const subsetPath = path.join(assetsDir, 'TwemojiCountryFlags.subset.woff2');
const manifestPath = path.join(assetsDir, 'TwemojiCountryFlags.subset.json');
const regenerate = 'run "npm run subset-flags" and commit the result';
if (!fs.existsSync(subsetPath) || !fs.existsSync(manifestPath)) {
throw new Error(`Flag font subset or manifest missing – ${regenerate}`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const subset = fs.readFileSync(subsetPath);
const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
if (sha256(subset) !== manifest.subsetSha256) {
throw new Error(`TwemojiCountryFlags.subset.woff2 does not match its manifest – ${regenerate}`);
}
const sourceFont = fs.readFileSync(path.join(assetsDir, 'TwemojiCountryFlags.woff2'));
if (sha256(sourceFont) !== manifest.sourceFontSha256) {
throw new Error(`TwemojiCountryFlags.woff2 changed since the subset was generated – ${regenerate}`);
}
const usedFlags = extractUsedFlags(collectFlagSourceFiles(websiteDir));
if (usedFlags.length === 0) {
throw new Error('Flag extraction found no flag emoji – extraction is broken');
}
const usedCodes = usedFlags.map(flagToCountryCode);
const missing = usedCodes.filter(c => !manifest.flags.includes(c));
if (missing.length > 0) {
throw new Error(`Flags used on the site but missing from the font subset: ${missing.join(', ')} – ${regenerate}`);
}
const extra = manifest.flags.filter(c => !usedCodes.includes(c));
if (extra.length > 0) {
console.warn(` ⚠️ Subset contains unused flags (${extra.join(', ')}) – "npm run subset-flags" would shrink it.`);
}
const name = `TwemojiCountryFlags.${sha8(subset)}.woff2`;
const destAssets = path.join(wwwDir, 'assets');
fs.mkdirSync(destAssets, { recursive: true });
for (const f of fs.readdirSync(destAssets)) {
if (/^TwemojiCountryFlags\.[0-9a-f]{8}\.woff2$/.test(f) && f !== name) {
fs.unlinkSync(path.join(destAssets, f));
}
}
fs.writeFileSync(path.join(destAssets, name), subset);
const pct = ((1 - subset.length / sourceFont.length) * 100).toFixed(0);
console.log(` Flag font: ${name} (${(subset.length / 1024).toFixed(1)} KB, ${usedCodes.length} flags, -${pct}% vs unsubset)`);
return name;
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
entry.isDirectory() ? copyDirSync(s, d) : fs.copyFileSync(s, d);
}
}
function sha8(buf) { return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8); }
function sha384(buf) { return 'sha384-' + crypto.createHash('sha384').update(buf).digest('base64'); }
async function minifyJS(raw) {
const result = await esbuild.transform(raw, {
loader: 'js',
minify: true,
target: 'es2022'
});
return result.code;
}
function injectAvifPictures(html) {
return html.replace(/
]*)>/gi, (match, attrs) => {
const srcMatch = attrs.match(/\bsrc="([^"]*)"/i);
if (!srcMatch) return match;
if (!/\.webp"$/i.test(srcMatch[0])) return match;
const src = srcMatch[1];
const avifSrc = src.replace(/\.webp$/i, '.avif');
const srcsetMatch = attrs.match(/\bsrcset="([^"]*)"/i);
if (srcsetMatch) {
const avifSrcset = srcsetMatch[1].replace(/\.webp/gi, '.avif');
// A with width descriptors requires its own sizes
// attribute; without it browsers may pick and even reject
// candidates (observed as broken images in Chrome).
const sizesMatch = attrs.match(/\bsizes="([^"]*)"/i);
const sizesAttr = sizesMatch ? ` sizes="${sizesMatch[1]}"` : '';
return `
`;
}
return `
`;
});
}
function minifyInlineSvgs(html) {
const svgRegex = /