diff --git a/.github/workflows/i18n-sync.yml b/.github/workflows/i18n-sync.yml new file mode 100644 index 000000000..ea7c56d69 --- /dev/null +++ b/.github/workflows/i18n-sync.yml @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +# +# SPDX-License-Identifier: CC0-1.0 + +name: I18n Sync + +on: + push: + branches: + - master + paths: + - "lang/en/**" + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + push-source: + name: Push source strings to Transifex + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Check out Git repository + uses: actions/checkout@v6 + + - name: Push source files to Transifex + uses: transifex/cli-action@v2 + with: + token: ${{ secrets.TX_TOKEN }} + args: push --source + + pull-translations: + name: Pull translations from Transifex + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Check out Git repository + uses: actions/checkout@v6 + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Pull translations from Transifex + uses: transifex/cli-action@v2 + with: + token: ${{ secrets.TX_TOKEN }} + args: pull --all --force + + - name: Regenerate i18n asset files + run: npm run generate-i18n-assets + + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "Update translations from Transifex" + title: "Update translations from Transifex" + body: | + Automated pull of latest translations from Transifex. + + This PR updates `lang/` source files and regenerates `src/renderer/assets/languages/`. + branch: i18n/update-translations + delete-branch: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 022523922..01d56f571 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Validate i18n assets + run: | + npm run generate-i18n-assets + git diff --exit-code src/renderer/assets/languages/ + npm run i18n:validate + - name: Format check run: npm run format:check diff --git a/.tx/config b/.tx/config new file mode 100644 index 000000000..6c3982eff --- /dev/null +++ b/.tx/config @@ -0,0 +1,31 @@ +[main] +host = https://app.transifex.com + +# NOTE: Replace and with the actual Transifex organization +# and project slugs before activating the sync workflow. +# Resource type is KEYVALUEJSON for nested JSON translation files. + +[o::p::r:lobby] +source_file = lang/en/lobby.json +file_filter = lang//lobby.json +type = KEYVALUEJSON + +[o::p::r:interface] +source_file = lang/en/interface.json +file_filter = lang//interface.json +type = KEYVALUEJSON + +[o::p::r:features] +source_file = lang/en/features.json +file_filter = lang//features.json +type = KEYVALUEJSON + +[o::p::r:tips] +source_file = lang/en/tips.json +file_filter = lang//tips.json +type = KEYVALUEJSON + +[o::p::r:units] +source_file = lang/en/units.json +file_filter = lang//units.json +type = KEYVALUEJSON diff --git a/REUSE.toml b/REUSE.toml index aeab6e33c..710200e91 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -4,6 +4,7 @@ version = 1 # We don't consider the top level configuration files copyrightable in any way path = [ ".*", + ".tx/config", "package*.json", "tsconfig.*", "eslint.config.mjs", diff --git a/lang/README.md b/lang/README.md index b17c2f250..117fdcad2 100644 --- a/lang/README.md +++ b/lang/README.md @@ -4,6 +4,118 @@ SPDX-FileCopyrightText: 2025 The BAR Lobby Authors SPDX-License-Identifier: MIT --> -# What is this directory? +# Translation Source Files -This directory and it's contents are a placeholder for the future git submodule that will manage translation files for both this repository and the main game repository, that will it self be managed through transifax. +This directory contains the source translation files for bar-lobby. Each locale +has its own subdirectory (e.g. `en/`, `de/`, `fr/`) with one or more JSON files +organized by domain. + +## File Structure + +``` +lang/ +├── en/ ← reference locale (source of truth) +│ ├── lobby.json +│ ├── interface.json +│ ├── features.json +│ ├── tips.json +│ └── units.json +├── de/ +│ ├── lobby.json +│ └── ... +└── ... +``` + +**Do not edit** `src/renderer/assets/languages/*.json` by hand — those files are +generated by merging the per-domain files here into a single file per locale. + +## How It Works + +1. **Source files live here** (`lang/{locale}/*.json`). English (`en`) is the + reference locale and must be complete (no `null` values). +2. **Translators work in Transifex.** Each JSON file maps to a separate Transifex + resource so translators can track progress per domain. +3. **Automated sync** via GitHub Actions (`.github/workflows/i18n-sync.yml`): + - When `lang/en/` changes on `master`, source strings are pushed to Transifex. + - On a daily schedule (or manual trigger), translations are pulled from + Transifex, assets are regenerated, and a PR is opened automatically. +4. **Asset generation** merges per-domain files into the single-file-per-locale + format that `vue-i18n` loads at runtime. + +## Developer Workflow + +### Adding a new translation string + +1. Add the key and English text to the appropriate file in `lang/en/` (e.g. + `lobby.json` for lobby UI strings). +2. Run `npm run generate-i18n-assets` to regenerate the runtime assets. +3. Reference the key in your component: + ```vue + + + ``` +4. TypeScript autocomplete will suggest valid keys. See the + [wiki guide](https://github.com/beyond-all-reason/bar-lobby/wiki/Internationalization:-translation-strings-in-bar%E2%80%90lobby) + for full details. + +### Finding unused or missing keys + +Run the report script to see which keys exist in the language files but are not +referenced in code (unused), or which keys are referenced in code but missing +from the language files: + +```sh +npm run i18n:report +``` + +### Validating translations + +Run the validation script to check that the English reference locale is +complete and see a coverage summary for all locales: + +```sh +npm run i18n:validate +``` + +CI runs this automatically — the build will fail if `en` has any `null` values +or if generated assets are out of date with source files. + +### Adding a new locale + +1. Create a new directory under `lang/` (e.g. `lang/pl/`). +2. Add JSON files matching the English structure. Missing keys will be set to + `null` by the generate script. +3. Run `npm run generate-i18n-assets`. +4. Register the new locale in `src/renderer/i18n.ts` (import and add to + `messages`). +5. Add the locale's Transifex resource mappings to `.tx/config`. + +## Transifex Setup + +The automated workflow requires: + +- A **Transifex project** with resources matching those defined in `.tx/config`. +- A **`TX_TOKEN` repository secret** containing a Transifex API token. + +Replace the `` and `` placeholders in `.tx/config` with actual +Transifex slugs before activating the workflow. + +### Manual Transifex CLI usage + +Install the [Transifex CLI](https://github.com/transifex/cli), then: + +```sh +# Push English source strings to Transifex +TX_TOKEN=your_token tx push --source + +# Pull all translations from Transifex +TX_TOKEN=your_token tx pull --all --force + +# Regenerate assets after pulling +npm run generate-i18n-assets +``` diff --git a/package-lock.json b/package-lock.json index 459a98b40..fc073d138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "vite-plugin-static-copy": "^3.1.2", "vite-plugin-vue-devtools": "^8.0.0", "vitest": "^3.2.4", + "vue-i18n-extract": "^2.0.7", "vue-tsc": "^3.0.3", "wait-on": "^8.0.4" }, @@ -6484,6 +6485,52 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dot-object": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-2.1.5.tgz", + "integrity": "sha512-xHF8EP4XH/Ba9fvAF2LDd5O3IITVolerVV6xvkxoM8zlGEiCUrggpAnHyOoKJKCrhvPcGATFAUwIujj7bRG5UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^6.1.0", + "glob": "^7.1.6" + }, + "bin": { + "dot-object": "bin/dot-object" + } + }, + "node_modules/dot-object/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/dot-object/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -8977,6 +9024,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-valid-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", + "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-what": { "version": "4.1.16", "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", @@ -14298,6 +14355,23 @@ "vue": "^3.0.0" } }, + "node_modules/vue-i18n-extract": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/vue-i18n-extract/-/vue-i18n-extract-2.0.7.tgz", + "integrity": "sha512-i1NW5R58S720iQ1BEk+6ILo3hT6UA8mtYNNolSH4rt9345qvXdvA6GHy2+jHozdDAKHwlu9VvS/+vIMKs1UYQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.12", + "dot-object": "^2.1.4", + "glob": "^8.0.1", + "is-valid-glob": "^1.0.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "vue-i18n-extract": "bin/vue-i18n-extract.js" + } + }, "node_modules/vue-router": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", diff --git a/package.json b/package.json index b0e2260a5..c7afbe26a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "concurrently \"npm run typecheck:node\" \"npm run typecheck:web\"", "checks": "concurrently \"npm run typecheck:web\" \"npm run typecheck:node\" \"npm run lint\" \"npm run format:check\"", - "generate-i18n-assets": "node --import=tsx tools/generate-i18n-asset-files.ts" + "generate-i18n-assets": "node --import=tsx tools/generate-i18n-asset-files.ts", + "i18n:validate": "node --import=tsx tools/validate-i18n.ts", + "i18n:report": "vue-i18n-extract report --vueFiles \"src/renderer/**/*.{vue,ts}\" --languageFiles \"src/renderer/assets/languages/en.json\"" }, "engines": { "node": "22.18.0" @@ -104,6 +106,7 @@ "vite-plugin-static-copy": "^3.1.2", "vite-plugin-vue-devtools": "^8.0.0", "vitest": "^3.2.4", + "vue-i18n-extract": "^2.0.7", "vue-tsc": "^3.0.3", "wait-on": "^8.0.4" } diff --git a/tools/generate-i18n-asset-files.ts b/tools/generate-i18n-asset-files.ts index a019a72aa..13ea32d8f 100644 --- a/tools/generate-i18n-asset-files.ts +++ b/tools/generate-i18n-asset-files.ts @@ -32,7 +32,7 @@ async function getLanguageFiles(locale: string) { return new Promise>((resolve, reject) => { fs.glob(path.join(LANG_DIR, locale, "**/*.json"), (err, matches) => { if (err) reject(err); - else resolve(matches); + else resolve(matches.sort()); }); }); } @@ -104,7 +104,7 @@ async function main() { const referenceObject = await processLocale(REFERENCE_LOCALE); const referenceOutputPath = path.join(OUTPUT_DIR, `${REFERENCE_LOCALE}.json`); - fs.writeFileSync(referenceOutputPath, JSON.stringify(referenceObject, null, 2)); + fs.writeFileSync(referenceOutputPath, JSON.stringify(referenceObject, null, 4) + "\n"); logAssetGeneration(referenceOutputPath); for (const locale of locales) { @@ -116,7 +116,7 @@ async function main() { const completeLocaleObject = addMissingKeys(referenceObject, localeObject); const outputFilePath = path.join(OUTPUT_DIR, `${locale}.json`); - fs.writeFileSync(outputFilePath, JSON.stringify(completeLocaleObject, null, 2)); + fs.writeFileSync(outputFilePath, JSON.stringify(completeLocaleObject, null, 4) + "\n"); logAssetGeneration(outputFilePath); } } diff --git a/tools/validate-i18n.ts b/tools/validate-i18n.ts new file mode 100644 index 000000000..39c04deb7 --- /dev/null +++ b/tools/validate-i18n.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +type TranslationValue = string | number | boolean | null | TranslationObject | TranslationValue[]; +interface TranslationObject { + [key: string]: TranslationValue; +} + +const OUTPUT_DIR = path.resolve(__dirname, "../src/renderer/assets/languages"); +const REFERENCE_LOCALE = "en"; + +function countKeys(obj: TranslationObject, prefix = ""): { total: number; nulls: number; paths: string[] } { + let total = 0; + let nulls = 0; + const paths: string[] = []; + + for (const key in obj) { + const currentPath = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const nested = countKeys(value as TranslationObject, currentPath); + total += nested.total; + nulls += nested.nulls; + paths.push(...nested.paths); + } else { + total++; + if (value === null) { + nulls++; + paths.push(currentPath); + } + } + } + + return { total, nulls, paths }; +} + +async function main() { + const files = await fs.promises.readdir(OUTPUT_DIR); + const jsonFiles = files.filter((f) => f.endsWith(".json")); + + if (jsonFiles.length === 0) { + console.error("No generated translation files found. Run `npm run generate-i18n-assets` first."); + process.exit(1); + } + + let hasErrors = false; + const results: { locale: string; total: number; translated: number; coverage: string }[] = []; + + for (const file of jsonFiles) { + const locale = file.replace(".json", ""); + const content = await fs.promises.readFile(path.join(OUTPUT_DIR, file), "utf-8"); + + let parsed: TranslationObject; + try { + parsed = JSON.parse(content); + } catch { + console.error(`\x1b[31m✗ ${locale}: Invalid JSON\x1b[0m`); + hasErrors = true; + continue; + } + + const { total, nulls, paths } = countKeys(parsed); + const translated = total - nulls; + const coverage = total > 0 ? ((translated / total) * 100).toFixed(1) : "0.0"; + + results.push({ locale, total, translated, coverage }); + + if (locale === REFERENCE_LOCALE && nulls > 0) { + console.error(`\x1b[31m✗ ${locale} (reference): ${nulls} null value(s) found — source locale must be complete\x1b[0m`); + for (const p of paths) { + console.error(` - ${p}`); + } + hasErrors = true; + } + } + + console.log("\nTranslation coverage report:"); + console.log("─".repeat(45)); + for (const r of results.sort((a, b) => a.locale.localeCompare(b.locale))) { + const bar = r.locale === REFERENCE_LOCALE ? "(reference)" : `${r.translated}/${r.total}`; + const color = r.coverage === "100.0" ? "\x1b[32m" : r.locale === REFERENCE_LOCALE ? "\x1b[36m" : "\x1b[33m"; + console.log(`${color} ${r.locale.padEnd(6)} ${r.coverage.padStart(6)}% ${bar}\x1b[0m`); + } + console.log(""); + + if (hasErrors) { + console.error("Validation failed."); + process.exit(1); + } + + console.log("\x1b[32mValidation passed.\x1b[0m"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});