/** * 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, '') .replace(/\s*([{}:;,])\s*/g, '$1') .replace(/\s+/g, ' ') .replace(/;\}/g, '}') .trim(); } const MIN_AVIF_KB = 0; 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); } async function minifyJS(raw) { const result = await esbuild.transform(raw, { loader: 'js', minify: true, target: 'es2020' }); 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'); return ``; } return ``; }); } function minifyInlineSvgs(html) { const svgRegex = //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() { 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/'); // ── 1. Minify CSS/JS (must happen first so hashes go into HTML) ── console.log('Minifying CSS/JS...'); const styleRaw = fs.readFileSync(path.join(websiteDir, 'style.css'), 'utf8'); const styleMin = minifyCSS(styleRaw); const styleHash = sha8(styleMin); const styleName = `style.${styleHash}.min.css`; 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 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 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}%)`); 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); 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', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt']; // 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); 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; } 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; } 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; } } // ── 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: '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' } ]; for (const mapping of staticMappings) { const src = path.join(websiteDir, mapping.src); const dest = path.join(wwwDir, mapping.dest); if (fs.existsSync(src)) { fs.copyFileSync(src, dest); console.log(` Copied: ${mapping.src} → ${mapping.dest}`); } } // ── 5. Copy generic static files ── const genericFiles = ['robots.txt', 'sitemap.xml', 'site.webmanifest', 'version.json']; 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); } } // ── 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); 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.'); } // ── 7. Convert all WebP to AVIF (quality 70) ── console.log('Converting WebP → AVIF...'); let avifCount = 0; const webpFiles = fs.readdirSync(destAssets).filter(f => f.endsWith('.webp')); for (const f of webpFiles) { const src = path.join(destAssets, f); const stat = fs.statSync(src); if (stat.size < MIN_AVIF_KB * 1024) continue; const dest = path.join(destAssets, f.replace(/\.webp$/, '.avif')); if (fs.existsSync(dest) && fs.statSync(dest).mtimeMs >= stat.mtimeMs) continue; await sharp(src).avif({ quality: 80, speed: 4 }).toFile(dest); avifCount++; } console.log(` ${avifCount} AVIF files generated.`); // ── 8. Post-process ALL HTML files ── console.log('Post-processing HTML...'); function walkHtml(dir, fn) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) { walkHtml(p, fn); } else if (entry.name.endsWith('.html')) { fn(p); } } } walkHtml(wwwDir, (filePath) => { let html = fs.readFileSync(filePath, 'utf8'); // 8a. Replace hashed asset refs html = html.replace(/href="(?:\.\.\/)?style\.min\.css"/g, (m) => { const prefix = m.includes('../') ? '../' : ''; return `href="${prefix}${styleName}"`; }); html = html.replace(/src="(?:\.\.\/)?app\.min\.js"/g, (m) => { const prefix = m.includes('../') ? '../' : ''; return `src="${prefix}${appName}"`; }); html = html.replace(/src="(?:\.\.\/)?lang-init\.min\.js"/g, (m) => { const prefix = m.includes('../') ? '../' : ''; return `src="${prefix}${langName}"`; }); // Also update preload directives html = html.replace(/href="(?:\.\.\/)?style\.min\.css"/g, (m) => { const prefix = m.includes('../') ? '../' : ''; return `href="${prefix}${styleName}"`; }); // 8b. Inject AVIF wrappers html = injectAvifPictures(html); // 8c. Minify inline SVGs html = minifyInlineSvgs(html); fs.writeFileSync(filePath, html); }); console.log('KoalaSync build finished successfully! Output: website/www/'); } compile().catch(err => { console.error('Build failed:', err); process.exit(1); });