#!/usr/bin/env node
import { rename, unlink, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const STATS_URL = "https://badges.awesome-crowdin.com/stats-17791545-912891.json";
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_RESPONSE_BYTES = 1_000_000;
const SOURCE_LANGUAGE_ID = "en";
const WIDTH = 680;
const RENDERED_WIDTH = 580;
const PADDING_X = 24;
const DIVIDER_Y = 48;
const BAR_RADIUS = 4;
const FLAG_WIDTH = 26;
const LABEL_CHAR_WIDTH = 7.1;
const AXIS_CHAR_WIDTH = 6.1;
const MAX_HORIZONTAL_ROWS = 8;
const ROW_BASELINE = 78;
const ROW_HEIGHT = 42;
const ROW_BOTTOM_PADDING = 36;
const ROW_BAR_HEIGHT = 16;
const ROW_LABEL_WIDTH = 190;
const ROW_LABEL_GAP = 12;
const ROW_PERCENT_WIDTH = 38;
const ROW_PERCENT_GAP = 12;
const AXIS_WIDTH = 30;
const PLOT_TOP = 74;
const PLOT_HEIGHT = 150;
const AXIS_TICKS = [0, 25, 50, 75, 100];
const COLUMN_MAX_WIDTH = 34;
const COLUMN_MIN_WIDTH = 6;
const COLUMN_WIDTH_RATIO = 0.62;
const COLUMN_LABEL_GAP = 16;
const COLUMN_LABEL_MAX_WIDTH = 118;
const COLUMN_LABEL_LEFT_BOUND = 10;
const COLUMN_PERCENT_MIN_SLOT = 32;
const COLUMN_BOTTOM_PADDING = 16;
const ROTATED_LABEL_SCALE = Math.SQRT1_2;
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,
};
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
const value = argv[index + 1];
if (!["--out", "--url"].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;
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;
}
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));
}
function displayFor(languageId, apiLanguage) {
const flag = flagFromLocale(apiLanguage?.locale);
const name = 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;
if (count <= MAX_HORIZONTAL_ROWS) {
return {
mode: "rows",
count,
barWidth: WIDTH - PADDING_X * 2 - ROW_LABEL_WIDTH - ROW_PERCENT_GAP - ROW_PERCENT_WIDTH,
height: ROW_BASELINE + Math.max(0, count - 1) * ROW_HEIGHT + ROW_BOTTOM_PADDING,
};
}
const plotLeft = PADDING_X + AXIS_WIDTH;
const slotWidth = (WIDTH - PADDING_X - plotLeft) / count;
const columnWidth = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(slotWidth * COLUMN_WIDTH_RATIO)));
const columns = languages.map((language, index) => {
const centerX = Math.round(plotLeft + slotWidth * (index + 0.5));
const spaceToLeftEdge = (centerX - COLUMN_LABEL_LEFT_BOUND) / ROTATED_LABEL_SCALE;
const label = labelFor(language, Math.min(COLUMN_LABEL_MAX_WIDTH, spaceToLeftEdge), AXIS_CHAR_WIDTH);
return { centerX, label };
});
const labelWidth = Math.max(0, ...columns.map((column) => column.label.width));
return {
mode: "columns",
count,
plotLeft,
slotWidth,
columnWidth,
columns,
baseline: PLOT_TOP + PLOT_HEIGHT,
showPercentages: slotWidth >= COLUMN_PERCENT_MIN_SLOT,
height: PLOT_TOP + PLOT_HEIGHT + COLUMN_LABEL_GAP + Math.round(labelWidth * ROTATED_LABEL_SCALE) + COLUMN_BOTTOM_PADDING,
};
}
function barClass(language) {
if (language.isSource) return "bar-source";
if (language.percent >= 90) return "bar-high";
if (language.percent >= 60) return "bar-medium";
return "bar-low";
}
function renderRow(language, index, layout) {
const baseline = ROW_BASELINE + index * ROW_HEIGHT;
const barX = PADDING_X + ROW_LABEL_WIDTH;
const fillWidth = Math.round((language.percent / 100) * layout.barWidth);
const label = labelFor(language, ROW_LABEL_WIDTH - ROW_LABEL_GAP, LABEL_CHAR_WIDTH);
return `
${escapeXml(label.text)}
${language.percent}%`;
}
function renderColumn(language, index, layout) {
const { centerX, label } = layout.columns[index];
const x = Math.round(centerX - layout.columnWidth / 2);
const fillHeight = Math.round((language.percent / 100) * PLOT_HEIGHT);
const labelY = layout.baseline + COLUMN_LABEL_GAP;
const percentLabel = layout.showPercentages
? `
${language.percent}%`
: "";
return `
${percentLabel}
${escapeXml(label.text)}`;
}
function renderAxis(layout) {
return AXIS_TICKS.map((tick) => {
const y = layout.baseline - Math.round((tick / 100) * PLOT_HEIGHT);
return `
${tick}`;
}).join("");
}
function renderBody(languages, layout) {
if (layout.mode === "rows") {
return languages.map((language, index) => renderRow(language, index, layout)).join("");
}
return renderAxis(layout) + languages.map((language, index) => renderColumn(language, index, layout)).join("");
}
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} target ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average`
: "Crowdin · no translation data";
return `
`;
}
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 = await fetchCrowdinStats(options.statsUrl);
const svg = generateSvg(stats);
const resolvedPath = resolve(process.cwd(), options.outputPath);
await writeFileAtomically(resolvedPath, svg);
console.log(`Wrote ${resolvedPath} with ${extractLanguages(stats).length} languages`);
}
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;
});
}