chore(i18n): automate Crowdin translation progress SVG chart and consolidate repo chart workflows

Add automated Crowdin translation progress SVG chart generator, test, and workflow. Consolidate star history and translation stats generation into unified repo-charts workflow, and update README links.
This commit is contained in:
neon
2026-07-27 18:17:33 +00:00
parent 5f47145a64
commit 3aff8f521b
12 changed files with 771 additions and 159 deletions
+141
View File
@@ -0,0 +1,141 @@
name: Repo Charts
on:
workflow_dispatch:
schedule:
- cron: "37 6 * * *"
push:
branches: [main]
paths:
- ".github/workflows/repo-charts.yml"
- "scripts/star-history/**"
- "scripts/crowdin-stats/**"
permissions: {}
concurrency:
group: repo-charts
cancel-in-progress: false
env:
CHARTS_BRANCH: generated-charts
jobs:
publish:
name: Refresh charts
if: github.repository == 'bookorbit/bookorbit'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 24
- name: Test generators
run: node --test scripts/star-history/generate.test.mjs scripts/crowdin-stats/generate.test.mjs
- name: Restore published charts
env:
SERIES_PATH: ${{ runner.temp }}/star-history.json
run: |
restore_series() {
for branch in "$CHARTS_BRANCH" star-history; do
if git ls-remote --exit-code --heads origin "$branch" >/dev/null &&
git fetch --depth 1 origin "+refs/heads/$branch:refs/remotes/origin/$branch" &&
git show "origin/$branch:star-history.json" > "$SERIES_PATH" 2>/dev/null; then
echo "Restored star history series from $branch"
return 0
fi
done
echo "No published series found, using the bootstrap snapshot"
cp scripts/star-history/bootstrap.json "$SERIES_PATH"
}
restore_series
- name: Generate star history chart
id: star-history
continue-on-error: true
env:
GITHUB_TOKEN: ${{ github.token }}
STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }}
SERIES_PATH: ${{ runner.temp }}/star-history.json
SVG_PATH: ${{ runner.temp }}/star-history.svg
run: |
node scripts/star-history/generate.mjs \
--repo "$GITHUB_REPOSITORY" \
--series "$SERIES_PATH" \
--bootstrap scripts/star-history/bootstrap.json \
--out "$SVG_PATH"
- name: Generate translation progress chart
id: translation-progress
continue-on-error: true
env:
SVG_PATH: ${{ runner.temp }}/translation-progress.svg
run: |
node scripts/crowdin-stats/generate.mjs --out "$SVG_PATH"
- name: Publish charts
env:
SERIES_PATH: ${{ runner.temp }}/star-history.json
STAR_SVG_PATH: ${{ runner.temp }}/star-history.svg
TRANSLATION_SVG_PATH: ${{ runner.temp }}/translation-progress.svg
GIT_INDEX_FILE: ${{ runner.temp }}/charts.index
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
parent=$(git rev-parse --verify "refs/remotes/origin/$CHARTS_BRANCH" 2>/dev/null || true)
rm -f "$GIT_INDEX_FILE"
if [ -n "$parent" ]; then
git read-tree "$parent"
fi
stage_chart() {
if [ ! -s "$2" ]; then
echo "Skipping $1, keeping the published copy"
return 0
fi
blob=$(git hash-object -w "$2")
git update-index --add --cacheinfo "100644,$blob,$1"
}
stage_chart star-history.svg "$STAR_SVG_PATH"
stage_chart star-history.json "$SERIES_PATH"
stage_chart translation-progress.svg "$TRANSLATION_SVG_PATH"
tree=$(git write-tree)
if [ -n "$parent" ]; then
if [ "$tree" = "$(git rev-parse "$parent^{tree}")" ]; then
echo "Charts are unchanged"
exit 0
fi
commit=$(printf '%s\n' "chore: update repository charts" | \
TZ=UTC git commit-tree "$tree" -p "$parent")
else
commit=$(printf '%s\n' "chore: initialize repository charts" | \
TZ=UTC git commit-tree "$tree")
fi
git push origin "$commit:refs/heads/$CHARTS_BRANCH"
- name: Fail on generator errors
if: steps.star-history.outcome == 'failure' || steps.translation-progress.outcome == 'failure'
env:
STAR_OUTCOME: ${{ steps.star-history.outcome }}
TRANSLATION_OUTCOME: ${{ steps.translation-progress.outcome }}
run: |
echo "star history: $STAR_OUTCOME"
echo "translation progress: $TRANSLATION_OUTCOME"
exit 1
-92
View File
@@ -1,92 +0,0 @@
name: Star History
on:
workflow_dispatch:
schedule:
- cron: "37 6 * * *"
push:
branches: [main]
paths:
- ".github/workflows/star-history.yml"
- "scripts/star-history/**"
permissions: {}
concurrency:
group: star-history
cancel-in-progress: false
jobs:
publish:
name: Refresh chart
if: github.repository == 'bookorbit/bookorbit'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 24
- name: Test generator
run: node --test scripts/star-history/generate.test.mjs
- name: Restore accumulated series
env:
SERIES_PATH: ${{ runner.temp }}/star-history.json
run: |
if git ls-remote --exit-code --heads origin star-history >/dev/null; then
git fetch --depth 1 origin \
'+refs/heads/star-history:refs/remotes/origin/star-history'
git show origin/star-history:star-history.json > "$SERIES_PATH"
else
cp scripts/star-history/bootstrap.json "$SERIES_PATH"
fi
- name: Generate chart
env:
GITHUB_TOKEN: ${{ github.token }}
STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }}
SERIES_PATH: ${{ runner.temp }}/star-history.json
SVG_PATH: ${{ runner.temp }}/star-history.svg
run: |
node scripts/star-history/generate.mjs \
--repo "$GITHUB_REPOSITORY" \
--series "$SERIES_PATH" \
--bootstrap scripts/star-history/bootstrap.json \
--out "$SVG_PATH"
- name: Publish chart and data
env:
SERIES_PATH: ${{ runner.temp }}/star-history.json
SVG_PATH: ${{ runner.temp }}/star-history.svg
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
json_blob=$(git hash-object -w "$SERIES_PATH")
svg_blob=$(git hash-object -w "$SVG_PATH")
tree=$(printf '100644 blob %s\tstar-history.json\n100644 blob %s\tstar-history.svg\n' \
"$json_blob" "$svg_blob" | git mktree)
parent=$(git rev-parse --verify refs/remotes/origin/star-history 2>/dev/null || true)
if [ -n "$parent" ]; then
previous_tree=$(git rev-parse "$parent^{tree}")
if [ "$tree" = "$previous_tree" ]; then
echo "Star history is unchanged"
exit 0
fi
commit=$(printf '%s\n' "chore: update star history" | \
TZ=UTC git commit-tree "$tree" -p "$parent")
else
commit=$(printf '%s\n' "chore: initialize star history" | \
TZ=UTC git commit-tree "$tree")
fi
git push origin "$commit:refs/heads/star-history"
+8 -7
View File
@@ -135,20 +135,21 @@ For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contri
## Translations
BookOrbit is available in English, German, Dutch, Brazilian Portuguese, and Slovenian. Translations are managed through [Crowdin](https://crowdin.com/project/bookorbit).
BookOrbit is translated by the community on [Crowdin](https://crowdin.com/project/bookorbit). English is the source language; every other catalog is exported from Crowdin and merged through automated pull requests.
[![German translation](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-17791545-912891.json&query=%24.progress.0.data.translationProgress&label=German&logo=crowdin&color=blue&style=flat-square)](https://crowdin.com/project/bookorbit)
[![Dutch translation](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-17791545-912891.json&query=%24.progress.2.data.translationProgress&label=Dutch&logo=crowdin&color=blue&style=flat-square)](https://crowdin.com/project/bookorbit)
[![Brazilian Portuguese translation](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-17791545-912891.json&query=%24.progress.3.data.translationProgress&label=Portuguese%20%28Brazil%29&logo=crowdin&color=blue&style=flat-square)](https://crowdin.com/project/bookorbit)
[![Slovenian translation](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-17791545-912891.json&query=%24.progress.4.data.translationProgress&label=Slovenian&logo=crowdin&color=blue&style=flat-square)](https://crowdin.com/project/bookorbit)
[![BookOrbit translation progress](https://raw.githubusercontent.com/bookorbit/bookorbit/generated-charts/translation-progress.svg)](https://crowdin.com/project/bookorbit)
[Help improve BookOrbit translations on Crowdin](https://crowdin.com/project/bookorbit).
The chart refreshes daily from Crowdin.
- **Improve an existing language:** translate or review strings on **[Crowdin](https://crowdin.com/project/bookorbit)**. No local setup or Git knowledge needed.
- **Request a new language:** open a [Discussion](https://github.com/bookorbit/bookorbit/discussions) and it will be enabled on Crowdin.
- **Found wrong English wording?** That one belongs in the code, so open an [issue](https://github.com/bookorbit/bookorbit/issues/new?template=bug_report.yml) instead of translating around it.
---
## Community Growth
[![BookOrbit GitHub star history](https://raw.githubusercontent.com/bookorbit/bookorbit/star-history/star-history.svg)](https://github.com/bookorbit/bookorbit/stargazers)
[![BookOrbit GitHub star history](https://raw.githubusercontent.com/bookorbit/bookorbit/generated-charts/star-history.svg)](https://github.com/bookorbit/bookorbit/stargazers)
---
+81
View File
@@ -0,0 +1,81 @@
# Repository Charts
BookOrbit renders its README charts in the repository instead of depending on
external chart or badge services.
## Storage
The `generated-charts` branch contains the generated files:
- `star-history.json`: validated daily aggregate star counts and source metadata
- `star-history.svg`: the self-contained star history chart
- `translation-progress.svg`: the self-contained Crowdin translation chart
The workflow creates normal commits on this branch. It does not force-push, so
previous chart data remains recoverable without adding automated commits to
`main`.
The bootstrap snapshot at `scripts/star-history/bootstrap.json` contains the
initial exact aggregate history. It is used only when no published series
exists. Raw stargazer records and GitHub usernames are never stored.
## Refresh behavior
The `Repo Charts` workflow runs daily and can also be dispatched manually. It
restores the published series, regenerates both charts, and publishes the
changed files in one branch commit. Nothing is pushed when both charts are
byte-identical to the published copies.
Each generator runs independently. If one fails, the other still publishes and
the failed chart keeps its previously published copy, while the workflow run is
marked as failed.
### Star history
The star history generator obtains the current repository star count, replaces
any existing point for the current UTC date, and renders the SVG.
The built-in workflow token is sufficient for daily snapshots. An optional
`STAR_HISTORY_TOKEN` repository secret belonging to a collaborator enables
exact-history refreshes from GitHub's timestamped stargazer endpoint. Exact
refreshes replace the aggregate curve and correct any drift from previous
snapshots. If the endpoint is unavailable, the workflow retains the stored
history and falls back to the current count.
The generator refuses malformed series, data for a different repository,
future points, invalid API responses, and exact histories beyond GitHub's
pagination limit.
### Translation progress
The translation generator reads public Crowdin project statistics and renders
every language the project exposes, so no code change is needed when a language
is added. English is pinned first at 100% as the source language and is
excluded from the average. Up to eight languages render as horizontal bars;
beyond that the chart switches to a vertical bar chart with a fixed height.
## Local commands
Run the focused test suites:
```bash
pnpm run test:charts
```
Generate the star history chart from a stored series and the current count:
```bash
GITHUB_TOKEN=... node scripts/star-history/generate.mjs \
--repo bookorbit/bookorbit \
--series star-history.json \
--bootstrap scripts/star-history/bootstrap.json \
--out star-history.svg
```
Set `STAR_HISTORY_TOKEN` as well to attempt a complete exact-history refresh.
Generate the translation chart from live Crowdin statistics:
```bash
node scripts/crowdin-stats/generate.mjs --out translation-progress.svg
```
-57
View File
@@ -1,57 +0,0 @@
# Star History Chart
BookOrbit renders its README star-history chart in the repository instead of
depending on an external chart service.
## Storage
The `star-history` branch contains two generated files:
- `star-history.json`: validated daily aggregate counts and source metadata
- `star-history.svg`: the self-contained README chart
The workflow creates normal commits on this branch. It does not force-push, so
previous chart data remains recoverable without adding automated commits to
`main`.
The bootstrap snapshot at `scripts/star-history/bootstrap.json` contains the
initial exact aggregate history. It is used only when the publication branch
does not exist. Raw stargazer records and GitHub usernames are never stored.
## Refresh behavior
The `Star History` workflow runs daily and can also be dispatched manually.
It restores the stored series, obtains the current repository star count,
replaces any existing point for the current UTC date, renders the SVG, and
publishes the JSON and SVG in one branch commit.
The built-in workflow token is sufficient for daily snapshots. An optional
`STAR_HISTORY_TOKEN` repository secret belonging to a collaborator enables
exact-history refreshes from GitHub's timestamped stargazer endpoint. Exact
refreshes replace the aggregate curve and correct any drift from previous
snapshots. If the endpoint is unavailable, the workflow retains the stored
history and falls back to the current count.
The generator refuses malformed series, data for a different repository,
future points, invalid API responses, and exact histories beyond GitHub's
pagination limit. A failed run publishes nothing.
## Local commands
Run the focused test suite:
```bash
pnpm run test:star-history
```
Generate from a stored series and the current count:
```bash
GITHUB_TOKEN=... node scripts/star-history/generate.mjs \
--repo bookorbit/bookorbit \
--series star-history.json \
--bootstrap scripts/star-history/bootstrap.json \
--out star-history.svg
```
Set `STAR_HISTORY_TOKEN` as well to attempt a complete exact-history refresh.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 KiB

+2 -2
View File
@@ -31,8 +31,8 @@
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
"format": "pnpm --filter server format && pnpm --filter client format",
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
"test": "pnpm run test:star-history && pnpm run test:server && pnpm run test:client",
"test:star-history": "node --test scripts/star-history/generate.test.mjs",
"test": "pnpm run test:charts && pnpm run test:server && pnpm run test:client",
"test:charts": "node --test scripts/star-history/generate.test.mjs scripts/crowdin-stats/generate.test.mjs",
"test:server": "pnpm --filter server test",
"test:client": "pnpm --filter client test:unit --run",
"e2e:run": "node ./scripts/e2e/run-suite.mjs",
+374
View File
@@ -0,0 +1,374 @@
#!/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 `
<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>`;
}
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>`
: "";
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>`;
}).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 `<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">
<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; }
.grid { stroke: #21262d; stroke-width: 1; }
.bar-bg { fill: #21262d; }
.bar-source { fill: #58a6ff; }
.bar-high { fill: #2ea44f; }
.bar-medium { fill: #d29922; }
.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>
<line x1="${PADDING_X}" y1="${DIVIDER_Y}" x2="${WIDTH - PADDING_X}" y2="${DIVIDER_Y}" class="grid" />
${renderBody(languages, layout)}
</svg>
`;
}
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;
});
}
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { computeLayout, extractLanguages, 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 } } };
}
const sampleStats = {
progress: [
language("de", "German", "de-DE", "96%", 18675, 19358),
language("en", "English", "en-US", "95%", 18465, 19358),
language("nl", "Dutch", "nl-NL", "95%", 18535, 19358),
language("pt-BR", "Portuguese, Brazilian", "pt-BR", "97%", 18868, 19358),
language("sl", "Slovenian", "sl-SI", "95%", 18554, 19358),
],
};
test("parseArguments parses flags", () => {
const options = parseArguments(["--out", "custom.svg", "--url", "https://example.com/stats.json"]);
assert.equal(options.outputPath, "custom.svg");
assert.equal(options.statsUrl, "https://example.com/stats.json");
});
test("parseArguments rejects unknown flags and missing values", () => {
assert.throws(() => parseArguments(["--nope", "x"]), /Unknown argument/);
assert.throws(() => parseArguments(["--out"]), /Missing value for --out/);
});
test("extractLanguages pins the source language at 100% and sorts targets by progress", () => {
const languages = extractLanguages(sampleStats);
assert.deepEqual(
languages.map((item) => item.id),
["en", "pt-BR", "de", "nl", "sl"],
);
assert.deepEqual(languages[0].percent, 100);
assert.equal(languages[0].isSource, true);
assert.equal(languages[1].isSource, false);
});
test("extractLanguages derives flags from the locale and defaults missing progress to zero", () => {
const [german] = extractLanguages({ progress: [language("de", "German", "de-DE", "96%")] });
assert.equal(german.flag, "🇩🇪");
const [brazilian] = extractLanguages({ progress: [language("pt-BR", "Portuguese, Brazilian", "pt-BR", "97%")] });
assert.equal(brazilian.name, "Portuguese");
assert.equal(brazilian.flag, "🇧🇷");
const [unflagged] = extractLanguages({ progress: [language("pt-BR", "Portuguese, Brazilian", "pt", "97%")] });
assert.equal(unflagged.name, "Portuguese, Brazilian");
const [unknown] = extractLanguages({ progress: [{ data: { languageId: "eo" } }] });
assert.equal(unknown.name, "eo");
assert.equal(unknown.flag, "");
assert.equal(unknown.percent, 0);
});
test("generateSvg renders every target language with its percentage", () => {
const svg = generateSvg(sampleStats);
assert.match(svg, /^<svg /);
assert.match(svg, /🇩🇪 {2}German/);
assert.match(svg, /🇳🇱 {2}Dutch/);
assert.match(svg, /🇧🇷 {2}Portuguese</);
assert.match(svg, /🇸🇮 {2}Slovenian/);
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", () => {
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);
assert.match(svg, /96% average/);
});
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, /🇪🇸 {2}Spanish/);
assert.match(generateSvg(sampleStats), /viewBox="0 0 680 282"/);
});
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("generateSvg switches to vertical columns beyond eight languages", () => {
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");
}
});
test("generateSvg colours bars by progress", () => {
const svg = generateSvg({
progress: [language("de", "German", "de-DE", "96%"), 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-low"/);
});
test("generateSvg escapes language names and handles empty data", () => {
const svg = generateSvg({ progress: [language("xx", "A & B <Test>", "xx-XX", "10%")] });
assert.match(svg, /A &amp; B &lt;Test&gt;/);
const empty = generateSvg({ progress: [] });
assert.match(empty, /no translation data/);
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");
}
});
+2 -1
View File
@@ -13,6 +13,7 @@ const MAX_RESPONSE_BYTES = 2_000_000;
const WIDTH = 680;
const HEIGHT = 380;
const RENDERED_WIDTH = 580;
const PANEL_X = 18;
const PANEL_Y = 58;
const PANEL_WIDTH = 644;
@@ -559,7 +560,7 @@ export function renderSvg(series) {
const repositoryLabel = xmlEscape(repository);
const updatedAt = xmlEscape(series.updatedAt || latest.date);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-labelledby="title description">
return `<svg xmlns="http://www.w3.org/2000/svg" width="${RENDERED_WIDTH}" height="${Math.round((HEIGHT * RENDERED_WIDTH) / WIDTH)}" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-labelledby="title description">
<title id="title">${repositoryLabel} GitHub stars over time</title>
<desc id="description">${repositoryLabel} reached ${formatCount(latest.stars)} GitHub stars on ${updatedAt}.</desc>
<defs>