mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||||
@@ -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"
|
|
||||||
@@ -135,20 +135,21 @@ For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contri
|
|||||||
|
|
||||||
## Translations
|
## 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
|
## Community Growth
|
||||||
|
|
||||||
[](https://github.com/bookorbit/bookorbit/stargazers)
|
[](https://github.com/bookorbit/bookorbit/stargazers)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -31,8 +31,8 @@
|
|||||||
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
|
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
|
||||||
"format": "pnpm --filter server format && pnpm --filter client format",
|
"format": "pnpm --filter server format && pnpm --filter client format",
|
||||||
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
|
"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": "pnpm run test:charts && pnpm run test:server && pnpm run test:client",
|
||||||
"test:star-history": "node --test scripts/star-history/generate.test.mjs",
|
"test:charts": "node --test scripts/star-history/generate.test.mjs scripts/crowdin-stats/generate.test.mjs",
|
||||||
"test:server": "pnpm --filter server test",
|
"test:server": "pnpm --filter server test",
|
||||||
"test:client": "pnpm --filter client test:unit --run",
|
"test:client": "pnpm --filter client test:unit --run",
|
||||||
"e2e:run": "node ./scripts/e2e/run-suite.mjs",
|
"e2e:run": "node ./scripts/e2e/run-suite.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, ">").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 `
|
||||||
|
<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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 & 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ const MAX_RESPONSE_BYTES = 2_000_000;
|
|||||||
|
|
||||||
const WIDTH = 680;
|
const WIDTH = 680;
|
||||||
const HEIGHT = 380;
|
const HEIGHT = 380;
|
||||||
|
const RENDERED_WIDTH = 580;
|
||||||
const PANEL_X = 18;
|
const PANEL_X = 18;
|
||||||
const PANEL_Y = 58;
|
const PANEL_Y = 58;
|
||||||
const PANEL_WIDTH = 644;
|
const PANEL_WIDTH = 644;
|
||||||
@@ -559,7 +560,7 @@ export function renderSvg(series) {
|
|||||||
const repositoryLabel = xmlEscape(repository);
|
const repositoryLabel = xmlEscape(repository);
|
||||||
const updatedAt = xmlEscape(series.updatedAt || latest.date);
|
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>
|
<title id="title">${repositoryLabel} GitHub stars over time</title>
|
||||||
<desc id="description">${repositoryLabel} reached ${formatCount(latest.stars)} GitHub stars on ${updatedAt}.</desc>
|
<desc id="description">${repositoryLabel} reached ${formatCount(latest.stars)} GitHub stars on ${updatedAt}.</desc>
|
||||||
<defs>
|
<defs>
|
||||||
|
|||||||
Reference in New Issue
Block a user