diff --git a/.github/workflows/repo-charts.yml b/.github/workflows/repo-charts.yml
new file mode 100644
index 000000000..08e9ddc76
--- /dev/null
+++ b/.github/workflows/repo-charts.yml
@@ -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
diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml
deleted file mode 100644
index f9c369d22..000000000
--- a/.github/workflows/star-history.yml
+++ /dev/null
@@ -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"
diff --git a/README.md b/README.md
index 43b1b07bf..692f926ca 100644
--- a/README.md
+++ b/README.md
@@ -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.
-[](https://crowdin.com/project/bookorbit)
-[](https://crowdin.com/project/bookorbit)
-[](https://crowdin.com/project/bookorbit)
-[](https://crowdin.com/project/bookorbit)
+[](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
-[](https://github.com/bookorbit/bookorbit/stargazers)
+[](https://github.com/bookorbit/bookorbit/stargazers)
---
diff --git a/docs/REPO_CHARTS.md b/docs/REPO_CHARTS.md
new file mode 100644
index 000000000..a4dfcdd13
--- /dev/null
+++ b/docs/REPO_CHARTS.md
@@ -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
+```
diff --git a/docs/STAR_HISTORY.md b/docs/STAR_HISTORY.md
deleted file mode 100644
index fe360d60b..000000000
--- a/docs/STAR_HISTORY.md
+++ /dev/null
@@ -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.
diff --git a/docs/images/koreader-details.png b/docs/images/koreader-details.png
deleted file mode 100644
index ca183f182..000000000
Binary files a/docs/images/koreader-details.png and /dev/null differ
diff --git a/docs/images/koreader-grid.png b/docs/images/koreader-grid.png
deleted file mode 100644
index b30e6c39f..000000000
Binary files a/docs/images/koreader-grid.png and /dev/null differ
diff --git a/docs/images/koreader-home.png b/docs/images/koreader-home.png
deleted file mode 100644
index c57f1d106..000000000
Binary files a/docs/images/koreader-home.png and /dev/null differ
diff --git a/package.json b/package.json
index e2cb99858..a376b78b1 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/crowdin-stats/generate.mjs b/scripts/crowdin-stats/generate.mjs
new file mode 100644
index 000000000..503305312
--- /dev/null
+++ b/scripts/crowdin-stats/generate.mjs
@@ -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, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+function flagFromLocale(locale) {
+ const region = /[-_]([A-Za-z]{2})$/.exec(locale || "")?.[1];
+ if (!region) return "";
+
+ return String.fromCodePoint(...[...region.toUpperCase()].map((letter) => letter.codePointAt(0) + 0x1f1a5));
+}
+
+function displayFor(languageId, apiLanguage) {
+ const flag = flagFromLocale(apiLanguage?.locale);
+ const name = apiLanguage?.name || languageId;
+
+ return {
+ name: flag ? name.split(",")[0].trim() : name,
+ flag,
+ };
+}
+
+function toPercent(value) {
+ const parsed = Number.parseInt(String(value).replace("%", ""), 10);
+ if (!Number.isFinite(parsed)) return 0;
+ return Math.min(100, Math.max(0, parsed));
+}
+
+export function extractLanguages(statsData) {
+ const targets = [];
+ let source;
+
+ for (const entry of statsData?.progress ?? []) {
+ const data = entry?.data;
+ const languageId = data?.languageId;
+ if (!languageId) continue;
+
+ const display = displayFor(languageId, data.language);
+
+ if (languageId === SOURCE_LANGUAGE_ID) {
+ source = { id: languageId, ...display, percent: 100, isSource: true, translatedWords: 0, totalWords: 0 };
+ continue;
+ }
+
+ targets.push({
+ id: languageId,
+ ...display,
+ percent: toPercent(data.translationProgress),
+ isSource: false,
+ translatedWords: data.words?.translated || 0,
+ totalWords: data.words?.total || 0,
+ });
+ }
+
+ targets.sort((a, b) => b.percent - a.percent || a.name.localeCompare(b.name, "en"));
+
+ return source ? [source, ...targets] : targets;
+}
+
+function averagePercent(languages) {
+ const targets = languages.filter((language) => !language.isSource);
+ if (targets.length === 0) return 0;
+
+ const totalWords = targets.reduce((sum, language) => sum + language.totalWords, 0);
+ if (totalWords > 0) {
+ const translatedWords = targets.reduce((sum, language) => sum + language.translatedWords, 0);
+ return Math.round((translatedWords / totalWords) * 100);
+ }
+
+ return Math.round(targets.reduce((sum, language) => sum + language.percent, 0) / targets.length);
+}
+
+function truncateToWidth(name, availableWidth, charWidth) {
+ const maxCharacters = Math.floor(availableWidth / charWidth);
+ if (maxCharacters <= 1 || name.length <= maxCharacters) return name;
+ return `${name.slice(0, maxCharacters - 1).trimEnd()}โฆ`;
+}
+
+function labelFor(language, availableWidth, charWidth) {
+ const textWidth = availableWidth - (language.flag ? FLAG_WIDTH : 0);
+ const name = truncateToWidth(language.name, textWidth, charWidth);
+
+ return {
+ text: language.flag ? `${language.flag} ${name}` : name,
+ width: (language.flag ? FLAG_WIDTH : 0) + name.length * charWidth,
+ };
+}
+
+export function computeLayout(languages) {
+ const count = languages.length;
+
+ if (count <= MAX_HORIZONTAL_ROWS) {
+ return {
+ mode: "rows",
+ count,
+ barWidth: WIDTH - PADDING_X * 2 - ROW_LABEL_WIDTH - ROW_PERCENT_GAP - ROW_PERCENT_WIDTH,
+ height: ROW_BASELINE + Math.max(0, count - 1) * ROW_HEIGHT + ROW_BOTTOM_PADDING,
+ };
+ }
+
+ const plotLeft = PADDING_X + AXIS_WIDTH;
+ const slotWidth = (WIDTH - PADDING_X - plotLeft) / count;
+ const columnWidth = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(slotWidth * COLUMN_WIDTH_RATIO)));
+
+ const columns = languages.map((language, index) => {
+ const centerX = Math.round(plotLeft + slotWidth * (index + 0.5));
+ const spaceToLeftEdge = (centerX - COLUMN_LABEL_LEFT_BOUND) / ROTATED_LABEL_SCALE;
+ const label = labelFor(language, Math.min(COLUMN_LABEL_MAX_WIDTH, spaceToLeftEdge), AXIS_CHAR_WIDTH);
+ return { centerX, label };
+ });
+
+ const labelWidth = Math.max(0, ...columns.map((column) => column.label.width));
+
+ return {
+ mode: "columns",
+ count,
+ plotLeft,
+ slotWidth,
+ columnWidth,
+ columns,
+ baseline: PLOT_TOP + PLOT_HEIGHT,
+ showPercentages: slotWidth >= COLUMN_PERCENT_MIN_SLOT,
+ height: PLOT_TOP + PLOT_HEIGHT + COLUMN_LABEL_GAP + Math.round(labelWidth * ROTATED_LABEL_SCALE) + COLUMN_BOTTOM_PADDING,
+ };
+}
+
+function barClass(language) {
+ if (language.isSource) return "bar-source";
+ if (language.percent >= 90) return "bar-high";
+ if (language.percent >= 60) return "bar-medium";
+ return "bar-low";
+}
+
+function renderRow(language, index, layout) {
+ const baseline = ROW_BASELINE + index * ROW_HEIGHT;
+ const barX = PADDING_X + ROW_LABEL_WIDTH;
+ const fillWidth = Math.round((language.percent / 100) * layout.barWidth);
+ const label = labelFor(language, ROW_LABEL_WIDTH - ROW_LABEL_GAP, LABEL_CHAR_WIDTH);
+
+ return `
+ ${escapeXml(label.text)}
+
+
+ ${language.percent}%`;
+}
+
+function renderColumn(language, index, layout) {
+ const { centerX, label } = layout.columns[index];
+ const x = Math.round(centerX - layout.columnWidth / 2);
+ const fillHeight = Math.round((language.percent / 100) * PLOT_HEIGHT);
+ const labelY = layout.baseline + COLUMN_LABEL_GAP;
+ const percentLabel = layout.showPercentages
+ ? `
+ ${language.percent}%`
+ : "";
+
+ return `
+
+ ${percentLabel}
+ ${escapeXml(label.text)}`;
+}
+
+function renderAxis(layout) {
+ return AXIS_TICKS.map((tick) => {
+ const y = layout.baseline - Math.round((tick / 100) * PLOT_HEIGHT);
+ return `
+
+ ${tick}`;
+ }).join("");
+}
+
+function renderBody(languages, layout) {
+ if (layout.mode === "rows") {
+ return languages.map((language, index) => renderRow(language, index, layout)).join("");
+ }
+
+ return renderAxis(layout) + languages.map((language, index) => renderColumn(language, index, layout)).join("");
+}
+
+export function generateSvg(statsData) {
+ const languages = extractLanguages(statsData);
+ const layout = computeLayout(languages);
+ const targetCount = languages.filter((language) => !language.isSource).length;
+ const summary =
+ targetCount > 0
+ ? `Crowdin ยท ${targetCount} target ${targetCount === 1 ? "language" : "languages"} ยท ${averagePercent(languages)}% average`
+ : "Crowdin ยท no translation data";
+
+ return `
+`;
+}
+
+async function writeFileAtomically(path, contents) {
+ const temporaryPath = `${path}.${process.pid}.tmp`;
+ try {
+ await writeFile(temporaryPath, contents, "utf8");
+ await rename(temporaryPath, path);
+ } catch (error) {
+ await unlink(temporaryPath).catch(() => {});
+ throw error;
+ }
+}
+
+export async function run(argv = process.argv.slice(2)) {
+ const options = parseArguments(argv);
+ const stats = await fetchCrowdinStats(options.statsUrl);
+ const svg = generateSvg(stats);
+ const resolvedPath = resolve(process.cwd(), options.outputPath);
+ await writeFileAtomically(resolvedPath, svg);
+
+ console.log(`Wrote ${resolvedPath} with ${extractLanguages(stats).length} languages`);
+}
+
+const entryPath = process.argv[1] ? resolve(process.argv[1]) : undefined;
+if (entryPath === fileURLToPath(import.meta.url)) {
+ run().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/crowdin-stats/generate.test.mjs b/scripts/crowdin-stats/generate.test.mjs
new file mode 100644
index 000000000..4e575ac2a
--- /dev/null
+++ b/scripts/crowdin-stats/generate.test.mjs
@@ -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, /^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 = /]*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(/]*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(
+ /]*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 ", "xx-XX", "10%")] });
+ assert.match(svg, /A & B <Test>/);
+
+ 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");
+ }
+});
diff --git a/scripts/star-history/generate.mjs b/scripts/star-history/generate.mjs
index b41ef5c35..574f94628 100644
--- a/scripts/star-history/generate.mjs
+++ b/scripts/star-history/generate.mjs
@@ -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 `