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:
Executable
+47
@@ -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"
|
||||
@@ -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',
|
||||
);
|
||||
Reference in New Issue
Block a user