From 854c360ea1de9d2bc5d001e77d82f0f90abfe5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 13 Oct 2026 13:43:38 -0400 Subject: [PATCH] Site: show downloads across every release [skip ci] /release.json adds the sum of every release asset's downloads; the update manifests are the apps' checks, not downloads. Co-Authored-By: Claude Opus 5.5 --- agents.md | 2 +- site/e2e/fixtures.ts | 3 ++- site/e2e/text.spec.ts | 14 +++++++---- site/src/main.ts | 13 +++++++--- site/src/release.ts | 9 +++---- site/src/text.ts | 11 ++++++++- site/worker/index.ts | 55 +++++++++++++++++++++++++++++++++---------- 7 files changed, 81 insertions(+), 26 deletions(-) diff --git a/agents.md b/agents.md index a51611a..3369500 100644 --- a/agents.md +++ b/agents.md @@ -173,7 +173,7 @@ tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住 tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认 tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限) tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用) -cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本,缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev +cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属) cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染) cargo check --workspace diff --git a/site/e2e/fixtures.ts b/site/e2e/fixtures.ts index 2046269..8a789d1 100644 --- a/site/e2e/fixtures.ts +++ b/site/e2e/fixtures.ts @@ -3,12 +3,13 @@ import { test as base } from "@playwright/test"; export { expect, type Page } from "@playwright/test"; export const VERSION = "9.9.9"; +export const DOWNLOADS = 12345; // Every page reads this version. export const test = base.extend<{ release: void }>({ release: [ async ({ context }, use) => { - await context.route("**/release.json", (r) => r.fulfill({ json: { version: VERSION } })); + await context.route("**/release.json", (r) => r.fulfill({ json: { version: VERSION, downloads: DOWNLOADS } })); await use(); }, { auto: true }, diff --git a/site/e2e/text.spec.ts b/site/e2e/text.spec.ts index f6efaf5..7bcde1f 100644 --- a/site/e2e/text.spec.ts +++ b/site/e2e/text.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, VERSION, type Page } from "./fixtures.ts"; -import { TEXT, type Key, type Lang } from "../src/text.ts"; +import { DOWNLOADS, expect, test, VERSION, type Page } from "./fixtures.ts"; +import { TEXT, word, type Key, type Lang } from "../src/text.ts"; const REPO = "https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice"; const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"]; @@ -23,11 +23,12 @@ const keyed = (page: Page) => /** Asserts every keyed element reads in `lang`. */ async function reads(page: Page, lang: Lang) { await expect(page.locator("html")).toHaveAttribute("lang", lang === "zh" ? "zh-CN" : "en"); + await expect(page.locator("[data-t=count]")).toBeVisible(); const found = await keyed(page); expect(found.length).toBeGreaterThan(0); for (const [key, text] of found) { expect(text, key).not.toBe(""); - expect(text, key).toBe(TEXT[lang][key as Key]); + expect(text, key).toBe(word(lang, key as Key, key === "count" ? DOWNLOADS : undefined)); } } @@ -93,9 +94,13 @@ test.describe("English locale", () => { expect(files).toEqual([]); }); - test("the download area names the newest version", async ({ page }) => { + test("the download area names the newest version and all downloads", async ({ page }) => { await page.goto("/"); await expect(page.locator("#download h2")).toHaveText(`Ely Office ${VERSION}`); + const count = page.locator("#download [data-t=count]"); + await expect(count).toHaveText("12,345 downloads so far."); + await page.locator(".lang").click(); + await expect(count).toHaveText("已下载 12,345 次。"); }); test("a failed version read shows as an error", async ({ page }) => { @@ -104,6 +109,7 @@ test.describe("English locale", () => { await expect.poll(() => errors.length).toBe(1); expect(errors[0]).toContain("Gitea answered 500"); await expect(page.locator("#download h2")).toHaveText("Ely Office"); + await expect(page.locator("#download [data-t=count]")).toBeHidden(); errors = []; }); diff --git a/site/src/main.ts b/site/src/main.ts index 1d42e73..c6a4410 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -7,7 +7,7 @@ import { night } from "./night.ts"; import { rhythm } from "./rhythm.ts"; import { playground } from "./play.ts"; import { film } from "./film.ts"; -import { said, speak, type Lang } from "./text.ts"; +import { said, speak, word, type Lang } from "./text.ts"; import { RELEASES, latest } from "./release.ts"; const REPO = "https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice"; @@ -36,10 +36,11 @@ const footer = document.createElement("footer"); footer.id = "download"; const version = document.createElement("h2"); version.textContent = "Ely Office"; -void latest().then((v) => (version.textContent = `Ely Office ${v}`)); +const count = said("p", "count"); +count.hidden = true; const get = said("a", "get"); get.href = RELEASES; -footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), get); +footer.append(version, count, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), get); const toggle = said("button", "other"); toggle.className = "lang"; @@ -57,3 +58,9 @@ main.append(section, playground(), shelf, film()); app.append(night(), toggle, main, footer); say(lang); reveal([shelf]); +void latest().then(({ version: v, downloads }) => { + version.textContent = `Ely Office ${v}`; + count.dataset.n = String(downloads); + count.textContent = word(lang, "count", downloads); + count.hidden = false; +}); diff --git a/site/src/release.ts b/site/src/release.ts index 446de32..23dbc44 100644 --- a/site/src/release.ts +++ b/site/src/release.ts @@ -1,10 +1,11 @@ // Gitea redirects this to the newest release. export const RELEASES = "https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice/releases/latest"; -/** The newest release's version, from the worker. */ -export async function latest(): Promise { +export type Release = { version: string; downloads: number }; + +/** The newest release and all downloads, from the worker. */ +export async function latest(): Promise { const res = await fetch("/release.json"); if (!res.ok) throw new Error(`release.json: ${res.status} ${await res.text()}`); - const { version } = (await res.json()) as { version: string }; - return version; + return (await res.json()) as Release; } diff --git a/site/src/text.ts b/site/src/text.ts index 565c69c..e9a821c 100644 --- a/site/src/text.ts +++ b/site/src/text.ts @@ -17,6 +17,7 @@ const en = { needs: "macOS 14 or later, Apple silicon.", unsigned: "Not notarized. If macOS blocks the app, open System Settings › Privacy & Security and click Open Anyway.", get: "Download", + count: "{n} downloads so far.", other: "中文", roads: "Drag a file down the road.", fileDoc: "ElyDoc file", @@ -53,6 +54,7 @@ const zh: typeof en = { needs: "macOS 14 或更新,Apple 芯片。", unsigned: "未经公证。macOS 拦下应用时,打开“系统设置 › 隐私与安全性”,点“仍要打开”。", get: "前往下载", + count: "已下载 {n} 次。", other: "English", roads: "把文件沿着路拖过去。", fileDoc: "ElyDoc 文件", @@ -83,10 +85,17 @@ export function said(tag: K, key: Key): H return el; } +/** `key` in `lang`, with `n` written for {n}. */ +export function word(lang: Lang, key: Key, n?: number): string { + const text = TEXT[lang][key]; + return n === undefined ? text : text.replace("{n}", n.toLocaleString(lang === "zh" ? "zh-CN" : "en")); +} + /** Writes every keyed text in `lang`. */ export function speak(lang: Lang): void { document.documentElement.lang = lang === "zh" ? "zh-CN" : "en"; for (const el of document.querySelectorAll("[data-t]")) { - el.textContent = TEXT[lang][el.dataset.t as Key]; + const { n } = el.dataset; + el.textContent = word(lang, el.dataset.t as Key, n === undefined ? undefined : Number(n)); } } diff --git a/site/worker/index.ts b/site/worker/index.ts index 1362c4e..081630d 100644 --- a/site/worker/index.ts +++ b/site/worker/index.ts @@ -1,20 +1,51 @@ -// Answers /release.json with the newest release. -const API = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest"; +// Answers /release.json with the newest release and downloads. +const REPO = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ElyOffice"; +const PAGE = 50; type Cached = RequestInit & { cf: { cacheTtl: number; cacheEverything: boolean } }; +type Asset = { name: string; download_count: number }; +type Release = { tag_name: string; assets: Asset[] }; + +const init: Cached = { + headers: { accept: "application/json", "user-agent": "ely-office-site" }, + cf: { cacheTtl: 300, cacheEverything: true }, +}; + +async function read(path: string): Promise { + const res = await fetch(`${REPO}${path}`, init); + if (!res.ok) throw new Error(`Gitea answered ${res.status} for ${path}`); + return (await res.json()) as T; +} + +/** The newest release's version, without the v. */ +async function version(): Promise { + const { tag_name: tag } = await read("/releases/latest"); + if (!/^v\d+\.\d+\.\d+$/.test(tag)) throw new Error(`Gitea's latest tag is ${JSON.stringify(tag)}`); + return tag.slice(1); +} + +/** Downloads of every release; update manifests are checks, not downloads. */ +async function downloads(): Promise { + let total = 0; + for (let page = 1; ; page++) { + const releases = await read(`/releases?limit=${PAGE}&page=${page}`); + for (const { assets } of releases) { + for (const a of assets) { + if (!Number.isInteger(a.download_count)) throw new Error(`${a.name} has no download count`); + if (!a.name.endsWith(".json")) total += a.download_count; + } + } + if (releases.length < PAGE) return total; + } +} export default { async fetch(): Promise { - const init: Cached = { - headers: { accept: "application/json", "user-agent": "ely-office-site" }, - cf: { cacheTtl: 300, cacheEverything: true }, - }; - const res = await fetch(API, init); - if (!res.ok) return new Response(`Gitea answered ${res.status}`, { status: 502 }); - const { tag_name: tag } = (await res.json()) as { tag_name: unknown }; - if (typeof tag !== "string" || !/^v\d+\.\d+\.\d+$/.test(tag)) { - return new Response(`Gitea's latest tag is ${JSON.stringify(tag)}`, { status: 502 }); + try { + const [v, n] = await Promise.all([version(), downloads()]); + return Response.json({ version: v, downloads: n }, { headers: { "cache-control": "public, max-age=300" } }); + } catch (e) { + return new Response(String(e), { status: 502 }); } - return Response.json({ version: tag.slice(1) }, { headers: { "cache-control": "public, max-age=300" } }); }, };