mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
72180ba817
- SEO: per-locale og:locale, Organization+WebSite schema, FAQPage replacing ItemList - SEO: BreadcrumbList on legal pages, site.webmanifest, expanded sitemap - a11y: hamburger ARIA+ESC, skip-to-content, aria-hidden on step-num - a11y: focus-visible states, prefers-reduced-motion, emoji→SVG icons - CSS: extract .mock-section-label, replace transition:all, will-change hints - Build: esbuild JS minifier (-46%), sharp AVIF conversion (26 files, quality 70) - Build: content hashing (style.XXXX.min.css), SVG minification, picture injection - Cleanup: remove lightningcss (caused CSS merging bugs)
242 lines
9.5 KiB
JavaScript
242 lines
9.5 KiB
JavaScript
/**
|
|
* 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 = 3;
|
|
|
|
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(/<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');
|
|
return `<picture><source srcset="${avifSrcset}" 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() {
|
|
console.log('Starting KoalaSync i18n compilation...');
|
|
const websiteDir = __dirname;
|
|
const wwwDir = path.join(websiteDir, 'www');
|
|
fs.mkdirSync(wwwDir, { recursive: true });
|
|
|
|
// ── 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'];
|
|
|
|
const englishHtml = {}; // track for join-page cross-ref
|
|
|
|
function compilePage(locale, assetPath, lang) {
|
|
let compiled = templateContent;
|
|
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
|
|
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 (join, impressum, datenschutz) ──
|
|
console.log('Copying static pages...');
|
|
const staticHtml = ['join.html', 'impressum.html', 'datenschutz.html'];
|
|
for (const file of staticHtml) {
|
|
const src = path.join(websiteDir, file);
|
|
const dest = path.join(wwwDir, file);
|
|
if (fs.existsSync(src)) { fs.copyFileSync(src, dest); console.log(` Copied: ${file}`); }
|
|
}
|
|
|
|
// ── 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.');
|
|
}
|
|
|
|
// ── 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: 70, speed: 6 }).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 <picture> 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); });
|