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
+28
View File
@@ -276,6 +276,34 @@ Telegram post. They publish no GitHub release, so the update dialog never sees t
version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle:
while `v<pubspec version>` is still tagged it refuses to collect anything and the release job fails.
### Website
`site/` is the GitHub Pages site: a static page that renders `CHANGELOG.md` and the download links in the browser, and
works as is for local previews. `tool/build_site.sh [dir]` assembles it with the preview images, the app icon,
`CHANGELOG.md` read at the latest release tag, and a `release.json` of asset names, sizes and digests fetched through
`gh`. Without `gh` access the page falls back to the newest version in `CHANGELOG.md` and lists the files without sizes.
It then runs `tool/render_site.mjs` (Node), which writes the English page and a `zh/` copy for search engines: page
text, the first changelog releases, canonical and `hreflang` links, JSON-LD and `sitemap.xml`, all from the strings,
changelog parser and release markup in `site/assets/shared.js`. `SITE_URL` overrides the published URL these point at.
On the built site each URL keeps its language: the language button links to the other page and carries the section in
view, the chosen platform and the changelog search. Only the English root sends readers who chose or prefer Chinese on
to `zh/`, unless its URL carries `?lang=en`: the button on `zh/` links there with it, so getting back to English never
depends on browser storage. A project site cannot serve the `robots.txt` that would announce the sitemap, so submit
`sitemap.xml` in Google Search Console and Bing Webmaster Tools once.
```bash
tool/build_site.sh && python3 -m http.server -d build/site
```
The page's look is deliberate: low-saturation flat colour, no blur, glow, glass or soft shadow, and motion that follows
the logo's 30° lattice. `site/assets/field.js` draws the hero's capsules on that lattice with the glyph's own
proportions, and the slanted wipes, the dot-to-bar marks and the theme transition reuse the same angle. Text that sits
inside the hero has to be listed in `wireField` in `app.js`, or the capsules run behind it.
`.github/workflows/pages.yaml` deploys it after every successful stable tag build and on pushes to `main` that touch the
site, in the public repository only; its Pages source has to be set to GitHub Actions once. The file names come from the
release matrix in `build.yaml`, so a renamed artifact needs the same change in `buildsFor` in `site/assets/app.js`.
## Verify
In the public repository every branch push runs the `dart` job; in the private
+81
View File
@@ -0,0 +1,81 @@
name: pages
on:
push:
branches:
- main
paths:
- 'site/**'
- 'tool/build_site.sh'
- 'tool/render_site.mjs'
- '.github/workflows/pages.yaml'
workflow_run:
workflows:
- build
types:
- completed
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
tag:
# head_branch also names a branch push's branch, so only a tag at the built commit counts as a release.
if: >-
github.repository == 'chen08209/FlClash' &&
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v') &&
!contains(github.event.workflow_run.head_branch, '-')
runs-on: ubuntu-latest
outputs:
stable: ${{ steps.lookup.outputs.stable }}
steps:
- name: Look up the tag
id: lookup
env:
NAME: ${{ github.event.workflow_run.head_branch }}
SHA: ${{ github.event.workflow_run.head_sha }}
REMOTE: ${{ github.server_url }}/${{ github.repository }}
run: |
if git ls-remote --tags "$REMOTE" "refs/tags/$NAME" "refs/tags/$NAME^{}" | grep -q "^$SHA"; then
echo 'stable=true' >> "$GITHUB_OUTPUT"
fi
deploy:
needs: tag
if: >-
github.repository == 'chen08209/FlClash' &&
!cancelled() &&
(github.event_name != 'workflow_run' || needs.tag.outputs.stable == 'true')
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assemble site
env:
GH_TOKEN: ${{ github.token }}
run: bash tool/build_site.sh _site
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload
uses: actions/upload-pages-artifact@v3
with:
path: _site
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+1027
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
globalThis.FlClashField = (() => {
'use strict';
const COS = Math.cos(Math.PI / 6);
const SIN = 0.5;
// Bar thickness and bar step of the logo glyph, in lane pitches. Odd lanes sit half a step along, which is what
// stacks a two-step bar, a one-step bar and a dot into the logo.
const THICKNESS = 0.75;
const STEP = 1.125;
const PALETTE = [0, 0, 0, 1, 1, 2, 3, 4, 5];
const HOPS = [1, 1, 2, 2, 2, 3];
const LENGTHS = [0, 0, 1, 1, 2];
const PIXEL_BUDGET = 12e6;
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
const pick = (list) => list[Math.floor(Math.random() * list.length)];
const spring = (t) => (t >= 1 ? 1 : 1 - Math.exp(-7.5 * t) * Math.cos(8.5 * t));
// `avoid` and `stage` report boxes in the canvas's own coordinates.
function mount(canvas, { avoid = () => [], stage = () => null, still = () => false } = {}) {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
let width = 0;
let height = 0;
let pitch = 0;
let step = 0;
let gap = 0;
let lanes = [];
let capsules = [];
let colors = [];
let logoColors = [];
let frame = 0;
let last = 0;
let tempo = 1;
let dirty = true;
let onScreen = true;
let pointer = null;
let scrollAt = { y: scrollY, time: performance.now() };
function point(lane, cell) {
const along = (cell + (lane & 1 ? 0.5 : 0)) * step;
return [along * COS, lane * gap - along * SIN];
}
function measure() {
const box = canvas.getBoundingClientRect();
width = box.width;
height = box.height;
const ratio = Math.min(devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / Math.max(width * height, 1)));
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
pitch = clamp(width * 0.044, 30, 62);
step = pitch * STEP;
gap = pitch / COS;
}
function chart() {
const edge = pitch * THICKNESS * 0.5 + 2;
const margin = edge + 10;
const blocks = avoid().map((rect) => ({
left: rect.left - margin,
right: rect.right + margin,
top: rect.top - margin,
bottom: rect.bottom + margin,
}));
const cells = Math.ceil(width / (step * COS)) + 1;
const count = Math.ceil((height + (width * SIN) / COS) / gap) + 1;
lanes = [];
for (let lane = 0; lane < count; lane++) {
const open = [];
const runs = [];
let from = -1;
for (let cell = 0; cell <= cells; cell++) {
const [x, y] = point(lane, cell);
const free =
cell < cells &&
x >= edge &&
x <= width - edge &&
y >= edge &&
y <= height - edge &&
!blocks.some((b) => x > b.left && x < b.right && y > b.top && y < b.bottom);
open.push(free);
if (free) {
if (from < 0) from = cell;
} else if (from >= 0) {
if (cell - from >= 2) runs.push([from, cell - 1]);
from = -1;
}
}
lanes.push({ open, runs });
}
}
function tint() {
const style = getComputedStyle(canvas);
const read = (name) => style.getPropertyValue(name).trim() || '#999';
colors = [1, 2, 3, 4, 5, 6].map((index) => read(`--field-${index}`));
logoColors = [1, 2, 3].map((index) => read(`--g${index}`));
}
function begin(capsule, phase, span) {
capsule.phase = phase;
capsule.span = span;
capsule.t = 0;
}
function rest(capsule, longest = 3.6) {
begin(capsule, 'rest', 0.4 + Math.random() * longest);
capsule.eager = false;
}
function claimed(lane, cell, self) {
return capsules.some(
(other) =>
other !== self &&
other.lane === lane &&
cell >= Math.floor(other.a) - 1 &&
cell <= Math.ceil(other.phase === 'stretch' ? other.to : other.b) + 1,
);
}
function settle(capsule, lane, cell) {
capsule.lane = lane;
capsule.a = capsule.b = cell;
}
function relocate(capsule) {
const ahead = lanes[capsule.lane]?.runs.find(([from]) => from > capsule.b && !claimed(capsule.lane, from, capsule));
if (ahead) {
settle(capsule, capsule.lane, ahead[0]);
return true;
}
for (let attempt = 0; attempt < 12; attempt++) {
const lane = Math.floor(Math.random() * lanes.length);
const { runs } = lanes[lane];
if (!runs.length) continue;
const [from] = Math.random() < 0.6 ? runs[0] : pick(runs);
if (claimed(lane, from, capsule)) continue;
settle(capsule, lane, from);
capsule.keep = pick(LENGTHS);
capsule.color = pick(PALETTE);
capsule.logo = 0;
return true;
}
return false;
}
function act(capsule) {
const { open } = lanes[capsule.lane];
let reach = 0;
while (reach < 3 && open[capsule.b + reach + 1] && !claimed(capsule.lane, capsule.b + reach + 1, capsule)) reach++;
if (!reach) {
begin(capsule, 'sink', 0.24);
return;
}
capsule.from = capsule.b;
capsule.to = capsule.b + Math.min(reach, pick(HOPS));
begin(capsule, 'stretch', capsule.eager ? 0.42 : 0.6 + Math.random() * 0.25);
}
function advance(capsule, dt) {
capsule.t += dt;
const done = capsule.t >= capsule.span;
const progress = capsule.span ? capsule.t / capsule.span : 1;
switch (capsule.phase) {
case 'rest':
if (done) act(capsule);
break;
case 'stretch':
capsule.b = capsule.from + (capsule.to - capsule.from) * spring(progress);
if (done) {
capsule.b = capsule.to;
begin(capsule, 'hold', 0.05 + Math.random() * 0.15);
}
break;
case 'hold':
if (!done) break;
if (capsule.b - capsule.keep > capsule.a) {
capsule.from = capsule.a;
capsule.to = capsule.b - capsule.keep;
begin(capsule, 'contract', capsule.eager ? 0.42 : 0.6 + Math.random() * 0.25);
} else {
rest(capsule);
}
break;
case 'contract':
capsule.a = capsule.from + (capsule.to - capsule.from) * spring(progress);
if (done) {
capsule.a = capsule.to;
rest(capsule);
}
break;
case 'sink':
capsule.w = Math.min(capsule.w, Math.max(0, 1 - progress * progress));
if (done) {
capsule.w = 0;
begin(capsule, relocate(capsule) ? 'cue' : 'limbo', 0.2 + Math.random() * 0.5);
}
break;
case 'limbo':
if (done) begin(capsule, relocate(capsule) ? 'cue' : 'limbo', 0.6);
break;
case 'cue':
if (done) begin(capsule, 'rise', 0.6);
break;
case 'rise':
capsule.w = spring(progress);
if (done) {
capsule.w = 1;
rest(capsule, capsule.logo ? 1.6 : 1.2);
if (capsule.logo) capsule.span += 3.4;
}
break;
}
}
function assemble() {
const target = stage();
if (!target) return [];
const cx = (target.left + target.right) / 2;
const cy = (target.top + target.bottom) / 2;
let best = null;
for (let lane = 0; lane + 2 < lanes.length; lane++) {
const shift = lane & 1;
const [top, middle, bottom] = [lanes[lane].open, lanes[lane + 1].open, lanes[lane + 2].open];
for (let cell = 0; cell + 2 < top.length; cell++) {
const fits =
top[cell] && top[cell + 1] && top[cell + 2] && middle[cell + shift] && middle[cell + shift + 1] && bottom[cell + 1];
if (!fits) continue;
const [x, y] = point(lane + 1, cell + shift + 0.5);
const distance = (x - cx) ** 2 + (y - cy) ** 2;
if (!best || distance < best.distance) best = { lane, cell, shift, distance };
}
}
if (!best) return [];
const { lane, cell, shift } = best;
return [
{ lane, a: cell, b: cell + 2, keep: 2, logo: 1 },
{ lane: lane + 1, a: cell + shift, b: cell + shift + 1, keep: 1, logo: 2 },
{ lane: lane + 2, a: cell + 1, b: cell + 1, keep: 0, logo: 3 },
];
}
function seed(intro) {
capsules = [];
const create = (shape, delay) => {
const capsule = { color: pick(PALETTE), logo: 0, w: 1, eager: false, from: 0, to: 0, ...shape };
capsules.push(capsule);
if (intro) {
capsule.w = 0;
begin(capsule, 'cue', delay);
} else {
rest(capsule);
}
};
assemble().forEach((shape) => create(shape, 0.95 - shape.logo * 0.13));
const busy = lanes.map((lane, index) => (lane.runs.length ? index : -1)).filter((index) => index >= 0);
const total = clamp(Math.round(busy.length * 0.62), 0, width < 700 ? 16 : 30);
for (let attempt = 0; capsules.length < total && attempt < total * 6; attempt++) {
const lane = pick(busy);
const [from, to] = pick(lanes[lane].runs);
const head = from + Math.floor(Math.random() * (to - from + 1));
const keep = pick(LENGTHS);
const tail = Math.max(from, head - keep);
if (claimed(lane, head, null) || claimed(lane, tail, null)) continue;
create({ lane, a: tail, b: head, keep }, 0.25 + (point(lane, head)[0] / width) * 0.8 + Math.random() * 0.3);
}
}
function draw() {
ctx.clearRect(0, 0, width, height);
ctx.lineCap = 'round';
for (const capsule of capsules) {
if (capsule.w < 0.02) continue;
const [x1, y1] = point(capsule.lane, capsule.a);
const [x2, y2] = point(capsule.lane, capsule.b);
ctx.strokeStyle = capsule.logo ? logoColors[capsule.logo - 1] : colors[capsule.color];
ctx.lineWidth = pitch * THICKNESS * capsule.w;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2 + 0.01, y2);
ctx.stroke();
}
}
function stir() {
const box = canvas.getBoundingClientRect();
const x = pointer.x - box.left;
const y = pointer.y - box.top;
pointer = null;
for (const capsule of capsules) {
if (capsule.phase !== 'rest') continue;
const [x1, y1] = point(capsule.lane, capsule.a);
const [x2, y2] = point(capsule.lane, capsule.b);
const reach = pitch * 1.3 + Math.hypot(x2 - x1, y2 - y1) / 2;
if (Math.hypot((x1 + x2) / 2 - x, (y1 + y2) / 2 - y) > reach) continue;
capsule.t = capsule.span;
capsule.eager = true;
}
}
function tick(now) {
frame = requestAnimationFrame(tick);
const dt = Math.min((now - last) / 1000, 0.05) * tempo;
last = now;
tempo += (1 - tempo) * Math.min(1, dt * 2.5);
if (pointer) stir();
let moving = false;
for (const capsule of capsules) {
advance(capsule, dt);
if (capsule.phase !== 'rest' && capsule.phase !== 'cue' && capsule.phase !== 'limbo') moving = true;
}
if (moving || dirty) draw();
dirty = moving;
}
function sync() {
const run = onScreen && !document.hidden && !still();
if (run && !frame) {
last = performance.now();
frame = requestAnimationFrame(tick);
} else if (!run && frame) {
cancelAnimationFrame(frame);
frame = 0;
}
}
function rebuild(intro) {
measure();
chart();
tint();
seed(intro && !still());
draw();
}
function refresh() {
chart();
tint();
if (still()) {
seed(false);
} else {
for (const capsule of capsules) {
if (capsule.phase === 'sink' || capsule.phase === 'limbo') continue;
const { open } = lanes[capsule.lane] || { open: [] };
const from = Math.floor(capsule.a);
const to = Math.ceil(capsule.phase === 'stretch' ? capsule.to : capsule.b);
let clear = true;
for (let cell = from; cell <= to; cell++) clear = clear && open[cell];
if (clear) continue;
if (capsule.phase !== 'cue') begin(capsule, 'sink', 0.2);
else if (!relocate(capsule)) begin(capsule, 'limbo', 0.6);
}
}
draw();
dirty = true;
sync();
}
function surge() {
for (const capsule of capsules) {
if (capsule.phase !== 'rest') continue;
capsule.span = capsule.t + (point(capsule.lane, capsule.b)[0] / width) * 0.45;
capsule.eager = true;
}
}
rebuild(true);
if ('ResizeObserver' in window) {
new ResizeObserver(() => {
const box = canvas.getBoundingClientRect();
if (Math.abs(box.width - width) > 1) {
rebuild(false);
} else if (Math.abs(box.height - height) > 1) {
measure();
refresh();
}
}).observe(canvas);
}
if ('IntersectionObserver' in window) {
new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
sync();
}).observe(canvas);
}
document.addEventListener('visibilitychange', sync);
addEventListener(
'pointermove',
(event) => {
if (event.pointerType === 'mouse' && frame) pointer = { x: event.clientX, y: event.clientY };
},
{ passive: true },
);
addEventListener(
'scroll',
() => {
const now = performance.now();
const speed = (Math.abs(scrollY - scrollAt.y) / Math.max(now - scrollAt.time, 8)) * 1000;
scrollAt = { y: scrollY, time: now };
tempo = Math.max(tempo, 1 + Math.min(speed / 700, 2.4));
},
{ passive: true },
);
sync();
return { refresh, surge };
}
return { mount };
})();
+342
View File
@@ -0,0 +1,342 @@
globalThis.FlClashSite = (() => {
'use strict';
const REPO = 'https://github.com/chen08209/FlClash';
const FIRST_PAGE = 6;
const MAX_STAGGER = 16;
const TYPE_BY_TITLE = {
'Breaking Changes': 'breaking',
Features: 'feat',
'Bug Fixes': 'fix',
Performance: 'perf',
Reverts: 'revert',
};
const TYPE_KEYS = { breaking: 'typeBreaking', feat: 'typeFeat', fix: 'typeFix', perf: 'typePerf', revert: 'typeRevert' };
const ICON_EXTERNAL =
'<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path d="M6 3.5h6.5V10M12.25 3.75 4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const STRINGS = {
en: {
pageTitle: 'FlClash – Clash Meta (mihomo) proxy client for Android, Windows, macOS and Linux',
pageDescription:
'FlClash is a free, open-source Clash Meta (mihomo) proxy client for Android, Windows, macOS and Linux, with rule routing, TUN mode and subscription import. No ads.',
skip: 'Skip to downloads',
navDownload: 'Download',
navChangelog: 'Changelog',
langToggle: '中文',
langLabel: '切换到中文',
themeLabel: 'Toggle dark mode',
heroFor: 'for',
reelLabel: 'Switch platform',
heroLede: 'A multi-platform proxy client based on Clash Meta (mihomo). Simple to use, open source and ad-free.',
heroOther: 'Other platforms',
heroReleaseLoading: 'Latest release',
heroRelease: (v) => `${v} is out · See what’s new`,
ctaDownload: (p) => `Download for ${p}`,
ctaChoose: 'Choose a download',
ctaIos: 'FlClash does not run on iOS. Pick a build for another device below.',
previewAlt: 'FlClash dashboard on a MacBook and a phone',
fact1Title: 'mihomo core',
fact1: 'Rule routing, proxy groups and TUN mode.',
fact2Title: 'Subscriptions',
fact2: 'Import a profile from a subscription link.',
fact3Title: 'WebDAV sync',
fact3: 'Back up and restore your data across devices.',
fact4Title: 'Open source',
fact4: 'Licensed under GPL-3.0, with no ads.',
dlTitle: 'Download',
dlMeta: (v, d) => `Version ${v} · Released ${d}`,
dlLead: 'The build for this device is already selected. Each file says which devices it is for.',
dlAll: 'All releases on GitHub',
dlError: 'Release details could not be loaded. Download from GitHub Releases instead.',
platformLabel: 'Platform',
archLabel: 'Architecture',
recommended: 'Recommended',
copied: 'Copied',
copy: 'Copy',
copySha: (name) => `Copy the SHA-256 of ${name}`,
hintAndroid: 'Not sure which one? Pick arm64-v8a.',
hintWindows: 'ARM64 is for Snapdragon and other ARM laptops.',
hintMacos: 'Apple menu › About This Mac shows your chip.',
hintLinux: 'Pick the package format your distribution uses.',
noteAndroidArm64: 'Most phones and tablets from recent years',
noteAndroidArm32: 'Older 32-bit devices',
noteAndroidX64: 'Emulators and x86 devices',
winSetup: 'Installer',
noteWinSetup: 'Adds a Start menu entry and an uninstaller',
winPortable: 'Portable',
noteWinPortable: 'Unzip and run, nothing to install',
noteMacArm: 'Macs with an M-series chip',
noteMacIntel: 'Macs with an Intel processor',
noteDeb: '.deb package for Debian, Ubuntu and their derivatives',
noteRpm: '.rpm package for Fedora, openSUSE and their derivatives',
noteAppImage: 'Runs on most distributions without installing',
tipFdroidTitle: 'F-Droid',
tipFdroid: 'Add the FlClash repository to your F-Droid client to get updates there.',
tipFdroidLink: 'Open the F-Droid repository',
tipIntentTitle: 'Automation',
tipIntent: 'Other apps can start, stop or toggle the proxy with these intent actions.',
tipBrewTitle: 'Homebrew',
tipBrew: 'Install from the terminal, and upgrade with brew later.',
tipTrayTitle: 'Tray icon',
tipTray:
'The .deb package installs its own dependencies. With the AppImage or the .rpm, install the AyatanaAppIndicator library so the tray icon can show.',
clTitle: 'Changelog',
clMeta: (n, d) => `${n} releases since ${d}`,
clSearch: 'Search changes or versions',
clLatest: 'Latest',
clMore: (n) => `Show earlier releases (${n} more)`,
clResults: (e, v) => `${e} ${e === 1 ? 'change' : 'changes'} in ${v} ${v === 1 ? 'release' : 'releases'}`,
clNone: (q) => `Nothing matches “${q}”.`,
clClear: 'Clear',
clEmpty: 'Internal improvements only.',
clError: 'The changelog could not be loaded.',
clErrorLink: 'Read CHANGELOG.md on GitHub',
clOnGithub: 'GitHub',
clOnGithubLabel: (v) => `Release ${v} on GitHub`,
typeBreaking: 'Breaking changes',
typeFeat: 'Features',
typeFix: 'Fixes',
typePerf: 'Performance',
typeRevert: 'Reverts',
footLicense: 'Free and open source under GPL-3.0.',
footUpdated: 'This page updates with every release.',
footReleases: 'Releases',
footTelegram: 'Telegram channel',
stars: (n) => `${n} stars on GitHub`,
},
zh: {
pageTitle: 'FlClash – 基于 Clash Meta(mihomo)的多平台代理客户端,支持 Android、Windows、macOS、Linux',
pageDescription:
'FlClash 是免费开源的 Clash Meta(mihomo)代理客户端,支持 Android、Windows、macOS 与 Linux,提供规则分流、TUN 模式与订阅导入,没有广告。',
skip: '跳到下载',
navDownload: '下载',
navChangelog: '更新日志',
langToggle: 'EN',
langLabel: 'Switch to English',
themeLabel: '切换深色模式',
heroFor: '适用于',
reelLabel: '切换平台',
heroLede: '基于 Clash Meta(mihomo)内核的多平台代理客户端。简单易用,开源,无广告。',
heroOther: '其他平台',
heroReleaseLoading: '最新版本',
heroRelease: (v) => `${v} 已发布 · 查看更新`,
ctaDownload: (p) => `下载 ${p} 版`,
ctaChoose: '选择下载版本',
ctaIos: 'FlClash 不支持 iOS,可以在下方选择其他设备的版本。',
previewAlt: 'FlClash 仪表盘在 MacBook 与手机上的界面',
fact1Title: 'mihomo 内核',
fact1: '规则分流、代理组与 TUN 模式。',
fact2Title: '订阅导入',
fact2: '通过订阅链接导入配置。',
fact3Title: 'WebDAV 同步',
fact3: '在设备之间备份与恢复数据。',
fact4Title: '开源',
fact4: '以 GPL-3.0 协议开源,没有广告。',
dlTitle: '下载',
dlMeta: (v, d) => `版本 ${v} · ${d}发布`,
dlLead: '已为当前设备选好安装包,每个文件都注明了适用的设备。',
dlAll: '在 GitHub 查看全部版本',
dlError: '未能读取版本信息,请前往 GitHub Releases 下载。',
platformLabel: '平台',
archLabel: '架构',
recommended: '推荐',
copied: '已复制',
copy: '复制',
copySha: (name) => `复制 ${name} 的 SHA-256`,
hintAndroid: '不确定选哪个?选 arm64-v8a。',
hintWindows: 'ARM64 适用于骁龙等 ARM 架构笔记本。',
hintMacos: '在苹果菜单 › 关于本机中可以查看芯片类型。',
hintLinux: '按发行版使用的软件包格式选择。',
noteAndroidArm64: '近几年的绝大多数手机与平板',
noteAndroidArm32: '较老的 32 位设备',
noteAndroidX64: '模拟器与 x86 设备',
winSetup: '安装版',
noteWinSetup: '添加开始菜单入口与卸载程序',
winPortable: '便携版',
noteWinPortable: '解压即用,无需安装',
noteMacArm: '搭载 M 系列芯片的 Mac',
noteMacIntel: '搭载 Intel 处理器的 Mac',
noteDeb: '.deb 安装包,也适用于其衍生版',
noteRpm: '.rpm 安装包,也适用于其衍生版',
noteAppImage: '适用于多数发行版,无需安装',
tipFdroidTitle: 'F-Droid',
tipFdroid: '在 F-Droid 客户端中添加 FlClash 仓库,即可在那里接收更新。',
tipFdroidLink: '打开 F-Droid 仓库',
tipIntentTitle: '自动化',
tipIntent: '其他应用可以通过以下 Intent Action 启动、停止或切换代理。',
tipBrewTitle: 'Homebrew',
tipBrew: '在终端中安装,之后用 brew 升级。',
tipTrayTitle: '托盘图标',
tipTray: '.deb 安装包会自动安装所需依赖。使用 AppImage 或 .rpm 时,需要先安装 AyatanaAppIndicator 库,托盘图标才能显示。',
clTitle: '更新日志',
clMeta: (n, d) => `自 ${d}以来共 ${n} 个版本`,
clSearch: '搜索更新内容或版本',
clLatest: '最新',
clMore: (n) => `显示更早的版本(还有 ${n} 个)`,
clResults: (e, v) => `${v} 个版本中有 ${e} 条匹配`,
clNone: (q) => `没有与「${q}」相关的更新。`,
clClear: '清除',
clEmpty: '仅内部改进。',
clError: '未能加载更新日志。',
clErrorLink: '在 GitHub 查看 CHANGELOG.md',
clOnGithub: 'GitHub',
clOnGithubLabel: (v) => `在 GitHub 查看 ${v} 发布页`,
typeBreaking: '不兼容变更',
typeFeat: '新功能',
typeFix: '问题修复',
typePerf: '性能',
typeRevert: '回退',
footLicense: '以 GPL-3.0 协议免费开源。',
footUpdated: '本页随每次发布自动更新。',
footReleases: '发布页',
footTelegram: 'Telegram 频道',
stars: (n) => `GitHub 上有 ${n} 个星标`,
},
};
function parseChangelog(markdown) {
const versions = [];
let version = null;
let group = null;
let entry = null;
for (const raw of markdown.split(/\r?\n/)) {
const line = raw.trimEnd();
let match = line.match(/^## (v?\d[^\s(]*)(?: \((\d{4}-\d{2}-\d{2})\))?$/);
if (match) {
version = { tag: match[1], version: match[1].replace(/^v/, ''), date: match[2] || '', groups: [], empty: false };
versions.push(version);
group = null;
entry = null;
continue;
}
if (!version) continue;
match = line.match(/^\*\*(.+)\*\*$/);
if (match) {
group = { type: TYPE_BY_TITLE[match[1]] || 'other', title: match[1], entries: [] };
version.groups.push(group);
entry = null;
continue;
}
match = line.match(/^[-*] (.+)$/);
if (match) {
if (!group) {
group = { type: null, title: '', entries: [] };
version.groups.push(group);
}
entry = parseEntry(match[1]);
group.entries.push(entry);
continue;
}
if (entry && /^\s+\S/.test(raw)) {
entry.text += ` ${raw.trim()}`;
continue;
}
if (line === 'Internal improvements only.') version.empty = true;
}
return versions;
}
function parseEntry(source) {
let text = source;
let scope = null;
let id = null;
const scoped = text.match(/^\*\*([^*]+)\*\* (.+)$/);
if (scoped) {
scope = scoped[1];
text = scoped[2];
}
const hashed = text.match(/^(.*) \(([0-9a-f]{7,40})\)$/);
if (hashed) {
text = hashed[1];
id = hashed[2];
}
return { scope, text, id };
}
function translate(lang, key, ...args) {
const value = STRINGS[lang][key] ?? STRINGS.en[key] ?? key;
return typeof value === 'function' ? value(...args) : value;
}
function formatDate(iso, lang) {
if (!iso) return '';
return new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en', {
year: 'numeric',
month: lang === 'zh' ? 'long' : 'short',
day: 'numeric',
timeZone: 'UTC',
}).format(new Date(`${iso}T00:00:00Z`));
}
function changelogMeta(versions, lang) {
const oldest = versions.filter((version) => version.date).pop();
return oldest ? translate(lang, 'clMeta', versions.length, formatDate(oldest.date, lang)) : '';
}
function escapeHtml(text) {
return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
}
function highlight(text, query) {
if (!query) return escapeHtml(text);
const lower = text.toLowerCase();
let html = '';
let from = 0;
for (let at = lower.indexOf(query); at !== -1; at = lower.indexOf(query, from)) {
html += `${escapeHtml(text.slice(from, at))}<mark>${escapeHtml(text.slice(at, at + query.length))}</mark>`;
from = at + query.length;
}
return html + escapeHtml(text.slice(from));
}
function releaseHtml(version, groups, { lang, query, isLatest, ago }) {
const t = (key, ...args) => translate(lang, key, ...args);
const tag = escapeHtml(version.tag);
let row = 0;
const order = () => ` style="--i:${Math.min(row++, MAX_STAGGER)}"`;
const sections = groups.map((group) => {
const title = group.type ? (TYPE_KEYS[group.type] ? t(TYPE_KEYS[group.type]) : group.title) : '';
const heading = title
? `<h3 class="group-title"${order()}><span class="mark" aria-hidden="true"></span>${escapeHtml(title)}</h3>`
: '';
const entries = group.entries.map((entry) => {
const scope = entry.scope ? `<span class="scope">${highlight(entry.scope, query)}</span>` : '';
const hash = entry.id ? `<a class="hash" href="${REPO}/commit/${entry.id}">${entry.id.slice(0, 7)}</a>` : '';
return `<li class="entry"${order()}><span class="entry-text">${scope}${highlight(entry.text, query)}</span>${hash}</li>`;
});
return (
`<section class="${group.type ? 'group' : 'group plain'}" data-type="${escapeHtml(group.type || 'plain')}">` +
`${heading}<ul class="entries">${entries.join('')}</ul></section>`
);
});
const empty = version.empty && !groups.length ? `<p class="empty">${escapeHtml(t('clEmpty'))}</p>` : '';
const title = ago ? ` title="${escapeHtml(ago(version.date))}"` : '';
const date = version.date
? `<span class="when"><time datetime="${version.date}"${title}>${formatDate(version.date, lang)}</time></span>`
: '';
return (
`<article class="release" id="${tag}"><header class="release-head"><div class="ver-row">` +
`<a class="ver" href="#${tag}">${highlight(version.version, query)}</a>` +
(isLatest ? `<span class="latest">${escapeHtml(t('clLatest'))}</span>` : '') +
`</div>${date}<span class="when"><a href="${REPO}/releases/tag/${tag}" ` +
`aria-label="${escapeHtml(t('clOnGithubLabel', version.tag))}">${escapeHtml(t('clOnGithub'))} ${ICON_EXTERNAL}</a>` +
`</span></header><div class="release-body">${empty}${sections.join('')}</div></article>`
);
}
function timelineHtml(items, { lang, query = '', latestTag, ago, year = null }) {
let html = '';
for (const { version, groups } of items) {
const releaseYear = version.date.slice(0, 4);
if (releaseYear && releaseYear !== year) {
html += `<div class="year">${releaseYear}</div>`;
year = releaseYear;
}
html += releaseHtml(version, groups, { lang, query, isLatest: version.tag === latestTag, ago });
}
return { html, year };
}
return { REPO, FIRST_PAGE, STRINGS, parseChangelog, translate, formatDate, changelogMeta, escapeHtml, timelineHtml };
})();
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>FlClash – Clash Meta (mihomo) proxy client for Android, Windows, macOS and Linux</title>
<meta name="description" content="FlClash is a free, open-source Clash Meta (mihomo) proxy client for Android, Windows, macOS and Linux, with rule routing, TUN mode and subscription import. No ads.">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f3efea" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#181617" media="(prefers-color-scheme: dark)">
<meta property="og:type" content="website">
<meta property="og:title" content="FlClash">
<meta property="og:description" content="A multi-platform proxy client based on Clash Meta (mihomo). Simple to use, open source and ad-free.">
<meta property="og:image" content="https://chen08209.github.io/FlClash/preview.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icon.png">
<link rel="stylesheet" href="assets/style.css">
<script>
(function () {
var root = document.documentElement;
var page = root.dataset.pageLang;
var lang = null;
try {
var theme = localStorage.getItem('flclash.theme');
if (theme === 'light' || theme === 'dark') root.dataset.theme = theme;
lang = localStorage.getItem('flclash.lang');
} catch (e) {}
if (page === 'zh') return;
if (lang !== 'zh' && lang !== 'en') {
var prefs = navigator.languages || [navigator.language || ''];
lang = prefs.some(function (l) { return /^zh/i.test(l); }) ? 'zh' : 'en';
}
// The built English page is also the x-default URL, so it hands Chinese readers to the zh/ page.
if (page === 'en') {
if (lang === 'zh' && !/[?&]lang=en(&|$)/.test(location.search)) {
location.replace('zh/' + location.search + location.hash);
}
return;
}
root.lang = lang === 'zh' ? 'zh-CN' : 'en';
})();
</script>
</head>
<body>
<a class="skip" href="#download" data-i18n="skip">Skip to downloads</a>
<header class="nav" id="nav">
<div class="wrap nav-inner">
<a class="brand" href="#top" aria-label="FlClash">
<svg class="glyph" viewBox="0 0 240 240" aria-hidden="true">
<g stroke-width="46.32" stroke-linecap="round">
<line class="sig sig-3" x1="59.68" y1="100.95" x2="180.02" y2="31.47"/>
<line class="sig sig-2" x1="90.56" y1="154.43" x2="150.74" y2="119.69"/>
</g>
<circle class="sig sig-1" cx="121.44" cy="207.92" r="23.16"/>
</svg>
<span>FlClash</span>
</a>
<nav class="nav-links" aria-label="Sections" data-ink>
<a href="#download" data-i18n="navDownload">Download</a>
<a href="#changelog" data-i18n="navChangelog">Changelog</a>
</nav>
<div class="nav-tools" data-ink>
<a class="tool gh" href="https://github.com/chen08209/FlClash" id="gh-link" aria-label="GitHub">
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-1.96c-3.2.7-3.87-1.54-3.87-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.69 1.25 3.35.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.18-3.09-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.81 1.18 1.83 1.18 3.09 0 4.41-2.69 5.38-5.25 5.67.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z"/></svg>
<span id="stars" class="stars" hidden></span>
</a>
<button class="tool" id="lang-toggle" type="button">中文</button>
<button class="tool" id="theme-toggle" type="button" aria-label="Toggle dark mode">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<circle cx="12" cy="12" r="8.25" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 3.75a8.25 8.25 0 0 1 0 16.5Z" fill="currentColor"/>
</svg>
</button>
</div>
</div>
<span class="nav-progress" aria-hidden="true"></span>
</header>
<main id="top">
<section class="hero">
<canvas class="field" id="field" aria-hidden="true"></canvas>
<div class="wrap">
<a class="eyebrow" id="hero-release" href="#changelog">
<span class="pulse" aria-hidden="true"></span>
<span id="hero-release-text" data-i18n="heroReleaseLoading">Latest release</span>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
<div class="hero-grid">
<div class="hero-copy">
<h1 class="display">
<span class="line word" id="wordmark">FlClash</span>
<span class="line second" id="tagline"><span class="for" data-i18n="heroFor">for</span> <button class="reel" id="reel" type="button" aria-label="Switch platform"><span class="reel-track" id="reel-track" aria-hidden="true"><span>Android</span><span>Windows</span><span>macOS</span><span>Linux</span></span><span class="sr" id="reel-current">Android</span></button></span>
</h1>
<p class="lede" data-i18n="heroLede">A multi-platform proxy client based on Clash Meta (mihomo). Simple to use, open source and ad-free.</p>
<div class="cta">
<a class="btn primary" id="cta" href="#download">
<span class="ico-clip" aria-hidden="true"><svg class="ico-down" viewBox="0 0 20 20" width="20" height="20"><path d="M10 3.5v11m0 0-4.5-4.5m4.5 4.5 4.5-4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="btn-text">
<span id="cta-title" data-i18n="ctaChoose">Choose a download</span>
<span class="btn-sub" id="cta-sub" hidden></span>
</span>
</a>
<a class="btn quiet" href="#download" data-i18n="heroOther">Other platforms</a>
</div>
<p class="cta-note" id="cta-note" hidden></p>
</div>
<div class="stage" id="stage" aria-hidden="true"></div>
</div>
<figure class="preview">
<picture>
<source id="shot-dark" srcset="preview-dark.png" media="(prefers-color-scheme: dark)">
<img class="shot" src="preview.png" width="2400" height="1500" decoding="async" alt="FlClash dashboard on a MacBook and a phone" data-i18n-alt="previewAlt">
</picture>
</figure>
<ul class="facts" id="facts">
<li><b data-i18n="fact1Title">mihomo core</b><span data-i18n="fact1">Rule routing, proxy groups and TUN mode.</span></li>
<li><b data-i18n="fact2Title">Subscriptions</b><span data-i18n="fact2">Import a profile from a subscription link.</span></li>
<li><b data-i18n="fact3Title">WebDAV sync</b><span data-i18n="fact3">Back up and restore your data across devices.</span></li>
<li><b data-i18n="fact4Title">Open source</b><span data-i18n="fact4">Licensed under GPL-3.0, with no ads.</span></li>
</ul>
</div>
</section>
<section class="section wrap" id="download" aria-labelledby="download-title">
<div class="rail-grid">
<div class="rail">
<p class="kicker" aria-hidden="true"><span class="cap"></span>01</p>
<h2 id="download-title" data-i18n="dlTitle">Download</h2>
<p class="section-sub" id="dl-meta"></p>
<p class="rail-text" data-i18n="dlLead">The build for this device is already selected. Each file says which devices it is for.</p>
<p class="rail-links">
<a id="sums-link" href="https://github.com/chen08209/FlClash/releases/latest">SHA256SUMS</a>
<a href="https://github.com/chen08209/FlClash/releases" data-i18n="dlAll">All releases on GitHub</a>
</p>
</div>
<div class="dl">
<div class="seg" id="platform-seg" role="tablist" aria-label="Platform">
<span class="seg-ink" aria-hidden="true"></span>
<button type="button" role="tab" id="tab-android" data-value="android" aria-controls="panel">Android</button>
<button type="button" role="tab" id="tab-windows" data-value="windows" aria-controls="panel">Windows</button>
<button type="button" role="tab" id="tab-macos" data-value="macos" aria-controls="panel">macOS</button>
<button type="button" role="tab" id="tab-linux" data-value="linux" aria-controls="panel">Linux</button>
</div>
<div class="panel" id="panel" role="tabpanel">
<div class="panel-top">
<p class="panel-hint" id="panel-hint"></p>
<div class="seg seg-sm" id="arch-seg" role="tablist" aria-label="Architecture" hidden>
<span class="seg-ink" aria-hidden="true"></span>
<button type="button" role="tab" id="arch-x64" data-value="x64">x64</button>
<button type="button" role="tab" id="arch-arm64" data-value="arm64">ARM64</button>
</div>
</div>
<ul class="files" id="files"></ul>
<p class="dl-error" id="dl-error" hidden></p>
<div class="extras" id="extras"></div>
</div>
</div>
</div>
</section>
<section class="section wrap" id="changelog" aria-labelledby="changelog-title">
<div class="rail-grid">
<div class="rail rail-sticky">
<p class="kicker" aria-hidden="true"><span class="cap"></span>02</p>
<h2 id="changelog-title" data-i18n="clTitle">Changelog</h2>
<p class="section-sub" id="cl-meta"></p>
<label class="search" for="cl-search">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="9" cy="9" r="5.75" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="m13.25 13.25 3.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<input id="cl-search" type="search" autocomplete="off" spellcheck="false" placeholder="Search changes or versions" data-i18n-placeholder="clSearch">
<kbd aria-hidden="true">/</kbd>
</label>
<p class="cl-status" id="cl-status" aria-live="polite"></p>
</div>
<div class="cl">
<div class="timeline" id="timeline"></div>
<button class="more" id="cl-more" type="button" hidden></button>
</div>
</div>
</section>
</main>
<footer class="foot wrap">
<div class="foot-brand">
<svg class="glyph" viewBox="0 0 240 240" aria-hidden="true">
<g stroke-width="46.32" stroke-linecap="round">
<line class="sig sig-3" x1="59.68" y1="100.95" x2="180.02" y2="31.47"/>
<line class="sig sig-2" x1="90.56" y1="154.43" x2="150.74" y2="119.69"/>
</g>
<circle class="sig sig-1" cx="121.44" cy="207.92" r="23.16"/>
</svg>
<div>
<p class="foot-name">FlClash</p>
<p class="foot-text"><span data-i18n="footLicense">Free and open source under GPL-3.0.</span> <span data-i18n="footUpdated">This page updates with every release.</span></p>
</div>
</div>
<nav class="foot-links" aria-label="Links">
<a href="https://github.com/chen08209/FlClash">GitHub</a>
<a href="https://github.com/chen08209/FlClash/releases" data-i18n="footReleases">Releases</a>
<a href="https://t.me/FlClash" data-i18n="footTelegram">Telegram channel</a>
</nav>
</footer>
<script src="assets/shared.js"></script>
<script src="assets/field.js"></script>
<script src="assets/app.js"></script>
</body>
</html>
+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',
);