chore(translations): add catalog verification script and update localization docs

This commit is contained in:
neon
2026-07-28 18:44:05 +00:00
parent 12ca966a79
commit c2884bd664
7 changed files with 488 additions and 224 deletions
+119 -107
View File
@@ -4,43 +4,50 @@ 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 = 680;
const RENDERED_WIDTH = 580;
const WIDTH = 540;
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 DIVIDER_Y = 51;
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 ROW_HEIGHT = 28;
const BAR_HEIGHT = 8;
const BAR_RADIUS = 4;
const BAR_BASELINE_OFFSET = 9;
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;
const FLAG_WIDTH = 26;
const LABEL_CHAR_WIDTH = 7.6;
const LABEL_MAX_WIDTH = 180;
const LABEL_GAP = 18;
const PERCENT_WIDTH = 42;
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 } = {}) {
@@ -55,13 +62,14 @@ 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"].includes(flag)) {
if (!["--out", "--url", "--project"].includes(flag)) {
throw new Error(`Unknown argument: ${flag}`);
}
if (!value) {
@@ -70,6 +78,7 @@ export function parseArguments(argv) {
if (flag === "--out") options.outputPath = value;
if (flag === "--url") options.statsUrl = value;
if (flag === "--project") options.projectId = value;
index += 1;
}
@@ -128,6 +137,37 @@ export async function fetchCrowdinStats(url = STATS_URL, fetchImpl = fetch) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
@@ -217,93 +257,61 @@ function labelFor(language, availableWidth, 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));
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 {
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,
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) {
if (language.isSource) return "bar-source";
if (language.percent >= 90) return "bar-high";
if (language.percent >= 60) return "bar-medium";
return "bar-low";
return TIERS.find((tier) => language.percent >= tier.min).className;
}
function renderRow(language, index, layout) {
const baseline = ROW_BASELINE + index * ROW_HEIGHT;
const barX = PADDING_X + ROW_LABEL_WIDTH;
const barY = baseline - BAR_BASELINE_OFFSET;
const fillWidth = Math.round((language.percent / 100) * layout.barWidth);
const label = labelFor(language, ROW_LABEL_WIDTH - ROW_LABEL_GAP, LABEL_CHAR_WIDTH);
return `
<text x="${PADDING_X}" y="${baseline}" class="lang-label">${escapeXml(label.text)}</text>
<rect x="${barX}" y="${baseline - 12}" width="${layout.barWidth}" height="${ROW_BAR_HEIGHT}" rx="${BAR_RADIUS}" class="bar-bg" />
<rect x="${barX}" y="${baseline - 12}" width="${fillWidth}" height="${ROW_BAR_HEIGHT}" rx="${BAR_RADIUS}" class="${barClass(language)}" />
<text x="${barX + layout.barWidth + ROW_PERCENT_GAP}" y="${baseline}" class="pct-label">${language.percent}%</text>`;
<text x="${PADDING_X}" y="${baseline}" class="lang-label">${escapeXml(layout.labels[index].text)}</text>
<rect x="${layout.barX}" y="${barY}" width="${layout.barWidth}" height="${BAR_HEIGHT}" rx="${BAR_RADIUS}" class="bar-bg" />
<rect x="${layout.barX}" y="${barY}" width="${fillWidth}" height="${BAR_HEIGHT}" rx="${BAR_RADIUS}" class="${barClass(language)}" />
<text x="${WIDTH - PADDING_X}" y="${baseline}" text-anchor="end" class="pct-label">${language.percent}%</text>`;
}
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
? `
<text x="${centerX}" y="${layout.baseline - fillHeight - 7}" text-anchor="middle" class="pct-label-small">${language.percent}%</text>`
: "";
// 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;
return `
<rect x="${x}" y="${PLOT_TOP}" width="${layout.columnWidth}" height="${PLOT_HEIGHT}" rx="${BAR_RADIUS}" class="bar-bg" />
<rect x="${x}" y="${layout.baseline - fillHeight}" width="${layout.columnWidth}" height="${fillHeight}" rx="${BAR_RADIUS}" class="${barClass(language)}" />${percentLabel}
<text x="${centerX}" y="${labelY}" text-anchor="end" transform="rotate(-45 ${centerX} ${labelY})" class="lang-label-small">${escapeXml(label.text)}</text>`;
}
function renderAxis(layout) {
return AXIS_TICKS.map((tick) => {
const y = layout.baseline - Math.round((tick / 100) * PLOT_HEIGHT);
return `
<line x1="${layout.plotLeft - 6}" y1="${y}" x2="${WIDTH - PADDING_X}" y2="${y}" class="grid" />
<text x="${layout.plotLeft - 12}" y="${y + 3.5}" text-anchor="end" class="axis-label">${tick}</text>`;
const entries = TIERS.map((tier) => {
const entry = `
<rect x="${x}" y="${layout.legendY - LEGEND_SWATCH}" width="${LEGEND_SWATCH}" height="${LEGEND_SWATCH}" rx="2" class="${tier.className}" />
<text x="${x + LEGEND_TEXT_GAP}" y="${layout.legendY}" class="legend-label">${tier.label}</text>`;
x += LEGEND_TEXT_GAP + tier.label.length * LEGEND_CHAR_WIDTH + LEGEND_ITEM_GAP;
return entry;
}).join("");
return `
<line x1="${PADDING_X}" y1="${layout.lastRowY + LEGEND_RULE_GAP}" x2="${WIDTH - PADDING_X}" y2="${layout.lastRowY + LEGEND_RULE_GAP}" class="grid" />${entries}`;
}
function renderBody(languages, layout) {
if (layout.mode === "rows") {
return languages.map((language, index) => renderRow(language, index, layout)).join("");
}
if (layout.count === 0) return "";
return renderAxis(layout) + languages.map((language, index) => renderColumn(language, index, layout)).join("");
return languages.map((language, index) => renderRow(language, index, layout)).join("") + renderLegend(layout);
}
export function generateSvg(statsData) {
@@ -315,28 +323,25 @@ export function generateSvg(statsData) {
? `Crowdin · ${targetCount} target ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average`
: "Crowdin · no translation data";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${RENDERED_WIDTH}" height="${Math.round((layout.height * RENDERED_WIDTH) / WIDTH)}" viewBox="0 0 ${WIDTH} ${layout.height}" fill="none" role="img" aria-label="BookOrbit translation progress">
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${layout.height}" viewBox="0 0 ${WIDTH} ${layout.height}" fill="none" role="img" aria-label="BookOrbit translation progress">
<title>BookOrbit translation progress</title>
<style>
.card { fill: #0d1117; stroke: #30363d; stroke-width: 1px; rx: 12px; }
.title { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 600; fill: #f0f6fc; }
.subtitle { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; fill: #8b949e; }
.lang-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13.5px; font-weight: 500; fill: #c9d1d9; }
.lang-label-small { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11.5px; font-weight: 500; fill: #c9d1d9; }
.pct-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12.5px; font-weight: 600; fill: #58a6ff; }
.pct-label-small { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 10.5px; font-weight: 600; fill: #8b949e; }
.axis-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 10px; font-weight: 400; fill: #6e7681; }
.lang-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 500; fill: #e6edf3; }
.pct-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13.5px; font-weight: 600; fill: #e6edf3; font-variant-numeric: tabular-nums; }
.legend-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11px; font-weight: 400; fill: #8b949e; }
.grid { stroke: #21262d; stroke-width: 1; }
.bar-bg { fill: #21262d; }
.bar-source { fill: #58a6ff; }
.bar-high { fill: #2ea44f; }
.bar-medium { fill: #d29922; }
.bar-done { fill: #2ea043; }
.bar-progress { fill: #2f81f7; }
.bar-low { fill: #db6d28; }
</style>
<rect width="${WIDTH - 2}" height="${layout.height - 2}" x="1" y="1" class="card" />
<text x="${PADDING_X}" y="36" class="title">Translation Progress</text>
<text x="${WIDTH - PADDING_X}" y="36" text-anchor="end" class="subtitle">${escapeXml(summary)}</text>
<text x="${PADDING_X}" y="35" class="title">Translation Progress</text>
<text x="${WIDTH - PADDING_X}" y="35" text-anchor="end" class="subtitle">${escapeXml(summary)}</text>
<line x1="${PADDING_X}" y1="${DIVIDER_Y}" x2="${WIDTH - PADDING_X}" y2="${DIVIDER_Y}" class="grid" />
${renderBody(languages, layout)}
@@ -357,12 +362,19 @@ async function writeFileAtomically(path, contents) {
export async function run(argv = process.argv.slice(2)) {
const options = parseArguments(argv);
const stats = await fetchCrowdinStats(options.statsUrl);
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`);
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;
+110 -59
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { computeLayout, extractLanguages, generateSvg, parseArguments } from "./generate.mjs";
import { computeLayout, extractLanguages, fetchCrowdinApiProgress, fetchStats, generateSvg, parseArguments } from "./generate.mjs";
function language(id, name, locale, translationProgress, translated = 0, total = 0) {
return { data: { languageId: id, language: { name, locale }, translationProgress, words: { translated, total } } };
@@ -27,6 +27,50 @@ test("parseArguments rejects unknown flags and missing values", () => {
assert.throws(() => parseArguments(["--out"]), /Missing value for --out/);
});
test("parseArguments accepts a project override", () => {
assert.equal(parseArguments([]).projectId, "912891");
assert.equal(parseArguments(["--project", "42"]).projectId, "42");
});
test("fetchStats reads live progress from the Crowdin API when a token is available", async () => {
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url, authorization: init.headers.Authorization });
return new Response(JSON.stringify({ data: [language("de", "German", "de-DE", 93, 4921, 5322)] }));
};
const { stats, source } = await fetchStats({ token: "secret-token", fetchImpl });
assert.equal(source, "Crowdin API");
assert.match(calls[0].url, /\/projects\/912891\/languages\/progress/);
assert.equal(calls[0].authorization, "Bearer secret-token");
// The API payload is remapped into the badge feed shape the generator expects.
assert.equal(stats.progress[0].data.languageId, "de");
assert.equal(extractLanguages(stats)[0].percent, 93);
});
test("fetchStats falls back to the cached badge feed when no token is set", async () => {
let requested;
const fetchImpl = async (url) => {
requested = url;
return new Response(JSON.stringify(sampleStats));
};
const { stats, source } = await fetchStats({ fetchImpl });
assert.equal(source, "cached badge feed");
assert.match(requested, /badges\.awesome-crowdin\.com/);
assert.equal(stats.progress.length, sampleStats.progress.length);
});
test("fetchCrowdinApiProgress rejects failed requests and malformed payloads", async () => {
await assert.rejects(() => fetchCrowdinApiProgress("token", { fetchImpl: async () => new Response("nope", { status: 500 }) }), /HTTP 500/);
await assert.rejects(
() => fetchCrowdinApiProgress("token", { fetchImpl: async () => new Response(JSON.stringify({ unexpected: true })) }),
/Invalid Crowdin progress response format/,
);
});
test("extractLanguages pins the source language at 100% and sorts targets by progress", () => {
const languages = extractLanguages(sampleStats);
assert.deepEqual(
@@ -65,81 +109,98 @@ test("generateSvg renders every target language with its percentage", () => {
assert.match(svg, />97%</);
assert.match(svg, /4 target languages · 96% average/);
assert.match(svg, /English/);
assert.match(svg, /class="bar-source"/);
assert.doesNotMatch(svg, />100%<.*\n.*bar-source/);
});
test("generateSvg renders the source language as a full blue bar excluded from the average", () => {
test("generateSvg renders the source language like any other completed row but keeps it out of the average", () => {
const svg = generateSvg(sampleStats);
const sourceBar = /<rect x="(\d+)"[^>]*width="(\d+)"[^>]*class="bar-source"/.exec(svg);
assert.ok(sourceBar, "source bar must be rendered");
assert.equal(Number(sourceBar[2]), computeLayout(extractLanguages(sampleStats)).barWidth);
const [source] = extractLanguages(sampleStats);
const layout = computeLayout(extractLanguages(sampleStats));
assert.equal(source.isSource, true);
assert.equal(source.percent, 100);
assert.doesNotMatch(svg, /bar-source/);
// English sits at 100%, so it takes the completed tier and fills the whole bar.
assert.match(svg, new RegExp(`width="${layout.barWidth}" height="8" rx="4" class="bar-done"`));
assert.match(svg, /96% average/);
});
test("generateSvg renders at its natural size so label type is never scaled down", () => {
const { height } = computeLayout(extractLanguages(sampleStats));
assert.match(generateSvg(sampleStats), new RegExp(`<svg [^>]*width="540" height="${height}" viewBox="0 0 540 ${height}"`));
});
test("generateSvg grows the canvas instead of overflowing when languages are added", () => {
const withExtra = generateSvg({ progress: [...sampleStats.progress, language("es", "Spanish", "es-ES", "40%", 4000, 19358)] });
assert.match(withExtra, /viewBox="0 0 680 324"/);
assert.match(withExtra, /viewBox="0 0 540 278"/);
assert.match(withExtra, /🇪🇸 {2}Spanish/);
assert.match(generateSvg(sampleStats), /viewBox="0 0 680 282"/);
assert.match(generateSvg(sampleStats), /viewBox="0 0 540 250"/);
});
test("computeLayout keeps horizontal rows up to eight languages", () => {
const rows = Array.from({ length: 8 }, (_, index) => ({ name: `Language ${index}`, flag: "", percent: 50 }));
assert.equal(computeLayout(rows).mode, "rows");
assert.equal(computeLayout([...rows, { name: "One more", flag: "", percent: 50 }]).mode, "columns");
test("computeLayout keeps horizontal rows at any language count", () => {
const rows = (count) => Array.from({ length: count }, (_, index) => ({ name: `Language ${index}`, flag: "", percent: 50 }));
const nine = computeLayout(rows(9));
const twenty = computeLayout(rows(20));
assert.equal(nine.count, 9);
assert.equal(twenty.height - nine.height, 11 * 28, "each extra language adds exactly one row");
});
test("generateSvg switches to vertical columns beyond eight languages", () => {
test("generateSvg renders many languages as rows without rotating labels", () => {
const many = {
progress: [
language("en", "English", "en-US", "95%", 1, 2),
...Array.from({ length: 12 }, (_, index) => language(`l${index}`, `Language ${index}`, "xx-XX", "50%", 1, 2)),
],
};
const languages = extractLanguages(many);
const layout = computeLayout(languages);
assert.equal(layout.mode, "columns");
assert.equal(layout.count, 13);
const svg = generateSvg(many);
assert.match(svg, new RegExp(`viewBox="0 0 680 ${layout.height}"`));
assert.equal(svg.match(/class="bar-bg"/g).length, 13);
assert.equal(svg.match(/transform="rotate\(-45 /g).length, 13);
assert.match(svg, /class="axis-label">100</);
assert.ok(layout.columnWidth >= 6, "columns must stay visible");
});
test("generateSvg keeps horizontal bars inside the card", () => {
const svg = generateSvg(sampleStats);
for (const [, x, width] of svg.matchAll(/<rect x="(\d+)" y="\d+" width="(\d+)"[^>]*class="bar-bg"/g)) {
assert.ok(Number(x) + Number(width) <= 680 - 24, "bar must not overflow the card padding");
}
});
test("generateSvg keeps vertical columns inside the plot area", () => {
const many = {
progress: Array.from({ length: 20 }, (_, index) => language(`l${index}`, `Language ${index}`, "xx-XX", `${index * 5}%`, index, 100)),
};
const layout = computeLayout(extractLanguages(many));
const svg = generateSvg(many);
for (const [, x, y, width, height] of svg.matchAll(
/<rect x="(\d+)" y="(\d+)" width="(\d+)" height="(\d+)"[^>]*class="bar-(?:bg|high|medium|low|source)"/g,
)) {
assert.ok(Number(x) >= 24, "column must not overflow the left padding");
assert.ok(Number(x) + Number(width) <= 680 - 24, "column must not overflow the right padding");
assert.ok(Number(y) + Number(height) <= layout.baseline, "column must sit on the baseline");
assert.equal(layout.count, 13);
assert.match(svg, new RegExp(`viewBox="0 0 540 ${layout.height}"`));
assert.equal(svg.match(/class="bar-bg"/g).length, 13);
assert.doesNotMatch(svg, /rotate\(/);
});
test("computeLayout sizes the label column to the widest name", () => {
const short = computeLayout([{ name: "Thai", flag: "", percent: 50 }]);
const long = computeLayout([
{ name: "Thai", flag: "", percent: 50 },
{ name: "Portuguese, Brazilian", flag: "", percent: 50 },
]);
assert.ok(long.barX > short.barX, "a longer name must push the bar right");
assert.equal(short.barX + short.barWidth, long.barX + long.barWidth, "bars stay right-aligned against the percentage column");
});
test("computeLayout caps the label column so one long name cannot squeeze out the bar", () => {
const layout = computeLayout([{ name: "A".repeat(200), flag: "🇦🇽", percent: 50 }]);
assert.ok(layout.barWidth > 200, `bar collapsed to ${layout.barWidth}px`);
assert.match(generateSvg({ progress: [language("xx", "A".repeat(200), "ax-AX", "50%")] }), /…/);
});
test("generateSvg keeps bars inside the card", () => {
const svg = generateSvg(sampleStats);
assert.equal(svg.match(/class="bar-bg"/g).length, 5, "one track per language row");
for (const [, x, width] of svg.matchAll(/<rect x="(\d+)" y="\d+" width="(\d+)"[^>]*class="bar-(?:bg|done|progress|low)"/g)) {
assert.ok(Number(x) >= 24, "bar must not overflow the left padding");
assert.ok(Number(x) + Number(width) <= 540 - 24, "bar must not overflow the right padding");
}
});
test("generateSvg colours bars by progress", () => {
test("generateSvg colours bars by progress and always explains the scale", () => {
const svg = generateSvg({
progress: [language("de", "German", "de-DE", "96%"), language("nl", "Dutch", "nl-NL", "70%"), language("sl", "Slovenian", "sl-SI", "12%")],
progress: [language("de", "German", "de-DE", "100%"), language("nl", "Dutch", "nl-NL", "70%"), language("sl", "Slovenian", "sl-SI", "12%")],
});
assert.match(svg, /class="bar-high"/);
assert.match(svg, /class="bar-medium"/);
assert.match(svg, /class="bar-done"/);
assert.match(svg, /class="bar-progress"/);
assert.match(svg, /class="bar-low"/);
// Every tier stays in the legend even when no language currently falls in it.
const complete = generateSvg({ progress: [language("de", "German", "de-DE", "100%")] });
for (const label of ["Complete", "In progress", "Needs work"]) {
assert.match(complete, new RegExp(`>${label}<`));
}
});
test("generateSvg escapes language names and handles empty data", () => {
@@ -148,16 +209,6 @@ test("generateSvg escapes language names and handles empty data", () => {
const empty = generateSvg({ progress: [] });
assert.match(empty, /no translation data/);
assert.match(empty, /viewBox="0 0 540 96"/);
assert.doesNotMatch(empty, /NaN|undefined/);
});
test("computeLayout keeps rotated labels inside the card", () => {
const languages = extractLanguages({
progress: Array.from({ length: 21 }, (_, index) => language(`l${index}`, "Portuguese, Brazilian", "pt-BR", "50%", 1, 2)),
});
const layout = computeLayout(languages);
for (const column of layout.columns) {
assert.ok(column.centerX - column.label.width * Math.SQRT1_2 >= 9, "rotated label must stay inside the card");
}
});
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Verifies that what Crowdin would export for a language matches the catalog in
// Git, and optionally reconciles the difference.
//
// Crowdin's bulk translation import is lossy: it silently skips entries that the
// per-string translation endpoint accepts, so a language can report a healthy
// percentage while real translations are missing. Because untranslated strings
// export as English source text, those gaps come back as a pull request that
// overwrites good translations in Git. The progress percentage cannot detect
// this; only diffing the built export against the catalog can.
//
// Usage:
// CROWDIN_TOKEN=... node scripts/crowdin-verify/verify.mjs es-ES es.json
// CROWDIN_TOKEN=... node scripts/crowdin-verify/verify.mjs es-ES es.json --reconcile
//
// Exits non-zero while any key still differs, so it can gate enabling export for
// a newly seeded language.
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const API = "https://api.crowdin.com/api/v2";
const SOURCE_PATH_SUFFIX = "/client/src/locales/en.json";
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const localesDirectory = path.join(repoRoot, "client/src/locales");
export function flattenMessages(value, prefix = "", output = new Map()) {
for (const [key, child] of Object.entries(value)) {
const messageKey = prefix ? `${prefix}.${key}` : key;
if (typeof child === "string") output.set(messageKey, child);
else if (child && typeof child === "object") flattenMessages(child, messageKey, output);
}
return output;
}
export function differingKeys(desired, exported) {
return [...desired.keys()].filter((key) => exported.get(key) !== desired.get(key));
}
export function parseArguments(argv) {
const positional = argv.filter((argument) => !argument.startsWith("--"));
if (positional.length !== 2) {
throw new Error("Usage: verify.mjs <crowdinLanguageId> <catalogFile> [--reconcile]");
}
return {
languageId: positional[0],
catalogFile: positional[1],
reconcile: argv.includes("--reconcile"),
};
}
function createClient(token, projectId) {
async function request(endpoint, init = {}) {
const response = await fetch(`${API}${endpoint}`, {
...init,
headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
});
const body = await response.text();
if (!response.ok) throw new Error(`${response.status} ${endpoint}: ${body.slice(0, 300)}`);
return body ? JSON.parse(body) : null;
}
return {
request,
async sourceFileId() {
for (let offset = 0; ; offset += 500) {
const page = await request(`/projects/${projectId}/files?limit=500&offset=${offset}`);
const match = page.data.find((entry) => entry.data.path.endsWith(SOURCE_PATH_SUFFIX));
if (match) return match.data.id;
if (page.data.length < 500) throw new Error(`No source file ending in ${SOURCE_PATH_SUFFIX}`);
}
},
async stringIds(fileId) {
const ids = new Map();
for (let offset = 0; ; offset += 500) {
const page = await request(`/projects/${projectId}/strings?fileId=${fileId}&limit=500&offset=${offset}`);
for (const entry of page.data) ids.set(entry.data.identifier, entry.data.id);
if (page.data.length < 500) return ids;
}
},
async exportedCatalog(fileId, languageId) {
const link = await request(`/projects/${projectId}/translations/builds/files/${fileId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetLanguageId: languageId }),
});
const download = await fetch(link.data.url);
if (!download.ok) throw new Error(`Export download failed: ${download.status}`);
return flattenMessages(JSON.parse(await download.text()));
},
async putTranslation(languageId, stringId, text) {
try {
await request(`/projects/${projectId}/translations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stringId, languageId, text }),
});
return;
} catch (error) {
if (!/Duplicate translation/i.test(error.message)) throw error;
}
// The wanted text already exists for this string but a newer translation
// shadows it, so Crowdin rejects the write as a duplicate. Crowdin exports
// the most recent translation, so drop the newer ones to make the wanted
// text current again. Older history is left alone.
const existing = await request(`/projects/${projectId}/translations?stringId=${stringId}&languageId=${languageId}&limit=100`);
const match = existing.data.find((entry) => entry.data.text === text);
if (!match) throw new Error(`Duplicate translation reported for string ${stringId} but no stored translation matches the catalog value`);
const shadowing = existing.data.filter(
(entry) => entry.data.id !== match.data.id && Date.parse(entry.data.createdAt) >= Date.parse(match.data.createdAt),
);
for (const entry of shadowing) {
await request(`/projects/${projectId}/translations/${entry.data.id}`, { method: "DELETE" });
}
},
};
}
async function main() {
const { languageId, catalogFile, reconcile } = parseArguments(process.argv.slice(2));
const token = process.env.CROWDIN_TOKEN;
if (!token) throw new Error("CROWDIN_TOKEN is required");
const projectId = process.env.CROWDIN_PROJECT_ID ?? "912891";
const client = createClient(token, projectId);
const fileId = await client.sourceFileId();
const desired = flattenMessages(JSON.parse(await readFile(path.join(localesDirectory, catalogFile), "utf8")));
// Crowdin serves a briefly cached export build, so a rebuild taken immediately
// after a write can still show the old content. Retry before believing a
// non-zero result.
async function differencesNow(attempts = 1) {
let missing = [];
for (let attempt = 1; attempt <= attempts; attempt += 1) {
missing = differingKeys(desired, await client.exportedCatalog(fileId, languageId));
if (missing.length === 0) return missing;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 5000));
}
return missing;
}
let missing = await differencesNow();
console.log(`${languageId}: ${missing.length} of ${desired.size} keys differ from ${catalogFile}`);
if (missing.length > 0 && reconcile) {
const ids = await client.stringIds(fileId);
let pushed = 0;
for (const key of missing) {
const stringId = ids.get(key);
if (!stringId) {
console.log(` skipped ${key}: not present in Crowdin`);
continue;
}
await client.putTranslation(languageId, stringId, desired.get(key));
pushed += 1;
}
console.log(` pushed ${pushed} translations through the per-string endpoint`);
missing = await differencesNow(4);
console.log(`${languageId}: ${missing.length} keys still differ after reconcile`);
}
for (const key of missing.slice(0, 20)) console.log(` ~ ${key}`);
if (missing.length > 20) console.log(` ... and ${missing.length - 20} more`);
if (missing.length > 0) {
console.error(`\n${languageId} is NOT safe to export: Crowdin would overwrite ${missing.length} keys in ${catalogFile}.`);
process.exitCode = 1;
return;
}
console.log(`\n${languageId} matches ${catalogFile} exactly; exporting cannot change it.`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
await main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}