#!/usr/bin/env node import { rename, unlink, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; // The badge feed is a cached snapshot and can lag the project by hours, which // silently renders a stale chart. Prefer the Crowdin API when a token is // available and keep the badge feed as the tokenless fallback. const STATS_URL = "https://badges.awesome-crowdin.com/stats-17791545-912891.json"; const API_BASE = "https://api.crowdin.com/api/v2"; const PROJECT_ID = "912891"; const REQUEST_TIMEOUT_MS = 30_000; const MAX_RESPONSE_BYTES = 1_000_000; const SOURCE_LANGUAGE_ID = "en"; const WIDTH = 460; const PADDING_X = 20; const DIVIDER_Y = 51; const ROW_BASELINE = 78; const ROW_HEIGHT = 25; const BAR_HEIGHT = 6; const BAR_RADIUS = 3; const BAR_BASELINE_OFFSET = 8; const FLAG_WIDTH = 26; const LABEL_CHAR_WIDTH = 7.1; const LABEL_MAX_WIDTH = 140; const LABEL_GAP = 18; const PERCENT_WIDTH = 38; const PERCENT_GAP = 14; const LEGEND_SWATCH = 8; const LEGEND_CHAR_WIDTH = 5.9; const LEGEND_TEXT_GAP = 14; const LEGEND_ITEM_GAP = 22; const LEGEND_RULE_GAP = 20; const LEGEND_BASELINE_GAP = 40; const LEGEND_BOTTOM_PADDING = 20; const EMPTY_HEIGHT = 96; // Ordered high to low; the first tier a percentage clears wins. Three tiers is a // hard ceiling, not a style choice: a fourth needs an amber that sits at deuteranopia // ΔE 1.4 against the orange, which no threshold tuning can separate. const TIERS = [ { className: "bar-done", label: "Complete", min: 100 }, { className: "bar-progress", label: "In progress", min: 70 }, { className: "bar-low", label: "Needs work", min: 0 }, ]; export class CrowdinStatsError extends Error { constructor(message, { status, url } = {}) { super(message); this.name = "CrowdinStatsError"; this.status = status; this.url = url; } } export function parseArguments(argv) { const options = { outputPath: "translation-progress.svg", statsUrl: STATS_URL, projectId: PROJECT_ID, }; for (let index = 0; index < argv.length; index += 1) { const flag = argv[index]; const value = argv[index + 1]; if (!["--out", "--url", "--project"].includes(flag)) { throw new Error(`Unknown argument: ${flag}`); } if (!value) { throw new Error(`Missing value for ${flag}`); } if (flag === "--out") options.outputPath = value; if (flag === "--url") options.statsUrl = value; if (flag === "--project") options.projectId = value; index += 1; } return options; } async function readJsonResponse(response, url) { const declaredLength = Number(response.headers.get("content-length")); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { throw new CrowdinStatsError(`Crowdin response exceeds ${MAX_RESPONSE_BYTES} bytes`, { url }); } if (!response.body) { throw new CrowdinStatsError("Crowdin response has no body", { url }); } const reader = response.body.getReader(); const chunks = []; let totalBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.byteLength; if (totalBytes > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new CrowdinStatsError(`Crowdin response exceeds ${MAX_RESPONSE_BYTES} bytes`, { url }); } chunks.push(Buffer.from(value)); } try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new CrowdinStatsError("Crowdin response is not valid JSON", { url }); } } export async function fetchCrowdinStats(url = STATS_URL, fetchImpl = fetch) { const response = await fetchImpl(url, { headers: { "User-Agent": "bookorbit-crowdin-stats" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { throw new CrowdinStatsError(`Failed to fetch Crowdin stats: HTTP ${response.status}`, { status: response.status, url, }); } const json = await readJsonResponse(response, url); if (!json || !Array.isArray(json.progress)) { throw new CrowdinStatsError("Invalid Crowdin stats response format", { url }); } return json; } export async function fetchCrowdinApiProgress(token, { projectId = PROJECT_ID, fetchImpl = fetch } = {}) { const url = `${API_BASE}/projects/${projectId}/languages/progress?limit=500`; const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, "User-Agent": "bookorbit-crowdin-stats" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { throw new CrowdinStatsError(`Failed to fetch Crowdin progress: HTTP ${response.status}`, { status: response.status, url, }); } const json = await readJsonResponse(response, url); if (!json || !Array.isArray(json.data)) { throw new CrowdinStatsError("Invalid Crowdin progress response format", { url }); } // Present the API payload in the same shape as the badge feed so the rest of // the generator does not care which source produced it. return { progress: json.data }; } export async function fetchStats({ token, statsUrl = STATS_URL, projectId = PROJECT_ID, fetchImpl = fetch } = {}) { if (token) { return { stats: await fetchCrowdinApiProgress(token, { projectId, fetchImpl }), source: "Crowdin API" }; } return { stats: await fetchCrowdinStats(statsUrl, fetchImpl), source: "cached badge feed" }; } function escapeXml(value) { return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function flagFromLocale(locale) { const region = /[-_]([A-Za-z]{2})$/.exec(locale || "")?.[1]; if (!region) return ""; return String.fromCodePoint(...[...region.toUpperCase()].map((letter) => letter.codePointAt(0) + 0x1f1a5)); } // The label column is sized by the single widest name, so Crowdin's Chinese names // would cost every other row roughly 60px of bar. The flags already carry the // region, leaving the script marker as the only part the name has to supply. const DISPLAY_NAME_OVERRIDES = new Map([ ["zh-CN", "Chinese (S)"], ["zh-TW", "Chinese (T)"], ]); function displayFor(languageId, apiLanguage) { const flag = flagFromLocale(apiLanguage?.locale); const name = DISPLAY_NAME_OVERRIDES.get(languageId) || apiLanguage?.name || languageId; return { name: flag ? name.split(",")[0].trim() : name, flag, }; } function toPercent(value) { const parsed = Number.parseInt(String(value).replace("%", ""), 10); if (!Number.isFinite(parsed)) return 0; return Math.min(100, Math.max(0, parsed)); } export function extractLanguages(statsData) { const targets = []; let source; for (const entry of statsData?.progress ?? []) { const data = entry?.data; const languageId = data?.languageId; if (!languageId) continue; const display = displayFor(languageId, data.language); if (languageId === SOURCE_LANGUAGE_ID) { source = { id: languageId, ...display, percent: 100, isSource: true, translatedWords: 0, totalWords: 0 }; continue; } targets.push({ id: languageId, ...display, percent: toPercent(data.translationProgress), isSource: false, translatedWords: data.words?.translated || 0, totalWords: data.words?.total || 0, }); } targets.sort((a, b) => b.percent - a.percent || a.name.localeCompare(b.name, "en")); return source ? [source, ...targets] : targets; } function averagePercent(languages) { const targets = languages.filter((language) => !language.isSource); if (targets.length === 0) return 0; const totalWords = targets.reduce((sum, language) => sum + language.totalWords, 0); if (totalWords > 0) { const translatedWords = targets.reduce((sum, language) => sum + language.translatedWords, 0); return Math.round((translatedWords / totalWords) * 100); } return Math.round(targets.reduce((sum, language) => sum + language.percent, 0) / targets.length); } function truncateToWidth(name, availableWidth, charWidth) { const maxCharacters = Math.floor(availableWidth / charWidth); if (maxCharacters <= 1 || name.length <= maxCharacters) return name; return `${name.slice(0, maxCharacters - 1).trimEnd()}…`; } function labelFor(language, availableWidth, charWidth) { const textWidth = availableWidth - (language.flag ? FLAG_WIDTH : 0); const name = truncateToWidth(language.name, textWidth, charWidth); return { text: language.flag ? `${language.flag} ${name}` : name, width: (language.flag ? FLAG_WIDTH : 0) + name.length * charWidth, }; } export function computeLayout(languages) { const count = languages.length; const labels = languages.map((language) => labelFor(language, LABEL_MAX_WIDTH, LABEL_CHAR_WIDTH)); // The label column tracks the widest name rather than a fixed slab, so a short // language list does not open a gap between each name and its bar. const labelWidth = count ? Math.max(...labels.map((label) => label.width)) : 0; const barX = Math.round(PADDING_X + labelWidth + LABEL_GAP); const lastRowY = ROW_BASELINE + Math.max(0, count - 1) * ROW_HEIGHT; return { count, labels, barX, barWidth: WIDTH - PADDING_X - PERCENT_WIDTH - PERCENT_GAP - barX, lastRowY, legendY: lastRowY + LEGEND_BASELINE_GAP, height: count ? lastRowY + LEGEND_BASELINE_GAP + LEGEND_BOTTOM_PADDING : EMPTY_HEIGHT, }; } function barClass(language) { return TIERS.find((tier) => language.percent >= tier.min).className; } function renderRow(language, index, layout) { const baseline = ROW_BASELINE + index * ROW_HEIGHT; const barY = baseline - BAR_BASELINE_OFFSET; const fillWidth = Math.round((language.percent / 100) * layout.barWidth); return ` ${escapeXml(layout.labels[index].text)} ${language.percent}%`; } // Every tier is listed even when no language currently falls in it, so the colour // scale stays self-describing and the card height does not shift as progress moves. function renderLegend(layout) { let x = PADDING_X; const entries = TIERS.map((tier) => { const entry = ` ${tier.label}`; x += LEGEND_TEXT_GAP + tier.label.length * LEGEND_CHAR_WIDTH + LEGEND_ITEM_GAP; return entry; }).join(""); return ` ${entries}`; } function renderBody(languages, layout) { if (layout.count === 0) return ""; return languages.map((language, index) => renderRow(language, index, layout)).join("") + renderLegend(layout); } export function generateSvg(statsData) { const languages = extractLanguages(statsData); const layout = computeLayout(languages); const targetCount = languages.filter((language) => !language.isSource).length; const summary = targetCount > 0 ? `Crowdin · ${targetCount} ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average` : "Crowdin · no translation data"; return ` BookOrbit translation progress Translation Progress ${escapeXml(summary)} ${renderBody(languages, layout)} `; } async function writeFileAtomically(path, contents) { const temporaryPath = `${path}.${process.pid}.tmp`; try { await writeFile(temporaryPath, contents, "utf8"); await rename(temporaryPath, path); } catch (error) { await unlink(temporaryPath).catch(() => {}); throw error; } } export async function run(argv = process.argv.slice(2)) { const options = parseArguments(argv); const { stats, source } = await fetchStats({ token: process.env.CROWDIN_TOKEN, statsUrl: options.statsUrl, projectId: options.projectId, }); const svg = generateSvg(stats); const resolvedPath = resolve(process.cwd(), options.outputPath); await writeFileAtomically(resolvedPath, svg); console.log(`Wrote ${resolvedPath} with ${extractLanguages(stats).length} languages from the ${source}`); if (!process.env.CROWDIN_TOKEN) { console.warn("CROWDIN_TOKEN is not set, so these numbers may lag the project. Set it to read live progress."); } } const entryPath = process.argv[1] ? resolve(process.argv[1]) : undefined; if (entryPath === fileURLToPath(import.meta.url)) { run().catch((error) => { console.error(error.message); process.exitCode = 1; }); }