docs(site): add a GitHub Pages site with a download guide and changelog

The page picks the build for the visitor's platform and architecture,
lists each release file with its size and SHA-256 digest, and renders
CHANGELOG.md with search. tool/build_site.sh assembles it from the
repository and the latest release, and the pages workflow deploys it
after every stable release build and on site changes to main.
This commit is contained in:
chen08209
2026-10-01 12:29:02 +08:00
parent 91e524c354
commit 6041cd9289
9 changed files with 4358 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
repo="${SITE_REPOSITORY:-chen08209/FlClash}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
out="${1:-$root/build/site}"
if ! command -v node >/dev/null; then
echo "error: tool/render_site.mjs needs Node.js on PATH" >&2
exit 1
fi
rm -rf "$out"
mkdir -p "$out"
cp -R "$root/site/." "$out/"
cp "$root/snapshots/preview.png" "$root/snapshots/preview-dark.png" "$out/"
cp "$root/assets/images/icon.png" "$out/icon.png"
cp "$root/assets_source/images/icon/glyph.svg" "$out/favicon.svg"
if stars="$(gh api "repos/$repo" --jq '.stargazers_count' 2>/dev/null)" &&
release="$(gh api "repos/$repo/releases/latest" --jq ".tag_name, ({
tag: .tag_name,
publishedAt: .published_at,
assets: [.assets[] | {name, size, digest}],
stars: $stars
} | tojson)" 2>/dev/null)"; then
{ read -r tag && read -r json; } <<<"$release"
printf '%s\n' "$json" >"$out/release.json"
else
echo "warning: no release metadata from $repo; the page falls back to CHANGELOG.md" >&2
tag=""
fi
# The changelog comes from the released tag so the notes never run ahead of the
# downloads; the working tree copy is only a fallback for local previews.
if [[ -n "$tag" ]] && ! git -C "$root" rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
git -C "$root" fetch -q --depth=1 origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null || true
fi
if [[ -n "$tag" ]] && git -C "$root" show "$tag:CHANGELOG.md" >"$out/CHANGELOG.md" 2>/dev/null; then
:
else
cp "$root/CHANGELOG.md" "$out/CHANGELOG.md"
fi
node "$root/tool/render_site.mjs" "$out" "${SITE_URL:-https://chen08209.github.io/FlClash/}"
echo "site assembled in $out"
+130
View File
@@ -0,0 +1,130 @@
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 <site dir> <site url>');
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 lang="[^"]*">/, `<html lang="${page.locale}" data-page-lang="${page.lang}">`);
html = replaceOnce(html, /<title>[^<]*<\/title>/, `<title>${text('pageTitle')}</title>`);
html = replaceOnce(html, /(<meta name="description" content=")[^"]*/, `$1${text('pageDescription')}`);
html = replaceOnce(html, /(<meta property="og:description" content=")[^"]*/, `$1${text('heroLede')}`);
html = replaceOnce(html, /(<meta property="og:image" content=")[^"]*/, `$1${siteUrl}preview.png`);
const alternates = PAGES.map(
(p) => ` <link rel="alternate" hreflang="${p.locale}" href="${siteUrl}${p.path}">`,
).join('\n');
const head = [
` <link rel="canonical" href="${siteUrl}${page.path}">`,
alternates,
` <link rel="alternate" hreflang="x-default" href="${siteUrl}">`,
` <meta property="og:url" content="${siteUrl}${page.path}">`,
` <meta property="og:locale" content="${page.ogLocale}">`,
` <script type="application/ld+json">${JSON.stringify(structuredData(t, page)).replace(/</g, '\\u003c')}</script>`,
].join('\n');
html = replaceOnce(html, /(<meta name="twitter:card"[^>]*>\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}`,
);
if (versions.length) {
const meta = escape(changelogMeta(versions, page.lang));
html = replaceOnce(html, /(<p class="section-sub" id="cl-meta">)(<\/p>)/, (_, open, close) => open + meta + close);
const releases = timelineHtml(
versions.slice(0, FIRST_PAGE).map((version) => ({ version, groups: version.groups })),
{ lang: page.lang, latestTag: versions[0].tag },
).html;
html = replaceOnce(
html,
/(<div class="timeline" id="timeline">)(<\/div>)/,
(_, open, close) => `${open}\n${releases}\n${close}`,
);
}
if (up) html = html.replace(/\b(href|src|srcset)="(?![a-z]+:|#|\/)/g, `$1="${up}`);
return replaceOnce(
html,
/<button class="tool" id="lang-toggle" type="button">[^<]*<\/button>/,
`<a class="tool" id="lang-toggle" href="${up}${other.toggle}" hreflang="${other.locale}" lang="${other.locale}" ` +
`aria-label="${text('langLabel')}">${text('langToggle')}</a>`,
);
}
for (const page of PAGES) {
const dir = join(out, page.path);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'index.html'), render(page));
}
const lastmod = latest?.date ? `<lastmod>${latest.date}</lastmod>` : '';
const links = PAGES.map(
(p) => `<xhtml:link rel="alternate" hreflang="${p.locale}" href="${siteUrl}${p.path}"/>`,
).join('');
writeFileSync(
join(out, 'sitemap.xml'),
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n' +
PAGES.map((p) => ` <url><loc>${siteUrl}${p.path}</loc>${lastmod}${links}</url>\n`).join('') +
'</urlset>\n',
);