From 874ad9f4fd3f770bcffb2550e37ee539a08389b4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 11 Oct 2026 00:19:05 -0500 Subject: [PATCH] feat: AI translations at release, 7 new languages and regional fallback --- .github/workflows/release.yml | 123 ++++++++++++++++-- .github/workflows/translate.yml | 53 ++++++++ CHANGELOG.md | 15 +++ src/ui/auth/Auth.tsx | 39 +----- src/ui/i18n/i18n.ts | 60 +++------ src/ui/i18n/languages.ts | 17 +++ src/ui/locales/README.md | 8 +- src/ui/locales/languages.json | 103 +++++++++++++++ .../appearance/appearance-controls.tsx | 39 +----- src/ui/sidebar/UserProfilePanel.tsx | 3 +- src/ui/tests/i18n/i18n.test.ts | 30 +++++ 11 files changed, 356 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/translate.yml create mode 100644 src/ui/i18n/languages.ts create mode 100644 src/ui/locales/languages.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1849b1a9a..6438956dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,15 +161,62 @@ jobs: echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT" - verify-beta: + # Translates new or changed strings with the Termix LLM server and commits + # them to the dev branch, so the beta or stable build includes them. If the + # server is down it warns and the release goes on with what is there. + translate: needs: [prep] + if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + outputs: + sha: ${{ steps.commit.outputs.sha }} + steps: + - name: Checkout dev branch + uses: actions/checkout@v7 + with: + ref: ${{ needs.prep.outputs.sha || needs.prep.outputs.dev_branch }} + fetch-depth: 1 + token: ${{ secrets.TERMIX_PAT }} + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + + - name: Translate + uses: Termix-SSH/Termix-Registry/translate@main + with: + source: src/ui/locales/en.json + out: src/ui/locales/translated + languages: src/ui/locales/languages.json + api-key: ${{ secrets.TERMIX_LLM_API_KEY }} + + - name: Commit translations + id: commit + run: | + git add src/ui/locales + if git diff --cached --quiet; then + echo "No translation changes." + elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then + echo "DRY RUN: would commit these translation changes:" + git --no-pager diff --cached --stat + else + git config user.name "LukeGus" + git config user.email "bugattiguy527@gmail.com" + git commit -m "chore: update translations" + git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" + fi + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + verify-beta: + needs: [prep, translate] if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout beta commit uses: actions/checkout@v7 with: - ref: ${{ needs.prep.outputs.sha }} + ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }} fetch-depth: 1 - name: Setup Node.js @@ -197,7 +244,7 @@ jobs: run: npm run test normalize: - needs: [prep] + needs: [prep, translate] if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: @@ -284,8 +331,8 @@ jobs: git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" merge-to-main: - needs: [prep, normalize, sync-main] - if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }} + needs: [prep, translate, normalize, sync-main] + if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.translate.result == 'success' || needs.translate.result == 'skipped') && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }} runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: main_sha: ${{ steps.merge.outputs.main_sha }} @@ -353,14 +400,14 @@ jobs: echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT" docker: - needs: [prep, verify-beta, merge-to-main] + needs: [prep, translate, verify-beta, merge-to-main] if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.skip != 'true' && (needs.merge-to-main.result == 'success' || needs.verify-beta.result == 'success') }} uses: ./.github/workflows/docker.yml with: version: ${{ needs.prep.outputs.version }} build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }} dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }} - source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }} + source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }} secrets: inherit create-release: @@ -418,7 +465,7 @@ jobs: fi create-beta-release: - needs: [prep, docker] + needs: [prep, translate, docker] if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }} runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: @@ -427,7 +474,7 @@ jobs: - name: Checkout beta commit uses: actions/checkout@v7 with: - ref: ${{ needs.prep.outputs.sha }} + ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }} fetch-depth: 0 - name: Setup Node.js @@ -440,7 +487,7 @@ jobs: node scripts/generate-beta-body.cjs \ --version "${{ needs.prep.outputs.version }}" \ --branch "${{ needs.prep.outputs.dev_branch }}" \ - --sha "${{ needs.prep.outputs.sha }}" \ + --sha "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}" \ --prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md - name: Create beta release @@ -456,11 +503,19 @@ jobs: else gh release create "$TAG" --repo ${{ github.repository }} \ --title "$TITLE" --notes-file BETA_BODY.md \ - --prerelease --target "${{ needs.prep.outputs.sha }}" + --prerelease --target "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}" fi electron-release: - needs: [prep, merge-to-main, docker, create-release, create-beta-release] + needs: + [ + prep, + translate, + merge-to-main, + docker, + create-release, + create-beta-release, + ] if: ${{ always() && needs.prep.outputs.skip != 'true' && needs.docker.result == 'success' && needs.create-release.result != 'failure' && needs.create-beta-release.result != 'failure' }} uses: ./.github/workflows/electron.yml with: @@ -468,7 +523,7 @@ jobs: artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }} release_tag: ${{ needs.prep.outputs.release_tag }} version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }} - source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }} + source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }} secrets: inherit electron-submit: @@ -541,8 +596,48 @@ jobs: # The docs site reads the env catalog, API spec and download list from the # newest stable release. - docs-refresh: + # Docs written for this release live on dev-X.Y.Z in the Docs repo. Squash + # merge it into main once the release is out. No branch means nothing to do. + docs-merge: needs: [prep, electron-release] + if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Merge the docs dev branch into main + env: + GH_TOKEN: ${{ secrets.TERMIX_PAT }} + REPO: Termix-SSH/Docs + VERSION: ${{ needs.prep.outputs.version }} + run: | + BRANCH="dev-$VERSION" + if ! gh api "repos/$REPO/branches/$BRANCH" > /dev/null 2>&1; then + echo "$REPO has no $BRANCH branch, nothing to merge." + exit 0 + fi + + AHEAD=$(gh api "repos/$REPO/compare/main...$BRANCH" -q .ahead_by) + if [ "$AHEAD" = "0" ]; then + echo "$BRANCH has nothing main doesn't, deleting it." + gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH" + exit 0 + fi + + PR_NUMBER=$(gh pr list --repo "$REPO" --head "$BRANCH" --base main --state open --json number -q '.[0].number') + if [ -z "$PR_NUMBER" ]; then + PR_NUMBER=$(gh pr create --repo "$REPO" --base main --head "$BRANCH" \ + --title "v$VERSION" --body "Docs for Termix v$VERSION." | grep -oE '[0-9]+$') + fi + + gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --admin --delete-branch + + STATE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state -q .state) + if [ "$STATE" != "MERGED" ]; then + echo "Docs PR #$PR_NUMBER did not merge (state: $STATE)." + exit 1 + fi + + docs-refresh: + needs: [prep, electron-release, docs-merge] if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: diff --git a/.github/workflows/translate.yml b/.github/workflows/translate.yml new file mode 100644 index 000000000..759d82daa --- /dev/null +++ b/.github/workflows/translate.yml @@ -0,0 +1,53 @@ +name: Translate + +on: + workflow_dispatch: + inputs: + retranslate: + description: "Translate every string again, not only new or changed ones" + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: translate-${{ github.ref }} + cancel-in-progress: false + +jobs: + translate: + if: ${{ startsWith(github.ref, 'refs/heads/dev-') }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 1 + token: ${{ secrets.TERMIX_PAT }} + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + + - name: Translate + uses: Termix-SSH/Termix-Registry/translate@main + with: + source: src/ui/locales/en.json + out: src/ui/locales/translated + languages: src/ui/locales/languages.json + retranslate: ${{ inputs.retranslate }} + api-key: ${{ secrets.TERMIX_LLM_API_KEY }} + + - name: Commit translations + run: | + git add src/ui/locales + if git diff --cached --quiet; then + echo "No translation changes." + exit 0 + fi + git config user.name "LukeGus" + git config user.email "bugattiguy527@gmail.com" + git commit -m "chore: update translations" + git push origin HEAD:"${GITHUB_REF_NAME}" diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8a614dc..7dd420570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 26.10.1 + +### Added + +- Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay + +### Changed + +- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release +- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one + +### Fixed + +- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation + ## 26.10.0 ### Added diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 7600cc496..c002b2dbf 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -59,6 +59,7 @@ import { normalizeLanguageCode, rememberLoginLanguage, } from "@/i18n/i18n"; +import { LANGUAGES } from "@/i18n/languages"; import { removeSilentSigninFromSearch, shouldTriggerSilentSignin, @@ -66,44 +67,6 @@ import { import { Select2 } from "@/components/select2"; import { cn } from "@/lib/utils"; -const LANGUAGES = [ - { code: "en", label: "English" }, - { code: "af", label: "Afrikaans" }, - { code: "ar", label: "العربية" }, - { code: "bn", label: "বাংলা" }, - { code: "bg", label: "Български" }, - { code: "ca", label: "Català" }, - { code: "zh-CN", label: "中文 (简体)" }, - { code: "zh-TW", label: "中文 (繁體)" }, - { code: "cs", label: "Čeština" }, - { code: "da", label: "Dansk" }, - { code: "nl", label: "Nederlands" }, - { code: "fi", label: "Suomi" }, - { code: "fr", label: "Français" }, - { code: "de", label: "Deutsch" }, - { code: "el", label: "Ελληνικά" }, - { code: "he", label: "עברית" }, - { code: "hi", label: "हिन्दी" }, - { code: "hu", label: "Magyar" }, - { code: "id", label: "Indonesia" }, - { code: "it", label: "Italiano" }, - { code: "ja", label: "日本語" }, - { code: "ko", label: "한국어" }, - { code: "no", label: "Norsk" }, - { code: "pl", label: "Polski" }, - { code: "pt-PT", label: "Português (PT)" }, - { code: "pt-BR", label: "Português (BR)" }, - { code: "ro", label: "Română" }, - { code: "ru", label: "Русский" }, - { code: "sr", label: "Српски" }, - { code: "es-ES", label: "Español" }, - { code: "sv-SE", label: "Svenska" }, - { code: "th", label: "ไทย" }, - { code: "tr", label: "Türkçe" }, - { code: "uk", label: "Українська" }, - { code: "vi", label: "Tiếng Việt" }, -]; - function LanguageRow({ label, value, diff --git a/src/ui/i18n/i18n.ts b/src/ui/i18n/i18n.ts index 8921b3642..c01be4514 100644 --- a/src/ui/i18n/i18n.ts +++ b/src/ui/i18n/i18n.ts @@ -3,46 +3,14 @@ import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import enTranslation from "../locales/en.json"; +import { TRANSLATED_LANGUAGES } from "./languages"; type LocaleModule = { default: ResourceKey }; /** i18n code -> translated file name, shared by core and plugin locales. */ -export const LOCALE_FILES = { - af: "af_ZA", - ar: "ar_SA", - bn: "bn_BD", - bg: "bg_BG", - ca: "ca_ES", - cs: "cs_CZ", - da: "da_DK", - de: "de_DE", - el: "el_GR", - "es-ES": "es_ES", - fi: "fi_FI", - fr: "fr_FR", - he: "he_IL", - hi: "hi_IN", - hu: "hu_HU", - id: "id_ID", - it: "it_IT", - ja: "ja_JP", - ko: "ko_KR", - nl: "nl_NL", - no: "no_NO", - pl: "pl_PL", - "pt-PT": "pt_PT", - "pt-BR": "pt_BR", - ro: "ro_RO", - ru: "ru_RU", - sr: "sr_SP", - "sv-SE": "sv_SE", - th: "th_TH", - tr: "tr_TR", - uk: "uk_UA", - vi: "vi_VN", - "zh-CN": "zh_CN", - "zh-TW": "zh_TW", -} as const satisfies Record; +export const LOCALE_FILES: Record = Object.fromEntries( + TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]), +); const translatedFiles = import.meta.glob( "../locales/translated/*.json", @@ -94,8 +62,20 @@ export function normalizeLanguageCode(language?: string | null): string { ); if (exactMatch) return exactMatch; - const baseLanguage = normalized.split("-")[0]; - return supportedLngs.includes(baseLanguage) ? baseLanguage : "en"; + const baseLanguage = normalized.split("-")[0].toLowerCase(); + if (supportedLngs.includes(baseLanguage)) return baseLanguage; + + // Regional variants we don't ship (es-MX, pt, sv) use the closest one we do. + if (baseLanguage === "zh") { + return /-(tw|hk|mo|hant)\b/i.test(normalized) ? "zh-TW" : "zh-CN"; + } + if (baseLanguage === "nb" || baseLanguage === "nn") return "no"; + if (baseLanguage === "pt") return "pt-BR"; + return ( + supportedLngs.find((supported) => + supported.toLowerCase().startsWith(`${baseLanguage}-`), + ) ?? "en" + ); } const localeBackend: BackendModule = { @@ -106,9 +86,7 @@ const localeBackend: BackendModule = { if (namespace !== CORE_NAMESPACE) { const file = - normalizedLanguage === "en" - ? "en" - : (LOCALE_FILES as Record)[normalizedLanguage]; + normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage]; if (!pluginLocaleResolver || !file) { callback(null, {}); return; diff --git a/src/ui/i18n/languages.ts b/src/ui/i18n/languages.ts new file mode 100644 index 000000000..8282fc604 --- /dev/null +++ b/src/ui/i18n/languages.ts @@ -0,0 +1,17 @@ +import languages from "../locales/languages.json"; + +/** One translated language. The translator in Termix-Registry keeps a copy of this list. */ +export interface Language { + code: string; + file: string; + name: string; + label: string; +} + +export const TRANSLATED_LANGUAGES: readonly Language[] = languages; + +/** Every language the app can show, English first, for language pickers. */ +export const LANGUAGES: readonly { code: string; label: string }[] = [ + { code: "en", label: "English" }, + ...TRANSLATED_LANGUAGES.map(({ code, label }) => ({ code, label })), +]; diff --git a/src/ui/locales/README.md b/src/ui/locales/README.md index 094c3a098..1f569a6f6 100644 --- a/src/ui/locales/README.md +++ b/src/ui/locales/README.md @@ -1,5 +1,9 @@ # Translations -Only `en.json` needs to be updated. Every other language in `translated/` is filled in automatically for any string that doesn't have a translation yet. +Only `en.json` needs to be updated. Every other language in `translated/` is translated automatically on each release (and by the Translate workflow) with the Termix LLM server, using the translator in [Termix-Registry](https://github.com/Termix-SSH/Termix-Registry/tree/main/translate). -If an automatic translation is wrong, fix it in that language's file. Strings that already have a translation are never overwritten. +New strings and strings whose English changed are translated. `translations-lock.json` tracks which English text each translation was made from, so don't edit it by hand. + +If an automatic translation is wrong, fix it in that language's file. A fixed string is kept until its English changes. + +To add a language, add it to `languages.json` here and in `translate/languages.json` in Termix-Registry. diff --git a/src/ui/locales/languages.json b/src/ui/locales/languages.json new file mode 100644 index 000000000..e1b435ded --- /dev/null +++ b/src/ui/locales/languages.json @@ -0,0 +1,103 @@ +[ + { "code": "af", "file": "af_ZA", "name": "Afrikaans", "label": "Afrikaans" }, + { "code": "ar", "file": "ar_SA", "name": "Arabic", "label": "العربية" }, + { "code": "bn", "file": "bn_BD", "name": "Bengali", "label": "বাংলা" }, + { "code": "bg", "file": "bg_BG", "name": "Bulgarian", "label": "Български" }, + { "code": "ca", "file": "ca_ES", "name": "Catalan", "label": "Català" }, + { + "code": "zh-CN", + "file": "zh_CN", + "name": "Simplified Chinese", + "label": "中文 (简体)" + }, + { + "code": "zh-TW", + "file": "zh_TW", + "name": "Traditional Chinese (Taiwan)", + "label": "中文 (繁體)" + }, + { "code": "hr", "file": "hr_HR", "name": "Croatian", "label": "Hrvatski" }, + { "code": "cs", "file": "cs_CZ", "name": "Czech", "label": "Čeština" }, + { "code": "da", "file": "da_DK", "name": "Danish", "label": "Dansk" }, + { "code": "nl", "file": "nl_NL", "name": "Dutch", "label": "Nederlands" }, + { "code": "et", "file": "et_EE", "name": "Estonian", "label": "Eesti" }, + { "code": "fi", "file": "fi_FI", "name": "Finnish", "label": "Suomi" }, + { "code": "fr", "file": "fr_FR", "name": "French", "label": "Français" }, + { "code": "de", "file": "de_DE", "name": "German", "label": "Deutsch" }, + { "code": "el", "file": "el_GR", "name": "Greek", "label": "Ελληνικά" }, + { "code": "he", "file": "he_IL", "name": "Hebrew", "label": "עברית" }, + { "code": "hi", "file": "hi_IN", "name": "Hindi", "label": "हिन्दी" }, + { "code": "hu", "file": "hu_HU", "name": "Hungarian", "label": "Magyar" }, + { + "code": "id", + "file": "id_ID", + "name": "Indonesian", + "label": "Indonesia" + }, + { "code": "it", "file": "it_IT", "name": "Italian", "label": "Italiano" }, + { "code": "ja", "file": "ja_JP", "name": "Japanese", "label": "日本語" }, + { "code": "ko", "file": "ko_KR", "name": "Korean", "label": "한국어" }, + { "code": "lv", "file": "lv_LV", "name": "Latvian", "label": "Latviešu" }, + { + "code": "lt", + "file": "lt_LT", + "name": "Lithuanian", + "label": "Lietuvių" + }, + { "code": "ms", "file": "ms_MY", "name": "Malay", "label": "Bahasa Melayu" }, + { + "code": "no", + "file": "no_NO", + "name": "Norwegian Bokmål", + "label": "Norsk" + }, + { "code": "pl", "file": "pl_PL", "name": "Polish", "label": "Polski" }, + { + "code": "pt-PT", + "file": "pt_PT", + "name": "European Portuguese", + "label": "Português (PT)" + }, + { + "code": "pt-BR", + "file": "pt_BR", + "name": "Brazilian Portuguese", + "label": "Português (BR)" + }, + { "code": "ro", "file": "ro_RO", "name": "Romanian", "label": "Română" }, + { "code": "ru", "file": "ru_RU", "name": "Russian", "label": "Русский" }, + { + "code": "sr", + "file": "sr_SP", + "name": "Serbian (Cyrillic script)", + "label": "Српски" + }, + { "code": "sk", "file": "sk_SK", "name": "Slovak", "label": "Slovenčina" }, + { + "code": "sl", + "file": "sl_SI", + "name": "Slovenian", + "label": "Slovenščina" + }, + { + "code": "es-ES", + "file": "es_ES", + "name": "Spanish (Spain)", + "label": "Español" + }, + { "code": "sv-SE", "file": "sv_SE", "name": "Swedish", "label": "Svenska" }, + { "code": "th", "file": "th_TH", "name": "Thai", "label": "ไทย" }, + { "code": "tr", "file": "tr_TR", "name": "Turkish", "label": "Türkçe" }, + { + "code": "uk", + "file": "uk_UA", + "name": "Ukrainian", + "label": "Українська" + }, + { + "code": "vi", + "file": "vi_VN", + "name": "Vietnamese", + "label": "Tiếng Việt" + } +] diff --git a/src/ui/settings/appearance/appearance-controls.tsx b/src/ui/settings/appearance/appearance-controls.tsx index 823c029bf..88ca3051d 100644 --- a/src/ui/settings/appearance/appearance-controls.tsx +++ b/src/ui/settings/appearance/appearance-controls.tsx @@ -14,6 +14,7 @@ import { UI_FONTS, } from "@/lib/theme"; import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; +import { LANGUAGES } from "@/i18n/languages"; import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api"; import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types"; @@ -48,44 +49,6 @@ const THEME_PALETTES: Record< gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" }, }; -export const LANGUAGES = [ - { code: "en", label: "English" }, - { code: "af", label: "Afrikaans" }, - { code: "ar", label: "العربية" }, - { code: "bn", label: "বাংলা" }, - { code: "bg", label: "Български" }, - { code: "ca", label: "Català" }, - { code: "zh-CN", label: "中文 (简体)" }, - { code: "zh-TW", label: "中文 (繁體)" }, - { code: "cs", label: "Čeština" }, - { code: "da", label: "Dansk" }, - { code: "nl", label: "Nederlands" }, - { code: "fi", label: "Suomi" }, - { code: "fr", label: "Français" }, - { code: "de", label: "Deutsch" }, - { code: "el", label: "Ελληνικά" }, - { code: "he", label: "עברית" }, - { code: "hi", label: "हिन्दी" }, - { code: "hu", label: "Magyar" }, - { code: "id", label: "Indonesia" }, - { code: "it", label: "Italiano" }, - { code: "ja", label: "日本語" }, - { code: "ko", label: "한국어" }, - { code: "no", label: "Norsk" }, - { code: "pl", label: "Polski" }, - { code: "pt-PT", label: "Português (PT)" }, - { code: "pt-BR", label: "Português (BR)" }, - { code: "ro", label: "Română" }, - { code: "ru", label: "Русский" }, - { code: "sr", label: "Српски" }, - { code: "es-ES", label: "Español" }, - { code: "sv-SE", label: "Svenska" }, - { code: "th", label: "ไทย" }, - { code: "tr", label: "Türkçe" }, - { code: "uk", label: "Українська" }, - { code: "vi", label: "Tiếng Việt" }, -]; - export const THEME_LABEL_KEYS: Record = { system: "newUi.sidebar.userProfile.themeSystem", light: "newUi.sidebar.userProfile.themeLight", diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 0665d911b..6d7a029e5 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -66,7 +66,8 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types"; import { toast } from "sonner"; import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { Select2 } from "@/components/select2"; -import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls"; +import { THEMES } from "@/settings/appearance/appearance-controls"; +import { LANGUAGES } from "@/i18n/languages"; import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences"; import { reportBetaFeedbackUrl } from "@/lib/issue-url"; import { isPrerelease } from "@/plugins/plugin-model"; diff --git a/src/ui/tests/i18n/i18n.test.ts b/src/ui/tests/i18n/i18n.test.ts index ae8f95db3..7e5482f9e 100644 --- a/src/ui/tests/i18n/i18n.test.ts +++ b/src/ui/tests/i18n/i18n.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it, beforeEach } from "vitest"; import { changeAppLanguage, consumeLoginLanguage, + LOCALE_FILES, normalizeLanguageCode, rememberLoginLanguage, } from "../../i18n/i18n"; +import { LANGUAGES, TRANSLATED_LANGUAGES } from "../../i18n/languages"; describe("i18n language handling", () => { beforeEach(() => { @@ -20,6 +22,34 @@ describe("i18n language handling", () => { expect(normalizeLanguageCode("unknown")).toBe("en"); }); + it("maps regional variants to the closest shipped language", () => { + expect(normalizeLanguageCode("es-MX")).toBe("es-ES"); + expect(normalizeLanguageCode("es")).toBe("es-ES"); + expect(normalizeLanguageCode("sv")).toBe("sv-SE"); + expect(normalizeLanguageCode("pt")).toBe("pt-BR"); + expect(normalizeLanguageCode("pt-AO")).toBe("pt-BR"); + expect(normalizeLanguageCode("zh")).toBe("zh-CN"); + expect(normalizeLanguageCode("zh-HK")).toBe("zh-TW"); + expect(normalizeLanguageCode("zh-Hant-TW")).toBe("zh-TW"); + expect(normalizeLanguageCode("nb-NO")).toBe("no"); + expect(normalizeLanguageCode("de-AT")).toBe("de"); + expect(normalizeLanguageCode("sk-SK")).toBe("sk"); + }); + + it("ships a translated file for every listed language", () => { + const files = new Set( + Object.keys(import.meta.glob("../../locales/translated/*.json")).map( + (path) => path.split("/").pop()?.replace(".json", ""), + ), + ); + for (const language of TRANSLATED_LANGUAGES) { + expect(LOCALE_FILES[language.code]).toBe(language.file); + expect(files.has(language.file), language.file).toBe(true); + } + expect(LANGUAGES[0]).toEqual({ code: "en", label: "English" }); + expect(LANGUAGES).toHaveLength(TRANSLATED_LANGUAGES.length + 1); + }); + it("stores the normalized language after a successful switch", async () => { await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN"); expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");