fix(scripts): read every page of the Weblate language stats

`getLanguageStats()` returned the first response page, so anything past it was
invisible to both callers. The coverage gate would pass by never seeing the
languages it was meant to catch, and the README language bar would quietly drop
them.

Weblate serves 50 translations per page and each component is at 40, so the
current lists are complete and nothing was missed. That is ten languages of
headroom, and a language appears whenever someone starts translating one.

Follow `next` until it runs out, and report the count actually read. A non-OK
response now names the URL rather than surfacing as a JSON parse error on the
error body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elian Doran 2026-08-20 13:26:02 +03:00
parent 5897d741bc
commit 768bc3946e
No known key found for this signature in database
2 changed files with 70 additions and 4 deletions

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { CACHE_MAX_AGE_MS, isCacheFresh } from "./utils"; import { CACHE_MAX_AGE_MS, fetchAllPages, isCacheFresh } from "./utils";
describe("isCacheFresh", () => { describe("isCacheFresh", () => {
const now = new Date("2026-08-20T12:00:00Z").getTime(); const now = new Date("2026-08-20T12:00:00Z").getTime();
@ -21,3 +21,43 @@ describe("isCacheFresh", () => {
expect(isCacheFresh(now + CACHE_MAX_AGE_MS, now)).toBe(false); expect(isCacheFresh(now + CACHE_MAX_AGE_MS, now)).toBe(false);
}); });
}); });
describe("fetchAllPages", () => {
afterEach(() => vi.unstubAllGlobals());
/** Answers each URL with a canned page, and records the order they were requested in. */
function stubPages(pages: Record<string, unknown>) {
const requested: string[] = [];
vi.stubGlobal("fetch", async (url: string) => {
requested.push(url);
const page = pages[url];
if (!page) return { ok: false, status: 404, statusText: "Not Found" };
return { ok: true, json: async () => page };
});
return requested;
}
it("follows every page and merges the results in order", async () => {
const requested = stubPages({
"/first": { count: 3, next: "/second", results: [ "a", "b" ] },
"/second": { count: 3, next: null, results: [ "c" ] }
});
const merged = await fetchAllPages("/first");
expect(merged).toStrictEqual({ count: 3, results: [ "a", "b", "c" ] });
expect(requested).toStrictEqual([ "/first", "/second" ]);
});
it("counts what it read rather than trusting the reported total", async () => {
stubPages({ "/only": { count: 99, next: null, results: [ "a" ] } });
expect(await fetchAllPages("/only")).toStrictEqual({ count: 1, results: [ "a" ] });
});
it("names the failing URL instead of failing to parse the body", async () => {
stubPages({ "/first": { count: 2, next: "/gone", results: [ "a" ] } });
await expect(fetchAllPages("/first"))
.rejects.toThrow("Weblate answered 404 Not Found for /gone.");
});
});

View File

@ -27,8 +27,8 @@ export async function getLanguageStats(project: WeblateProject) {
// Make the request // Make the request
console.log("Reading language stats from Weblate API."); console.log("Reading language stats from Weblate API.");
const request = await fetch(`https://hosted.weblate.org/api/components/trilium/${project}/translations/`); const stats = await fetchAllPages(
const stats = JSON.parse(await request.text()); `https://hosted.weblate.org/api/components/trilium/${project}/translations/`);
// Update the cache // Update the cache
await writeFile(cacheFile, JSON.stringify(stats, null, 4)); await writeFile(cacheFile, JSON.stringify(stats, null, 4));
@ -36,6 +36,32 @@ export async function getLanguageStats(project: WeblateProject) {
return stats; return stats;
} }
/**
* Reads every page of a paginated Weblate endpoint into a single `results` array.
*
* The API serves 50 entries per page. Each component is already at 40 languages, so a
* caller reading only the first response would start losing languages once translators
* pick up ten more, and the coverage gate would pass by never seeing them.
*/
export async function fetchAllPages(firstPageUrl: string) {
const results: unknown[] = [];
let url: string | null = firstPageUrl;
while (url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Weblate answered ${response.status} ${response.statusText} for ${url}.`);
}
const page = await response.json();
results.push(...page.results);
url = page.next;
}
return { count: results.length, results };
}
/** /**
* Determines whether a cache file written at `mtimeMs` can still be used at `nowMs`. * Determines whether a cache file written at `mtimeMs` can still be used at `nowMs`.
* *