mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
chore(translations): add catalog verification script and update localization docs
This commit is contained in:
+119
-107
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user