import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { runInNewContext } from 'node:vm'; const [out, siteArg] = process.argv.slice(2); if (!out || !siteArg) { console.error('usage: render_site.mjs '); process.exit(64); } const siteUrl = new URL(siteArg.replace(/\/*$/, '/')).href; const PAGES = [ // The head script in index.html keeps the English root from redirecting to zh/ when it sees ?lang=en. { lang: 'en', locale: 'en', ogLocale: 'en_US', path: '', toggle: '?lang=en' }, { lang: 'zh', locale: 'zh-CN', ogLocale: 'zh_CN', path: 'zh/', toggle: 'zh/' }, ]; const sandbox = {}; runInNewContext(readFileSync(join(out, 'assets/shared.js'), 'utf8'), sandbox); const { REPO, FIRST_PAGE, STRINGS, parseChangelog, changelogMeta, escapeHtml: escape, timelineHtml } = sandbox.FlClashSite; const template = readFileSync(join(out, 'index.html'), 'utf8'); const versions = parseChangelog(readFileSync(join(out, 'CHANGELOG.md'), 'utf8')); const releasePath = join(out, 'release.json'); const release = existsSync(releasePath) ? JSON.parse(readFileSync(releasePath, 'utf8')) : null; const latest = release?.tag ? { version: release.tag.replace(/^v/, ''), date: (release.publishedAt || '').slice(0, 10) } : versions.find((v) => !v.tag.includes('-')); function replaceOnce(html, pattern, replacement) { if (!pattern.test(html)) throw new Error(`index.html no longer matches ${pattern}`); return html.replace(pattern, replacement); } function structuredData(t, page) { return { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'FlClash', url: siteUrl + page.path, description: t.pageDescription, inLanguage: page.locale, applicationCategory: 'UtilitiesApplication', operatingSystem: 'Android, Windows, macOS, Linux', image: `${siteUrl}preview.png`, downloadUrl: `${REPO}/releases/latest`, ...(latest ? { softwareVersion: latest.version, dateModified: latest.date || undefined } : {}), license: 'https://www.gnu.org/licenses/gpl-3.0.html', isAccessibleForFree: true, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, sameAs: [REPO], }; } function render(page) { const t = STRINGS[page.lang]; const text = (key) => escape(t[key]); const up = '../'.repeat(page.path.split('/').length - 1); const other = PAGES.find((p) => p !== page); let html = template; html = replaceOnce(html, //, ``); html = replaceOnce(html, /[^<]*<\/title>/, `<title>${text('pageTitle')}`); html = replaceOnce(html, /( ` `, ).join('\n'); const head = [ ` `, alternates, ` `, ` `, ` `, ` `, ].join('\n'); html = replaceOnce(html, /(]*>\n)/, `$1${head}\n`); html = html.replace( /(<(\w+)\b[^>]*\bdata-i18n="(\w+)"[^>]*>)[^<]*(<\/\2>)/g, (_, open, _tag, key, close) => `${open}${text(key)}${close}`, ); html = html.replace(/(\balt=")[^"]*("[^>]*data-i18n-alt="(\w+)")/g, (_, a, b, key) => `${a}${text(key)}${b}`); html = html.replace( /(\bplaceholder=")[^"]*("[^>]*data-i18n-placeholder="(\w+)")/g, (_, a, b, key) => `${a}${text(key)}${b}`, ); // With the count in the page, the GitHub link has its final width from the first paint instead of growing when // release.json arrives. if (typeof release?.stars === 'number') { const stars = new Intl.NumberFormat(page.locale, { notation: 'compact', maximumFractionDigits: 1 }); html = replaceOnce( html, /