mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
866 lines
41 KiB
JavaScript
866 lines
41 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 { execFileSync } = require('child_process');
|
||
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 inviteLinksRaw = fs.readFileSync(path.join(websiteDir, '..', 'shared', 'invite-links.js'), 'utf8');
|
||
const inviteLinksMin = await minifyJS(inviteLinksRaw);
|
||
const inviteLinksHash = sha8(inviteLinksMin);
|
||
const inviteLinksName = `invite-links.${inviteLinksHash}.min.js`;
|
||
const inviteLinksSRI = sha384(inviteLinksMin);
|
||
|
||
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(` Invite links: ${inviteLinksName} (${(inviteLinksMin.length/1024).toFixed(1)} KB)`);
|
||
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, inviteLinksName), inviteLinksMin);
|
||
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(websiteDir, 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=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => {
|
||
return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" 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(websiteDir, 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 repoRoot = path.resolve(websiteDir, '..');
|
||
const lastModifiedCache = new Map();
|
||
const dirtySourceFiles = new Set();
|
||
let gitHistoryAvailable = false;
|
||
let usedDirtySourceDate = false;
|
||
|
||
try {
|
||
const isInsideWorkTree = execFileSync(
|
||
'git', ['rev-parse', '--is-inside-work-tree'],
|
||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||
).trim() === 'true';
|
||
const isShallow = execFileSync(
|
||
'git', ['rev-parse', '--is-shallow-repository'],
|
||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||
).trim() === 'true';
|
||
gitHistoryAvailable = isInsideWorkTree && !isShallow;
|
||
|
||
if (gitHistoryAvailable) {
|
||
const dirtyCommands = [
|
||
['diff', '--name-only', '--'],
|
||
['diff', '--cached', '--name-only', '--'],
|
||
['ls-files', '--others', '--exclude-standard', '--']
|
||
];
|
||
for (const args of dirtyCommands) {
|
||
const output = execFileSync(
|
||
'git', args,
|
||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||
);
|
||
for (const file of output.split(/\r?\n/).filter(Boolean)) {
|
||
dirtySourceFiles.add(file.replaceAll('\\', '/'));
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {
|
||
gitHistoryAvailable = false;
|
||
}
|
||
|
||
function getLastModified(sourceFiles) {
|
||
const cacheKey = sourceFiles.slice().sort().join('\0');
|
||
if (lastModifiedCache.has(cacheKey)) return lastModifiedCache.get(cacheKey);
|
||
if (!gitHistoryAvailable) {
|
||
lastModifiedCache.set(cacheKey, null);
|
||
return null;
|
||
}
|
||
let date = null;
|
||
try {
|
||
if (sourceFiles.some(file => dirtySourceFiles.has(file))) {
|
||
usedDirtySourceDate = true;
|
||
date = new Date().toISOString().slice(0, 10);
|
||
} else {
|
||
const output = execFileSync(
|
||
'git',
|
||
['log', '-1', '--format=%cs', '--', ...sourceFiles],
|
||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||
).trim();
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(output)) date = output;
|
||
}
|
||
} catch (_) {
|
||
// Source archives and minimal deployment environments may not contain
|
||
// Git metadata. Omitting lastmod is more accurate than inventing a date.
|
||
}
|
||
lastModifiedCache.set(cacheKey, date);
|
||
return date;
|
||
}
|
||
|
||
function lastmod(sourceFiles, indent = ' ') {
|
||
const date = getLastModified(sourceFiles);
|
||
return date ? `\n${indent}<lastmod>${date}</lastmod>` : '';
|
||
}
|
||
|
||
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(['website/imprint.html'])}
|
||
<changefreq>monthly</changefreq>
|
||
<priority>0.3</priority>
|
||
</url>
|
||
<url>
|
||
<loc>https://sync.koalastuff.net/privacy</loc>${lastmod(['website/privacy.html'])}
|
||
<changefreq>monthly</changefreq>
|
||
<priority>0.3</priority>
|
||
</url>
|
||
<url>
|
||
<loc>https://sync.koalastuff.net/site-access-help.html</loc>${lastmod(['website/site-access-help.html'])}
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.8</priority>
|
||
</url>
|
||
<url>
|
||
<loc>https://sync.koalastuff.net/de/impressum</loc>${lastmod(['website/impressum-de.html'])}
|
||
<changefreq>monthly</changefreq>
|
||
<priority>0.3</priority>
|
||
</url>
|
||
<url>
|
||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>${lastmod(['website/datenschutz-de.html'])}
|
||
<changefreq>monthly</changefreq>
|
||
<priority>0.3</priority>
|
||
</url>`;
|
||
|
||
function addPage(relativePath, changefreq, priority, templateSource) {
|
||
for (const lang of languages) {
|
||
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
|
||
const sourceFiles = [templateSource, `website/locales/${lang.code}.json`];
|
||
xml += `
|
||
<url>
|
||
<loc>${loc}</loc>${lastmod(sourceFiles)}
|
||
<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', 'website/template.html');
|
||
addPage('alternatives', 'weekly', '0.7', 'website/alternatives/index.html');
|
||
|
||
const subpages = [
|
||
['alternatives/teleparty', 'website/alternatives/teleparty.html'],
|
||
['alternatives/screen-sharing', 'website/alternatives/screen-sharing.html'],
|
||
['alternatives/watch2gether', 'website/alternatives/watch2gether.html'],
|
||
['alternatives/scener', 'website/alternatives/scener.html'],
|
||
['alternatives/kosmi', 'website/alternatives/kosmi.html'],
|
||
['alternatives/twoseven', 'website/alternatives/twoseven.html']
|
||
];
|
||
for (const [sub, templateSource] of subpages) {
|
||
addPage(sub, 'weekly', '0.7', templateSource);
|
||
}
|
||
|
||
xml += `\n</urlset>\n`;
|
||
|
||
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
|
||
const datedEntries = (xml.match(/<lastmod>/g) || []).length;
|
||
const dateSource = usedDirtySourceDate ? 'Git/working-tree-derived' : 'Git-derived';
|
||
console.log(` ✓ Dynamically generated sitemap.xml with ${datedEntries} ${dateSource} modification dates`);
|
||
}
|
||
compile().catch(err => { console.error('Build failed:', err); process.exit(1); });
|