Files
KoalaSync/website/build.cjs
T

789 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 htmlnano = require('htmlnano');
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 only: parser-based comment removal and conservative whitespace collapse.
// CSS, JS, JSON, and SVG keep their dedicated build steps below.
const HTML_MINIFY_OPTIONS = Object.freeze({
skipConfigLoading: true,
removeComments: 'safe',
collapseWhitespace: 'conservative'
});
const HTML_MINIFY_PRESET = Object.freeze({});
async function minifyHTML(html) {
return (await htmlnano.process(html, HTML_MINIFY_OPTIONS, HTML_MINIFY_PRESET)).html;
}
async function verifyHTMLMinifier() {
const pre = '<pre> keep\n exactly </pre>';
const jsonLd = '<script type="application/ld+json">{"name":"KoalaSync"}</script>';
const conditional = '<!--[if IE]>legacy<![endif]-->';
const result = await minifyHTML(`<!<!--nested-->--unsafe-->${pre}${jsonLd}${conditional}`);
const ordinaryComments = result.replace(conditional, '');
if (ordinaryComments.includes('<!--') || !result.includes(pre) || !result.includes(conditional)) {
throw new Error('HTML minifier security/preservation invariant failed');
}
JSON.parse(result.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
}
// ── 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(/<img\b([^>]*)>/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 <source> 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 `<picture><source srcset="${avifSrcset}"${sizesAttr} type="image/avif"><img${attrs}></picture>`;
}
return `<picture><source srcset="${avifSrc}" type="image/avif"><img${attrs}></picture>`;
});
}
function minifyInlineSvgs(html) {
const svgRegex = /<svg\b[\s\S]*?<\/svg>/gi;
return html.replace(svgRegex, (svg) => {
try {
const result = svgoOptimize(svg, { multipass: true, plugins: ['preset-default'] });
return result.data;
} catch { return svg; }
});
}
async function compile() {
await verifyHTMLMinifier();
console.log('Starting KoalaSync i18n compilation...');
const websiteDir = __dirname;
const wwwDir = path.join(websiteDir, 'www');
fs.mkdirSync(wwwDir, { recursive: true });
// ── 0. Auto-generate website logo sizes and sync favicons ──
console.log('Generating responsive website logos...');
const rawLogoSrc = path.join(websiteDir, '..', 'assets', 'icon', 'TwoPointZero_Logo_Icon_600.webp');
const targetAssetsDir = path.join(websiteDir, 'assets');
if (fs.existsSync(rawLogoSrc)) {
fs.mkdirSync(targetAssetsDir, { recursive: true });
// Generate NewLogoIcon_64.webp (64x64)
await sharp(rawLogoSrc)
.resize(64, 64)
.toFile(path.join(targetAssetsDir, 'NewLogoIcon_64.webp'));
// Generate NewLogoIcon_128.webp (128x128)
await sharp(rawLogoSrc)
.resize(128, 128)
.toFile(path.join(targetAssetsDir, 'NewLogoIcon_128.webp'));
// Generate NewLogoIcon.webp (256x256)
await sharp(rawLogoSrc)
.resize(256, 256)
.toFile(path.join(targetAssetsDir, 'NewLogoIcon.webp'));
console.log(' ✓ WebP logo variants successfully generated in website/assets/');
} else {
console.warn(` ⚠️ Warning: Source logo ${rawLogoSrc} not found. Skipping auto-generation.`);
}
const pngMappings = [
{ src: 'TwoPointZero_Logo_Icon_16.png', dest: 'favicon-16x16.png' },
{ src: 'TwoPointZero_Logo_Icon_32.png', dest: 'favicon-32x32.png' },
{ src: 'TwoPointZero_Logo_Icon_256.png', dest: 'apple-touch-icon.png' },
{ src: 'TwoPointZero_Logo_Icon_256.png', dest: 'icon-192x192.png' }
];
for (const mapping of pngMappings) {
const srcPath = path.join(websiteDir, '..', 'assets', 'icon', mapping.src);
const destPath = path.join(targetAssetsDir, mapping.dest);
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, destPath);
} else {
console.warn(` ⚠️ Warning: Source PNG ${srcPath} not found.`);
}
}
console.log(' ✓ Favicons/touch icons successfully synced to website/assets/');
// Social link preview image (og:image / twitter:image), same artwork as the
// GitHub repository OpenGraph card. PNG kept for maximum scraper support.
const ogSrc = path.join(websiteDir, '..', 'assets', 'StoreAssets', 'RepositoryOpenGraph.png');
if (fs.existsSync(ogSrc)) {
await sharp(ogSrc)
.png({ quality: 80, palette: true })
.toFile(path.join(targetAssetsDir, 'og-image.png'));
console.log(' ✓ og-image.png generated from RepositoryOpenGraph.png');
} else {
console.warn(` ⚠️ Warning: ${ogSrc} not found. Skipping og-image generation.`);
}
// ── 0.7 Stage the committed flag-font subset (~45% smaller) ──
console.log('Staging flag font subset...');
const flagFontName = stageFlagFontSubset(websiteDir, wwwDir);
// ── 1. Minify CSS/JS (must happen first so hashes go into HTML) ──
console.log('Minifying CSS/JS...');
// CSS references the hashed subset instead of the raw font file
const readStyle = relativePath => fs.readFileSync(path.join(websiteDir, relativePath), 'utf8')
.replaceAll('TwemojiCountryFlags.woff2', flagFontName);
const styleModules = [
'styles/foundation.css',
'styles/hero.css',
'styles/landing-primary.css',
'styles/legal.css',
'styles/support.css',
'styles/landing-controls.css',
'styles/join-spinner.css',
'styles/landing-sections.css',
'styles/join.css',
'styles/landing-faq.css',
'styles/alternatives.css',
'styles/demo.css'
];
// Keep the landing cascade equivalent to styleModules while excluding
// utility-page-only rules. A single blocking file avoids both request
// fan-out and layout shifts from activating structural CSS after paint.
const landingBundles = [
{
key: 'landing',
modules: [
'styles/foundation.css',
'styles/hero.css',
'styles/landing-primary.css',
'styles/legal.css',
'styles/landing-controls.css',
'styles/landing-sections.css',
'styles/landing-selfhost.css',
'styles/landing-faq.css',
'styles/demo.css'
]
}
];
const styleRaw = styleModules.map(readStyle).join('');
const styleMin = minifyCSS(styleRaw);
const styleHash = sha8(styleMin);
const styleName = `style.${styleHash}.min.css`;
const styleSRI = sha384(styleMin);
const compiledLandingBundles = landingBundles.map(bundle => {
const raw = bundle.modules.map(readStyle).join('');
const min = minifyCSS(raw);
return {
...bundle,
min,
name: `${bundle.key === 'landing' ? 'landing' : `landing-${bundle.key}`}.${sha8(min)}.min.css`,
sri: sha384(min)
};
});
const appRaw = fs.readFileSync(path.join(websiteDir, 'app.js'), 'utf8');
const appMin = await minifyJS(appRaw);
const appHash = sha8(appMin);
const appName = `app.${appHash}.min.js`;
const appSRI = sha384(appMin);
const langRaw = fs.readFileSync(path.join(websiteDir, 'lang-init.js'), 'utf8');
const langMin = await minifyJS(langRaw);
const langHash = sha8(langMin);
const langName = `lang-init.${langHash}.min.js`;
const langSRI = sha384(langMin);
const stylePct = ((1 - styleMin.length / styleRaw.length) * 100).toFixed(0);
const appPct = ((1 - appMin.length / appRaw.length) * 100).toFixed(0);
const langPct = ((1 - langMin.length / langRaw.length) * 100).toFixed(0);
console.log(` CSS: ${styleName} (${(styleMin.length/1024).toFixed(1)} KB, -${stylePct}%)`);
for (const bundle of compiledLandingBundles) {
console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`);
}
console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`);
console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`);
// ── 2. Clean stale minified output ──
for (const f of fs.readdirSync(wwwDir)) {
if (/\.min\.(css|js)$/.test(f) || /\.(css|js)$/.test(f)) {
const p = path.join(wwwDir, f);
if (fs.statSync(p).isFile()) fs.unlinkSync(p);
}
}
// Write minified files
fs.writeFileSync(path.join(wwwDir, styleName), styleMin);
for (const bundle of compiledLandingBundles) {
fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min);
}
fs.writeFileSync(path.join(wwwDir, appName), appMin);
fs.writeFileSync(path.join(wwwDir, langName), langMin);
// ── 3. Compile HTML templates ──
const templatePath = path.join(websiteDir, 'template.html');
if (!fs.existsSync(templatePath)) {
console.error('Error: template.html not found!');
process.exit(1);
}
const templateContent = fs.readFileSync(templatePath, 'utf8');
const localesDir = path.join(websiteDir, 'locales');
const languages = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'];
// Read version for build-time injection (SEO: crawlers see real version)
const versionJson = JSON.parse(fs.readFileSync(path.join(websiteDir, 'version.json'), 'utf8'));
const buildVersion = versionJson.version || '?';
const englishHtml = {}; // track for join-page cross-ref
function compilePage(locale, assetPath, lang) {
let compiled = templateContent;
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
compiled = compiled.replace(/\{\{VERSION\}\}/g, buildVersion);
compiled = compiled.replace(/\{\{VERSION_DATE\}\}/g, versionJson.date || '');
const langPrefix = lang === 'en' ? '' : `${lang}/`;
compiled = compiled.replace(/\{\{LANG_PREFIX\}\}/g, langPrefix);
languages.forEach(l => {
compiled = compiled.replace(new RegExp(`\\{\\{SELECTED_${l.toUpperCase()}\\}\\}`, 'g'), l === lang ? 'selected' : '');
});
for (const [key, value] of Object.entries(locale)) {
compiled = compiled.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value);
}
return compiled;
}
// Read alternative page templates
const telepartyTemplatePath = path.join(websiteDir, 'alternatives/teleparty.html');
const screenSharingTemplatePath = path.join(websiteDir, 'alternatives/screen-sharing.html');
const watch2getherTemplatePath = path.join(websiteDir, 'alternatives/watch2gether.html');
const scenerTemplatePath = path.join(websiteDir, 'alternatives/scener.html');
const kosmiTemplatePath = path.join(websiteDir, 'alternatives/kosmi.html');
const twosevenTemplatePath = path.join(websiteDir, 'alternatives/twoseven.html');
const overviewTemplatePath = path.join(websiteDir, 'alternatives/index.html');
const hasTelepartyTemplate = fs.existsSync(telepartyTemplatePath);
const hasScreenSharingTemplate = fs.existsSync(screenSharingTemplatePath);
const hasWatch2getherTemplate = fs.existsSync(watch2getherTemplatePath);
const hasScenerTemplate = fs.existsSync(scenerTemplatePath);
const hasKosmiTemplate = fs.existsSync(kosmiTemplatePath);
const hasTwosevenTemplate = fs.existsSync(twosevenTemplatePath);
const hasOverviewTemplate = fs.existsSync(overviewTemplatePath);
const telepartyTemplate = hasTelepartyTemplate ? fs.readFileSync(telepartyTemplatePath, 'utf8') : '';
const screenSharingTemplate = hasScreenSharingTemplate ? fs.readFileSync(screenSharingTemplatePath, 'utf8') : '';
const watch2getherTemplate = hasWatch2getherTemplate ? fs.readFileSync(watch2getherTemplatePath, 'utf8') : '';
const scenerTemplate = hasScenerTemplate ? fs.readFileSync(scenerTemplatePath, 'utf8') : '';
const kosmiTemplate = hasKosmiTemplate ? fs.readFileSync(kosmiTemplatePath, 'utf8') : '';
const twosevenTemplate = hasTwosevenTemplate ? fs.readFileSync(twosevenTemplatePath, 'utf8') : '';
const overviewTemplate = hasOverviewTemplate ? fs.readFileSync(overviewTemplatePath, 'utf8') : '';
// Preload English locale for fallback
const englishLocalePath = path.join(localesDir, 'en.json');
const englishLocale = JSON.parse(fs.readFileSync(englishLocalePath, 'utf8'));
function compileAlternativePage(templateContent, locale, lang, assetPath, langPrefix) {
let compiled = templateContent;
compiled = compiled.replace(/\{\{LANG_CODE\}\}/g, lang);
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
compiled = compiled.replace(/\{\{LANG_PREFIX\}\}/g, langPrefix);
compiled = compiled.replace(/\{\{VERSION\}\}/g, buildVersion);
// og:locale mapping for Facebook
const ogLocales = {
en: 'en_US', de: 'de_DE', fr: 'fr_FR', es: 'es_ES',
'pt-BR': 'pt_BR', ru: 'ru_RU', it: 'it_IT', pl: 'pl_PL',
tr: 'tr_TR', nl: 'nl_NL', ja: 'ja_JP', ko: 'ko_KR',
zh: 'zh_CN', uk: 'uk_UA', pt: 'pt_PT'
};
compiled = compiled.replace(/\{\{OG_LOCALE\}\}/g, ogLocales[lang] || 'en_US');
// Merge locale with English fallback for keys not present in current locale
const mergedLocale = { ...englishLocale, ...locale };
for (const [key, value] of Object.entries(mergedLocale)) {
compiled = compiled.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value);
}
return compiled;
}
for (const lang of languages) {
const localePath = path.join(localesDir, `${lang}.json`);
if (!fs.existsSync(localePath)) { console.warn(`Warning: Locale ${lang} not found.`); continue; }
const locale = JSON.parse(fs.readFileSync(localePath, 'utf8'));
if (lang === 'en') {
console.log('Compiling English (index.html)...');
const html = compilePage(locale, '', lang);
fs.writeFileSync(path.join(wwwDir, 'index.html'), html);
englishHtml[''] = html;
// Compile alternatives for English (assets resolved using '../' prefix)
if (hasTelepartyTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const tpCompiled = compileAlternativePage(telepartyTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'teleparty.html'), tpCompiled);
}
if (hasScreenSharingTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const ssCompiled = compileAlternativePage(screenSharingTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'screen-sharing.html'), ssCompiled);
}
if (hasWatch2getherTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const w2gCompiled = compileAlternativePage(watch2getherTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'watch2gether.html'), w2gCompiled);
}
if (hasScenerTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const scCompiled = compileAlternativePage(scenerTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'scener.html'), scCompiled);
}
if (hasKosmiTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const koCompiled = compileAlternativePage(kosmiTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'kosmi.html'), koCompiled);
}
if (hasTwosevenTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const tsCompiled = compileAlternativePage(twosevenTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'twoseven.html'), tsCompiled);
}
if (hasOverviewTemplate) {
const altDir = path.join(wwwDir, 'alternatives');
fs.mkdirSync(altDir, { recursive: true });
const ovCompiled = compileAlternativePage(overviewTemplate, locale, lang, '../', '');
fs.writeFileSync(path.join(altDir, 'index.html'), ovCompiled);
}
} else {
console.log(`Compiling ${lang.toUpperCase()} (${lang}/index.html)...`);
const langDir = path.join(wwwDir, lang);
fs.mkdirSync(langDir, { recursive: true });
const html = compilePage(locale, '../', lang);
fs.writeFileSync(path.join(langDir, 'index.html'), html);
englishHtml[lang] = html;
// Compile alternatives for other languages (assets resolved using '../../' prefix)
if (hasTelepartyTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const tpCompiled = compileAlternativePage(telepartyTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'teleparty.html'), tpCompiled);
}
if (hasScreenSharingTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const ssCompiled = compileAlternativePage(screenSharingTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'screen-sharing.html'), ssCompiled);
}
if (hasWatch2getherTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const w2gCompiled = compileAlternativePage(watch2getherTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'watch2gether.html'), w2gCompiled);
}
if (hasScenerTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const scCompiled = compileAlternativePage(scenerTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'scener.html'), scCompiled);
}
if (hasKosmiTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const koCompiled = compileAlternativePage(kosmiTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'kosmi.html'), koCompiled);
}
if (hasTwosevenTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const tsCompiled = compileAlternativePage(twosevenTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'twoseven.html'), tsCompiled);
}
if (hasOverviewTemplate) {
const langAltDir = path.join(langDir, 'alternatives');
fs.mkdirSync(langAltDir, { recursive: true });
const ovCompiled = compileAlternativePage(overviewTemplate, locale, lang, '../../', lang + '/');
fs.writeFileSync(path.join(langAltDir, 'index.html'), ovCompiled);
}
}
}
// ── 4. Copy static HTML files ──
console.log('Copying static pages...');
fs.mkdirSync(path.join(wwwDir, 'de'), { recursive: true });
const staticMappings = [
{ src: 'join.html', dest: 'join.html' },
{ src: 'imprint.html', dest: 'imprint.html' },
{ src: 'privacy.html', dest: 'privacy.html' },
{ src: 'site-access-help.html', dest: 'site-access-help.html' },
{ src: 'impressum-de.html', dest: 'de/impressum.html' },
{ src: 'datenschutz-de.html', dest: 'de/datenschutz.html' },
{ src: 'impressum.html', dest: 'impressum.html' },
{ src: 'datenschutz.html', dest: 'datenschutz.html' },
{ src: '404.html', dest: '404.html' }
];
for (const mapping of staticMappings) {
const src = path.join(websiteDir, mapping.src);
const dest = path.join(wwwDir, mapping.dest);
if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
console.log(` Copied: ${mapping.src}${mapping.dest}`);
}
}
// ── 5. Copy generic static files and verification files ──
const genericFiles = ['robots.txt', 'site.webmanifest', 'version.json', 'llms.txt'];
for (const file of genericFiles) {
const src = path.join(websiteDir, file);
const dest = path.join(wwwDir, file);
if (fs.existsSync(src)) { fs.copyFileSync(src, dest); }
}
// ── 5.5 Generate dynamic sitemap ──
generateSitemap(wwwDir);
// Auto-copy Google verification files and IndexNow/txt key files from website source to www root
const websiteFiles = fs.readdirSync(websiteDir);
for (const file of websiteFiles) {
const filePath = path.join(websiteDir, file);
if (fs.statSync(filePath).isFile()) {
// Match google[hex/alphanumeric].html or any hex/uuid txt file (e.g. 4650678bb36b488d8f64343a67f1b931.txt)
const isGoogleVerification = /^google[a-zA-Z0-9_-]+\.html$/i.test(file);
const isIndexNowVerification = (/^[a-zA-Z0-9_-]{8,}\.txt$/i.test(file) || /^[a-f0-9-]{32,36}$/i.test(file)) && file !== 'robots.txt';
if (isGoogleVerification || isIndexNowVerification) {
const dest = path.join(wwwDir, file);
fs.copyFileSync(filePath, dest);
console.log(` Copied verification file: ${file}${file}`);
}
}
}
// ── 6. Copy assets ──
console.log('Copying assets...');
const srcAssets = path.join(websiteDir, 'assets');
const destAssets = path.join(wwwDir, 'assets');
if (fs.existsSync(srcAssets)) {
copyDirSync(srcAssets, destAssets);
// Pages use the hashed subset staged in step 0.7; ship neither the raw
// font nor the unhashed subset artifacts
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.woff2'), { force: true });
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.subset.woff2'), { force: true });
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.subset.json'), { force: true });
console.log(' Assets copied.');
}
// Copy apple-touch-icon to www root (browsers/iOS request these at /)
const appleTouchSrc = path.join(destAssets, 'apple-touch-icon.png');
if (fs.existsSync(appleTouchSrc)) {
fs.copyFileSync(appleTouchSrc, path.join(wwwDir, 'apple-touch-icon.png'));
fs.copyFileSync(appleTouchSrc, path.join(wwwDir, 'apple-touch-icon-precomposed.png'));
console.log(' ✓ Apple touch icons copied to www root.');
}
// ── 6.5 Responsive variants for the static-page mascots ──
// These pages showed 434-500px sources at 180px CSS (2x need: 360px).
// Variants feed the AVIF step below and the srcset attributes in the
// static HTML files.
console.log('Generating responsive mascot variants...');
const RESPONSIVE_VARIANTS = [
{ src: 'KoalaImprintl.webp', widths: [180, 360] },
{ src: 'KoalaPrivacy.webp', widths: [180, 360] },
{ src: 'KoalaSearching.webp', widths: [180, 360] }
];
for (const variant of RESPONSIVE_VARIANTS) {
const variantSrc = path.join(destAssets, variant.src);
if (!fs.existsSync(variantSrc)) {
throw new Error(`Responsive variant source ${variant.src} not found in assets`);
}
for (const width of variant.widths) {
const out = variantSrc.replace(/\.webp$/, `-${width}.webp`);
await sharp(variantSrc).resize({ width }).toFile(out);
}
}
// ── 7. Convert all WebP to AVIF (quality 65) ──
// Content-hash cache: mtime comparison never hit because copyDirSync
// refreshes mtimes, so every verify re-encoded all AVIFs.
console.log('Converting WebP → AVIF...');
const AVIF_OPTS = { quality: 65, speed: 4 };
const avifCachePath = path.join(websiteDir, '.avif-cache.json');
let avifCache = {};
try { avifCache = JSON.parse(fs.readFileSync(avifCachePath, 'utf8')); } catch { /* cold cache */ }
const avifCacheNext = {};
let avifCount = 0, avifCachedCount = 0;
const webpFiles = fs.readdirSync(destAssets).filter(f => f.endsWith('.webp'));
for (const f of webpFiles) {
const src = path.join(destAssets, f);
const dest = path.join(destAssets, f.replace(/\.webp$/, '.avif'));
const key = `${sha8(fs.readFileSync(src))}:q${AVIF_OPTS.quality}s${AVIF_OPTS.speed}`;
avifCacheNext[f] = key;
if (avifCache[f] === key && fs.existsSync(dest)) { avifCachedCount++; continue; }
await sharp(src).avif(AVIF_OPTS).toFile(dest);
avifCount++;
}
fs.writeFileSync(avifCachePath, JSON.stringify(avifCacheNext, null, 2) + '\n');
console.log(` ${avifCount} AVIF files generated, ${avifCachedCount} unchanged (cache).`);
// ── 8. Post-process ALL HTML files ──
console.log('Post-processing HTML...');
async function walkHtml(dir, fn) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) { await walkHtml(p, fn); }
else if (entry.name.endsWith('.html')) { await fn(p); }
}
}
await walkHtml(wwwDir, async (filePath) => {
let html = fs.readFileSync(filePath, 'utf8');
// 8a. Replace hashed asset refs and inject SRI (Subresource Integrity)
// The prefix is either any number of "../" segments or a single "/"
// (404.html must use absolute paths: it is served for arbitrary URLs).
html = html.replace(/(<link\b[^>]*?\bhref=")((?:\.\.\/)*\/?)style\.min\.css"/g, (m, before, prefix) => {
return `${before}${prefix}${styleName}" integrity="${styleSRI}" crossorigin="anonymous"`;
});
for (const bundle of compiledLandingBundles) {
const placeholder = bundle.key === 'landing' ? 'landing.min.css' : `landing-${bundle.key}.min.css`;
html = html.replace(new RegExp(`(<link\\b[^>]*?\\bhref=")((?:\\.\\.\\/)*\\/?)${placeholder.replaceAll('.', '\\.') }"`, 'g'), (m, before, prefix) => {
return `${before}${prefix}${bundle.name}" integrity="${bundle.sri}" crossorigin="anonymous"`;
});
}
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
});
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
});
// 8b. Inject AVIF <picture> wrappers
html = injectAvifPictures(html);
// 8c. Minify inline SVGs
html = minifyInlineSvgs(html);
// 8d. Preload the subset flag font on pages that show flags. This
// cuts the HTML→CSS→font chain; unicode-range alone would delay the
// request until after CSS parse + layout.
if (/[\u{1F1E6}-\u{1F1FF}]{2}/u.test(html)) {
const relDir = path.relative(wwwDir, path.dirname(filePath));
// 404.html is served for arbitrary URLs and must use absolute paths
const prefix = path.basename(filePath) === '404.html'
? '/'
: (relDir ? '../'.repeat(relDir.split(path.sep).length) : '');
const preload = `<link rel="preload" as="font" type="font/woff2" href="${prefix}assets/${flagFontName}" crossorigin>`;
html = html.replace('</head>', `${preload}\n</head>`);
}
// 8e. Minify HTML (comments + indentation)
html = await minifyHTML(html);
fs.writeFileSync(filePath, html);
});
console.log('KoalaSync build finished successfully! Output: website/www/');
}
function generateSitemap(wwwDir) {
const languages = [
{ code: 'en', prefix: '', hreflang: 'en' },
{ code: 'de', prefix: 'de/', hreflang: 'de' },
{ code: 'fr', prefix: 'fr/', hreflang: 'fr' },
{ code: 'es', prefix: 'es/', hreflang: 'es' },
{ code: 'pt-BR', prefix: 'pt-BR/', hreflang: 'pt-br' },
{ code: 'ru', prefix: 'ru/', hreflang: 'ru' },
{ code: 'it', prefix: 'it/', hreflang: 'it' },
{ code: 'pl', prefix: 'pl/', hreflang: 'pl' },
{ code: 'tr', prefix: 'tr/', hreflang: 'tr' },
{ code: 'nl', prefix: 'nl/', hreflang: 'nl' },
{ code: 'ja', prefix: 'ja/', hreflang: 'ja' },
{ code: 'ko', prefix: 'ko/', hreflang: 'ko' },
{ code: 'zh', prefix: 'zh/', hreflang: 'zh' },
{ code: 'uk', prefix: 'uk/', hreflang: 'uk' },
{ code: 'pt', prefix: 'pt/', hreflang: 'pt' }
];
const today = new Date().toISOString().split('T')[0];
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">`;
// Static legal pages
xml += `
<url>
<loc>https://sync.koalastuff.net/imprint</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/privacy</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/site-access-help.html</loc>
<lastmod>${today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/impressum</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>`;
function addPage(relativePath, changefreq, priority) {
for (const lang of languages) {
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
xml += `
<url>
<loc>${loc}</loc>
<lastmod>${today}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>`;
for (const alt of languages) {
const altHref = `https://sync.koalastuff.net/${alt.prefix}${relativePath}`;
xml += `
<xhtml:link rel="alternate" hreflang="${alt.hreflang}" href="${altHref}"/>`;
}
const defaultHref = `https://sync.koalastuff.net/${relativePath}`;
xml += `
<xhtml:link rel="alternate" hreflang="x-default" href="${defaultHref}"/>
</url>`;
}
}
addPage('', 'weekly', '1.0');
addPage('alternatives', 'weekly', '0.7');
const subpages = [
'alternatives/teleparty',
'alternatives/screen-sharing',
'alternatives/watch2gether',
'alternatives/scener',
'alternatives/kosmi',
'alternatives/twoseven'
];
for (const sub of subpages) {
addPage(sub, 'weekly', '0.7');
}
xml += `\n</urlset>\n`;
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
console.log(' ✓ Dynamically generated sitemap.xml with current date');
}
compile().catch(err => { console.error('Build failed:', err); process.exit(1); });