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 <noreply@anthropic.com>
This commit is contained in:
1 parent
87c42fde0e
commit
854c360ea1
7 files changed
+81
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+10
-4
@@ -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 = [];
|
||||
});
|
||||
|
||||
|
||||
+10
-3
@@ -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;
|
||||
});
|
||||
+5
-4
@@ -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<string> {
|
||||
export type Release = { version: string; downloads: number };
|
||||
|
||||
/** The newest release and all downloads, from the worker. */
|
||||
export async function latest(): Promise<Release> {
|
||||
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;
|
||||
}
|
||||
+10
-1
@@ -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<K extends keyof HTMLElementTagNameMap>(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<HTMLElement>("[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));
|
||||
}
|
||||
}
|
||||
+43
-12
@@ -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<T>(path: string): Promise<T> {
|
||||
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<string> {
|
||||
const { tag_name: tag } = await read<Release>("/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<number> {
|
||||
let total = 0;
|
||||
for (let page = 1; ; page++) {
|
||||
const releases = await read<Release[]>(`/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<Response> {
|
||||
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" } });
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user