-
-
- {{ t('nav.preferences.preferenceTips') }}
-
-
-
-
- {{ t('nav.preferences.language') }}
-
- {{ t('nav.preferences.languageTips') }}
-
-
-
-
- {{ t('nav.preferences.colorScheme') }}
- v && prefTheme(v)">
-
-
- {{ opt.label }}
-
-
-
-
-
-
- {{ t('nav.preferences.ipSourcesToCheck') }}
- v && prefipCards(Number(v))">
-
- {{ num }}
-
-
- {{ t('nav.preferences.ipSourcesToCheckTips') }}
-
-
-
-
- {{ t('nav.preferences.ipDB') }}
-
- {{ t('nav.preferences.ipDBTips') }}
-
-
-
+
+
+
+
+
+
+ {{ t('nav.preferences.title') }}
+
+
+
+
+
+
+
+
+ {{ t('nav.preferences.preferenceTips') }}
+
+
+
+
+ {{ t('nav.preferences.language') }}
+
+ {{ t('nav.preferences.languageTips') }}
+
+
+
+
+ {{ t('nav.preferences.colorScheme') }}
+ v && prefTheme(v)">
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+ {{ t('nav.preferences.ipSourcesToCheck') }}
+ v && prefipCards(Number(v))">
+
+ {{ num }}
+
+
+ {{ t('nav.preferences.ipSourcesToCheckTips') }}
+
+
+
+
+ {{ t('nav.preferences.ipDB') }}
+
+ {{ t('nav.preferences.ipDBTips') }}
+
+
+
-
- {{ t('nav.preferences.autoRun') }}
-
-
prefAutoRun('autoRunConnectivity', v)" />
-
- prefAutoRun('autoRunWebRTC', v)" />
-
- prefAutoRun('autoRunDnsLeak', v)" />
-
- {{ t('nav.preferences.autoRunTips') }}
-
-
-
-
- {{ t('nav.preferences.appSettings') }}
-
-
-
-
-
- {{ t('nav.preferences.ipHistory') }}
-
-
-
-
+
+ {{ t('nav.preferences.appSettings') }}
+
+
+
+
+
+ {{ t('nav.preferences.ipHistory') }}
+
+
+
+
-
-
-
-
- {{ ipHistoryDaysDraft }}
-
-
-
{ if (v?.[0] != null) ipHistoryDaysDraft = v[0]; }"
- @value-commit="(v) => prefIpHistoryDays(v?.[0])" />
-
-
- {{ t('nav.preferences.ipHistoryTips') }}
-
+
+
+
+
+ {{ ipHistoryDaysDraft }}
+
+
+
{ if (v?.[0] != null) ipHistoryDaysDraft = v[0]; }"
+ @value-commit="(v) => prefIpHistoryDays(v?.[0])" />
-
-
+
+ {{ t('nav.preferences.ipHistoryTips') }}
+
+
+
+
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index c7398c0a1..0c4b71e32 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -342,7 +342,7 @@
"ipDB": "IP Geolocation Database",
"ipDBTips": "You can select the default IP geolocation source to use. If the selected source is unavailable, the system will use the subsequent sources in order.",
"language": "Language Setting",
- "languageTips": "Refreshing the browser will take effect."
+ "languageTips": "Refreshing the browser will take effect. Languages in beta may contain incomplete translations."
},
"DocsPrompt": "Type your question…",
"DocsSignInTitle": "Sign In Required",
diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json
index 3b5a86722..08208b2af 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -342,7 +342,7 @@
"ipDB": "Base de données de géolocalisation IP",
"ipDBTips": "Vous pouvez sélectionner la source de données de géolocalisation IP par défaut. Si la source sélectionnée n'est pas disponible, le système utilisera les sources suivantes.",
"language": "Paramètres de langue",
- "languageTips": "Rafraîchissement du navigateur pour prendre effet."
+ "languageTips": "Rafraîchissement du navigateur pour prendre effet. Les idiomes en bêta peuvent contenir des traductions incomplètes."
},
"DocsPrompt": "Saisissez votre question…",
"DocsSignInTitle": "Connexion requise",
diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json
index 6fa20462f..08d4d4922 100644
--- a/frontend/locales/ru.json
+++ b/frontend/locales/ru.json
@@ -342,7 +342,7 @@
"ipDB": "База геолокации IP",
"ipDBTips": "Можно выбрать основной источник геолокации IP. Если он недоступен, система по очереди использует следующие источники.",
"language": "Язык",
- "languageTips": "Изменение вступит в силу после обновления страницы."
+ "languageTips": "Изменение вступит в силу после обновления страницы. Языки в бета-версии могут содержать неполные переводы."
},
"DocsPrompt": "Введите свой вопрос…",
"DocsSignInTitle": "Требуется вход",
diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json
index c0ce6849a..47e9aea8c 100644
--- a/frontend/locales/zh.json
+++ b/frontend/locales/zh.json
@@ -342,7 +342,7 @@
"ipDB": "IP 解析数据源",
"ipDBTips": "你可以选择你默认使用的 IP 地理位置数据源,如果你选定的不可用,系统会依次使用后续的数据源。",
"language": "语言设置",
- "languageTips": "刷新浏览器后生效。"
+ "languageTips": "刷新浏览器后生效。正在 Beta 测试中的语言可能会存在翻译不完整的情况。"
},
"DocsPrompt": "请输入你要询问的问题",
"DocsSignInTitle": "请先登录",
From 10b3368f67962d1f9855379c6929868f3ee81fd1 Mon Sep 17 00:00:00 2001
From: jason5ng32
Date: Sun, 23 Aug 2026 12:41:00 +0800
Subject: [PATCH 14/15] Fix(ci): render the i18n comment from validated numbers
only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Even fenced plain text from the PR's run let a hostile PR speak in the
bot's voice. The artifact now carries a structured --json snapshot
(counts, locale codes, key paths); the privileged side validates every
field against enums, integer bounds and tight regexes, then renders the
comment text entirely with trusted code. A hostile PR can at most
misstate its own coverage numbers — it cannot emit prose, links or
instructions as the bot.
Co-Authored-By: Claude Fable 5
---
.github/workflows/ci.yml | 10 +++---
.github/workflows/i18n-comment.yml | 58 +++++++++++++++++++++++++-----
scripts/i18n-status.js | 24 ++++++++++++-
3 files changed, 79 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3139698ba..d9226ce1b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,9 +55,11 @@ jobs:
# see secrets — so the report is computed here and handed over as an
# artifact). No locale changes → no artifact → no comment.
#
- # The artifact carries RAW TEXT only. This run executes the PR's own
- # code, so nothing produced here may reach a comment as Markdown —
- # the privileged side sanitizes the text and owns all formatting.
+ # The artifact carries STRUCTURED NUMBERS only (--json: counts, locale
+ # codes, key paths). This run executes the PR's own code, so nothing
+ # produced here reaches a comment as text — the privileged side
+ # validates every field against strict shapes and renders the comment
+ # entirely with its own trusted code.
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
@@ -70,7 +72,7 @@ jobs:
exit 0
fi
mkdir -p i18n-report
- node scripts/i18n-status.js > i18n-report/report.txt
+ node scripts/i18n-status.js --json > i18n-report/report.json
printf '%s\n' "$PR_NUMBER" > i18n-report/pr-number
printf '%s\n' "$HEAD_SHA" > i18n-report/head-sha
diff --git a/.github/workflows/i18n-comment.yml b/.github/workflows/i18n-comment.yml
index 96c78095c..a971d0f12 100644
--- a/.github/workflows/i18n-comment.yml
+++ b/.github/workflows/i18n-comment.yml
@@ -7,10 +7,13 @@
# the PR here.
#
# The artifact is untrusted input (a PR can change scripts/i18n-status.js
-# to print whatever it likes). It is therefore treated as opaque text:
-# backticks stripped, length capped, always rendered inside a code fence.
-# Every piece of Markdown around it is authored here, by trusted code —
-# nothing from the artifact can become a link, an image or formatting.
+# to emit whatever it likes), so no artifact byte ever reaches the comment.
+# It carries structured claims — counts, locale codes, key paths — and each
+# field is validated against a strict shape (enums, integer bounds, tight
+# regexes) before trusted code here renders the comment text from scratch.
+# A hostile PR can at most misstate its own coverage numbers; it cannot put
+# a single word of prose, a link, or an instruction into the bot's mouth.
+# (The numbers are advisory anyway — the merge gate is pnpm test.)
name: i18n PR comment
on:
@@ -50,11 +53,11 @@ jobs:
const prNumber = Number.parseInt(read('pr-number'), 10);
const headSha = read('head-sha');
- // Neutralize the untrusted report: no backticks (so it can never
- // escape the fence below), bounded size (comment cap is 65536).
- const report = read('report.txt').replace(/`/g, '').slice(0, 55000);
if (!Number.isInteger(prNumber) || prNumber <= 0) return;
+ let data;
+ try { data = JSON.parse(read('report.json')); } catch { return; }
+
// The artifact came from an untrusted run: only write to the PR
// whose head actually triggered that run.
const { data: pr } = await github.rest.pulls.get({
@@ -63,6 +66,45 @@ jobs:
const runHead = context.payload.workflow_run.head_sha;
if (pr.head.sha !== runHead || headSha !== runHead) return;
+ // Validate-then-render: every value below either matches a strict
+ // shape or its row is dropped. Only validated numbers, enum labels
+ // and regex-tight identifiers make it into the rendered text.
+ const CODE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
+ const KEY = /^[\w./-]{1,120}$/;
+ const DATASET_LABELS = ['main pack', 'privacy copy', 'checklist'];
+ const int = (v, max) => Number.isInteger(v) && v >= 0 && v <= max;
+ const pct = (done, total) => ((done / (total || 1)) * 100).toFixed(1);
+ const bar = (done, total) => {
+ const filled = Math.min(24, Math.max(0, Math.round((done / (total || 1)) * 24)));
+ return '█'.repeat(filled) + '░'.repeat(24 - filled);
+ };
+
+ const lines = [];
+ const locales = Array.isArray(data?.locales) ? data.locales.slice(0, 50) : [];
+ for (const loc of locales) {
+ if (typeof loc?.code !== 'string' || !CODE.test(loc.code)) continue;
+ if (!['full', 'beta'].includes(loc.status)) continue;
+ const rows = (Array.isArray(loc.datasets) ? loc.datasets.slice(0, 10) : [])
+ .filter((d) => DATASET_LABELS.includes(d?.name) && typeof d.file === 'boolean'
+ && int(d.done, 1e6) && int(d.total, 1e6) && d.done <= d.total);
+ if (rows.length === 0) continue;
+ const done = rows.reduce((sum, d) => sum + d.done, 0);
+ const total = rows.reduce((sum, d) => sum + d.total, 0);
+ lines.push(`${loc.code} [${loc.status}] ${bar(done, total)} ${pct(done, total)}% (${done}/${total})`);
+ for (const d of rows) {
+ const note = d.file ? '' : ' (no file yet)';
+ lines.push(` ${d.name.padEnd(12)} ${pct(d.done, d.total).padStart(5)}% ${d.done}/${d.total}${note}`);
+ }
+ if (int(loc.sameAsEn, 1e6) && loc.sameAsEn > 0) {
+ lines.push(` identical to en: ${loc.sameAsEn} value(s) — worth a spot check`);
+ }
+ const nextUp = (Array.isArray(loc.nextUp) ? loc.nextUp.slice(0, 10) : [])
+ .filter((k) => typeof k === 'string' && KEY.test(k));
+ if (nextUp.length > 0) lines.push(` next up: ${nextUp.join(', ')}`);
+ lines.push('');
+ }
+ if (lines.length === 0) return;
+
const marker = '';
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
const body = [
@@ -70,7 +112,7 @@ jobs:
'### 🌍 Translation status',
'',
'```text',
- report,
+ lines.join('\n').trimEnd(),
'```',
`Untranslated keys fall back to English — partial PRs are welcome, and every extra key helps. Guide: [TRANSLATING.md](${repoUrl}/blob/dev/TRANSLATING.md).`,
].join('\n');
diff --git a/scripts/i18n-status.js b/scripts/i18n-status.js
index b90760d56..c8d7312bf 100644
--- a/scripts/i18n-status.js
+++ b/scripts/i18n-status.js
@@ -54,11 +54,33 @@ const bar = (ratio, width = 24) => {
};
const { values } = parseArgs({
- options: { locale: { type: 'string' }, limit: { type: 'string', default: '10' } },
+ options: {
+ locale: { type: 'string' },
+ limit: { type: 'string', default: '10' },
+ json: { type: 'boolean', default: false },
+ },
});
const limit = Math.max(0, Number.parseInt(values.limit, 10) || 0);
const targets = LOCALES.filter((l) => l.code !== FALLBACK_LOCALE && (!values.locale || l.code === values.locale));
+// --json: machine-readable snapshot of the same numbers, for CI's PR-comment
+// pipeline. Counts and key paths only, no prose — the consumer treats every
+// field as untrusted input and validates it before rendering anything.
+if (values.json) {
+ const locales = targets.map(({ code, status }) => {
+ const reports = DATASETS.map((dataset) => ({ ...dataset, ...compare(dataset.dir, code) }));
+ return {
+ code,
+ status,
+ sameAsEn: reports.reduce((sum, r) => sum + r.sameAsEn, 0),
+ datasets: reports.map((r) => ({ name: r.name, done: r.done, total: r.total, file: r.file })),
+ nextUp: reports.flatMap((r) => r.missing.map((key) => `${r.dir}${key}`)).slice(0, limit),
+ };
+ });
+ console.log(JSON.stringify({ locales }));
+ process.exit(0);
+}
+
console.log(`\nTranslation status — reference locale: ${FALLBACK_LOCALE}\n`);
if (targets.length === 0) {
From 58bd271d8ba3342f3fba10d724d11dda719e03c5 Mon Sep 17 00:00:00 2001
From: jason5ng32
Date: Sun, 23 Aug 2026 13:26:28 +0800
Subject: [PATCH 15/15] Refactor(ci): drop the i18n PR comment bot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Republishing PR-computed content under the bot identity needed ever more
laundering (fences, then schema validation) and the residue was still
reviewer-flagged — the data originates from code the PR controls, so no
amount of validation closes that. The convenience isn't worth the
privileged surface: the CI job summary already shows the same dashboard
from inside the PR's own sandbox, so the sticky comment, its artifact
hand-off and the --json mode all go.
Co-Authored-By: Claude Fable 5
---
.github/workflows/ci.yml | 35 --------
.github/workflows/i18n-comment.yml | 132 -----------------------------
scripts/i18n-status.js | 24 +-----
3 files changed, 1 insertion(+), 190 deletions(-)
delete mode 100644 .github/workflows/i18n-comment.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d9226ce1b..6bda661a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,41 +49,6 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- - name: Prepare translation report artifact
- # Locale-touching PRs get a sticky status comment, posted by
- # i18n-comment.yml (this job's token is read-only, and fork PRs never
- # see secrets — so the report is computed here and handed over as an
- # artifact). No locale changes → no artifact → no comment.
- #
- # The artifact carries STRUCTURED NUMBERS only (--json: counts, locale
- # codes, key paths). This run executes the PR's own code, so nothing
- # produced here reaches a comment as text — the privileged side
- # validates every field against strict shapes and renders the comment
- # entirely with its own trusted code.
- if: github.event_name == 'pull_request'
- env:
- GH_TOKEN: ${{ github.token }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- run: |
- if ! gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate \
- --jq '.[].filename' \
- | grep -qE '^(frontend/locales/|common/locale-registry\.js|frontend/data/changelog\.json)'; then
- exit 0
- fi
- mkdir -p i18n-report
- node scripts/i18n-status.js --json > i18n-report/report.json
- printf '%s\n' "$PR_NUMBER" > i18n-report/pr-number
- printf '%s\n' "$HEAD_SHA" > i18n-report/head-sha
-
- - name: Upload translation report
- if: github.event_name == 'pull_request'
- uses: actions/upload-artifact@v7
- with:
- name: i18n-report
- path: i18n-report/
- if-no-files-found: ignore
-
- name: Run tests
run: pnpm test
diff --git a/.github/workflows/i18n-comment.yml b/.github/workflows/i18n-comment.yml
deleted file mode 100644
index a971d0f12..000000000
--- a/.github/workflows/i18n-comment.yml
+++ /dev/null
@@ -1,132 +0,0 @@
-# Posts / updates one sticky translation-status comment on locale PRs.
-#
-# Deliberately split from CI: the report is computed there, under the
-# read-only token fork PRs get, and handed over as an artifact. This
-# workflow runs only trusted code from the default branch and holds the
-# pull-requests: write token — never check out or execute anything from
-# the PR here.
-#
-# The artifact is untrusted input (a PR can change scripts/i18n-status.js
-# to emit whatever it likes), so no artifact byte ever reaches the comment.
-# It carries structured claims — counts, locale codes, key paths — and each
-# field is validated against a strict shape (enums, integer bounds, tight
-# regexes) before trusted code here renders the comment text from scratch.
-# A hostile PR can at most misstate its own coverage numbers; it cannot put
-# a single word of prose, a link, or an instruction into the bot's mouth.
-# (The numbers are advisory anyway — the merge gate is pnpm test.)
-name: i18n PR comment
-
-on:
- workflow_run:
- workflows: [CI]
- types: [completed]
-
-permissions:
- pull-requests: write
- actions: read
-
-jobs:
- comment:
- # Only PR runs can carry the artifact; push runs are skipped outright.
- if: github.event.workflow_run.event == 'pull_request'
- runs-on: ubuntu-latest
-
- steps:
- - name: Download translation report
- id: download
- # No artifact = the PR didn't touch locale data; treat as a clean skip.
- continue-on-error: true
- uses: actions/download-artifact@v8
- with:
- name: i18n-report
- path: i18n-report
- run-id: ${{ github.event.workflow_run.id }}
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Upsert sticky comment
- if: steps.download.outcome == 'success'
- uses: actions/github-script@v9
- with:
- script: |
- const fs = require('fs');
- const read = (f) => fs.readFileSync(`i18n-report/${f}`, 'utf8').trim();
-
- const prNumber = Number.parseInt(read('pr-number'), 10);
- const headSha = read('head-sha');
- if (!Number.isInteger(prNumber) || prNumber <= 0) return;
-
- let data;
- try { data = JSON.parse(read('report.json')); } catch { return; }
-
- // The artifact came from an untrusted run: only write to the PR
- // whose head actually triggered that run.
- const { data: pr } = await github.rest.pulls.get({
- ...context.repo, pull_number: prNumber,
- });
- const runHead = context.payload.workflow_run.head_sha;
- if (pr.head.sha !== runHead || headSha !== runHead) return;
-
- // Validate-then-render: every value below either matches a strict
- // shape or its row is dropped. Only validated numbers, enum labels
- // and regex-tight identifiers make it into the rendered text.
- const CODE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
- const KEY = /^[\w./-]{1,120}$/;
- const DATASET_LABELS = ['main pack', 'privacy copy', 'checklist'];
- const int = (v, max) => Number.isInteger(v) && v >= 0 && v <= max;
- const pct = (done, total) => ((done / (total || 1)) * 100).toFixed(1);
- const bar = (done, total) => {
- const filled = Math.min(24, Math.max(0, Math.round((done / (total || 1)) * 24)));
- return '█'.repeat(filled) + '░'.repeat(24 - filled);
- };
-
- const lines = [];
- const locales = Array.isArray(data?.locales) ? data.locales.slice(0, 50) : [];
- for (const loc of locales) {
- if (typeof loc?.code !== 'string' || !CODE.test(loc.code)) continue;
- if (!['full', 'beta'].includes(loc.status)) continue;
- const rows = (Array.isArray(loc.datasets) ? loc.datasets.slice(0, 10) : [])
- .filter((d) => DATASET_LABELS.includes(d?.name) && typeof d.file === 'boolean'
- && int(d.done, 1e6) && int(d.total, 1e6) && d.done <= d.total);
- if (rows.length === 0) continue;
- const done = rows.reduce((sum, d) => sum + d.done, 0);
- const total = rows.reduce((sum, d) => sum + d.total, 0);
- lines.push(`${loc.code} [${loc.status}] ${bar(done, total)} ${pct(done, total)}% (${done}/${total})`);
- for (const d of rows) {
- const note = d.file ? '' : ' (no file yet)';
- lines.push(` ${d.name.padEnd(12)} ${pct(d.done, d.total).padStart(5)}% ${d.done}/${d.total}${note}`);
- }
- if (int(loc.sameAsEn, 1e6) && loc.sameAsEn > 0) {
- lines.push(` identical to en: ${loc.sameAsEn} value(s) — worth a spot check`);
- }
- const nextUp = (Array.isArray(loc.nextUp) ? loc.nextUp.slice(0, 10) : [])
- .filter((k) => typeof k === 'string' && KEY.test(k));
- if (nextUp.length > 0) lines.push(` next up: ${nextUp.join(', ')}`);
- lines.push('');
- }
- if (lines.length === 0) return;
-
- const marker = '';
- const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
- const body = [
- marker,
- '### 🌍 Translation status',
- '',
- '```text',
- lines.join('\n').trimEnd(),
- '```',
- `Untranslated keys fall back to English — partial PRs are welcome, and every extra key helps. Guide: [TRANSLATING.md](${repoUrl}/blob/dev/TRANSLATING.md).`,
- ].join('\n');
-
- const comments = await github.paginate(github.rest.issues.listComments, {
- ...context.repo, issue_number: prNumber, per_page: 100,
- });
- const existing = comments.find((c) => c.body?.startsWith(marker));
- if (existing) {
- await github.rest.issues.updateComment({
- ...context.repo, comment_id: existing.id, body,
- });
- } else {
- await github.rest.issues.createComment({
- ...context.repo, issue_number: prNumber, body,
- });
- }
diff --git a/scripts/i18n-status.js b/scripts/i18n-status.js
index c8d7312bf..b90760d56 100644
--- a/scripts/i18n-status.js
+++ b/scripts/i18n-status.js
@@ -54,33 +54,11 @@ const bar = (ratio, width = 24) => {
};
const { values } = parseArgs({
- options: {
- locale: { type: 'string' },
- limit: { type: 'string', default: '10' },
- json: { type: 'boolean', default: false },
- },
+ options: { locale: { type: 'string' }, limit: { type: 'string', default: '10' } },
});
const limit = Math.max(0, Number.parseInt(values.limit, 10) || 0);
const targets = LOCALES.filter((l) => l.code !== FALLBACK_LOCALE && (!values.locale || l.code === values.locale));
-// --json: machine-readable snapshot of the same numbers, for CI's PR-comment
-// pipeline. Counts and key paths only, no prose — the consumer treats every
-// field as untrusted input and validates it before rendering anything.
-if (values.json) {
- const locales = targets.map(({ code, status }) => {
- const reports = DATASETS.map((dataset) => ({ ...dataset, ...compare(dataset.dir, code) }));
- return {
- code,
- status,
- sameAsEn: reports.reduce((sum, r) => sum + r.sameAsEn, 0),
- datasets: reports.map((r) => ({ name: r.name, done: r.done, total: r.total, file: r.file })),
- nextUp: reports.flatMap((r) => r.missing.map((key) => `${r.dir}${key}`)).slice(0, limit),
- };
- });
- console.log(JSON.stringify({ locales }));
- process.exit(0);
-}
-
console.log(`\nTranslation status — reference locale: ${FALLBACK_LOCALE}\n`);
if (targets.length === 0) {