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. -[![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) --- 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 ` + BookOrbit translation progress + + + + Translation Progress + ${escapeXml(summary)} + + +${renderBody(languages, layout)} + +`; +} + +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%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= 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 ` + return ` ${repositoryLabel} GitHub stars over time ${repositoryLabel} reached ${formatCount(latest.stars)} GitHub stars on ${updatedAt}.