mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: AI translations at release, 7 new languages and regional fallback
This commit is contained in:
1 parent
6873bc92bb
commit
874ad9f4fd
11 files changed
+356
-134
No files matched your search
+109
-14
@@ -161,15 +161,62 @@ jobs:
|
|||||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||||
echo "prev_ref=$PREV" >> "$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]
|
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' }}
|
if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout beta commit
|
- name: Checkout beta commit
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.sha }}
|
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -197,7 +244,7 @@ jobs:
|
|||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
normalize:
|
normalize:
|
||||||
needs: [prep]
|
needs: [prep, translate]
|
||||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
|
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
@@ -284,8 +331,8 @@ jobs:
|
|||||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||||
|
|
||||||
merge-to-main:
|
merge-to-main:
|
||||||
needs: [prep, normalize, sync-main]
|
needs: [prep, translate, 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') }}
|
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
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
outputs:
|
outputs:
|
||||||
main_sha: ${{ steps.merge.outputs.main_sha }}
|
main_sha: ${{ steps.merge.outputs.main_sha }}
|
||||||
@@ -353,14 +400,14 @@ jobs:
|
|||||||
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
docker:
|
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') }}
|
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
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prep.outputs.version }}
|
version: ${{ needs.prep.outputs.version }}
|
||||||
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
|
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
|
||||||
dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }}
|
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
|
secrets: inherit
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -418,7 +465,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create-beta-release:
|
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' }}
|
if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
@@ -427,7 +474,7 @@ jobs:
|
|||||||
- name: Checkout beta commit
|
- name: Checkout beta commit
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.sha }}
|
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -440,7 +487,7 @@ jobs:
|
|||||||
node scripts/generate-beta-body.cjs \
|
node scripts/generate-beta-body.cjs \
|
||||||
--version "${{ needs.prep.outputs.version }}" \
|
--version "${{ needs.prep.outputs.version }}" \
|
||||||
--branch "${{ needs.prep.outputs.dev_branch }}" \
|
--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
|
--prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md
|
||||||
|
|
||||||
- name: Create beta release
|
- name: Create beta release
|
||||||
@@ -456,11 +503,19 @@ jobs:
|
|||||||
else
|
else
|
||||||
gh release create "$TAG" --repo ${{ github.repository }} \
|
gh release create "$TAG" --repo ${{ github.repository }} \
|
||||||
--title "$TITLE" --notes-file BETA_BODY.md \
|
--title "$TITLE" --notes-file BETA_BODY.md \
|
||||||
--prerelease --target "${{ needs.prep.outputs.sha }}"
|
--prerelease --target "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
electron-release:
|
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' }}
|
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
|
uses: ./.github/workflows/electron.yml
|
||||||
with:
|
with:
|
||||||
@@ -468,7 +523,7 @@ jobs:
|
|||||||
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
|
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||||
version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }}
|
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
|
secrets: inherit
|
||||||
|
|
||||||
electron-submit:
|
electron-submit:
|
||||||
@@ -541,8 +596,48 @@ jobs:
|
|||||||
|
|
||||||
# The docs site reads the env catalog, API spec and download list from the
|
# The docs site reads the env catalog, API spec and download list from the
|
||||||
# newest stable release.
|
# 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]
|
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' }}
|
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# 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
|
## 26.10.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-38
@@ -59,6 +59,7 @@ import {
|
|||||||
normalizeLanguageCode,
|
normalizeLanguageCode,
|
||||||
rememberLoginLanguage,
|
rememberLoginLanguage,
|
||||||
} from "@/i18n/i18n";
|
} from "@/i18n/i18n";
|
||||||
|
import { LANGUAGES } from "@/i18n/languages";
|
||||||
import {
|
import {
|
||||||
removeSilentSigninFromSearch,
|
removeSilentSigninFromSearch,
|
||||||
shouldTriggerSilentSignin,
|
shouldTriggerSilentSignin,
|
||||||
@@ -66,44 +67,6 @@ import {
|
|||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
function LanguageRow({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
+19
-41
@@ -3,46 +3,14 @@ import { initReactI18next } from "react-i18next";
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
|
||||||
import enTranslation from "../locales/en.json";
|
import enTranslation from "../locales/en.json";
|
||||||
|
import { TRANSLATED_LANGUAGES } from "./languages";
|
||||||
|
|
||||||
type LocaleModule = { default: ResourceKey };
|
type LocaleModule = { default: ResourceKey };
|
||||||
|
|
||||||
/** i18n code -> translated file name, shared by core and plugin locales. */
|
/** i18n code -> translated file name, shared by core and plugin locales. */
|
||||||
export const LOCALE_FILES = {
|
export const LOCALE_FILES: Record<string, string> = Object.fromEntries(
|
||||||
af: "af_ZA",
|
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
|
||||||
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<string, string>;
|
|
||||||
|
|
||||||
const translatedFiles = import.meta.glob<LocaleModule>(
|
const translatedFiles = import.meta.glob<LocaleModule>(
|
||||||
"../locales/translated/*.json",
|
"../locales/translated/*.json",
|
||||||
@@ -94,8 +62,20 @@ export function normalizeLanguageCode(language?: string | null): string {
|
|||||||
);
|
);
|
||||||
if (exactMatch) return exactMatch;
|
if (exactMatch) return exactMatch;
|
||||||
|
|
||||||
const baseLanguage = normalized.split("-")[0];
|
const baseLanguage = normalized.split("-")[0].toLowerCase();
|
||||||
return supportedLngs.includes(baseLanguage) ? baseLanguage : "en";
|
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 = {
|
const localeBackend: BackendModule = {
|
||||||
@@ -106,9 +86,7 @@ const localeBackend: BackendModule = {
|
|||||||
|
|
||||||
if (namespace !== CORE_NAMESPACE) {
|
if (namespace !== CORE_NAMESPACE) {
|
||||||
const file =
|
const file =
|
||||||
normalizedLanguage === "en"
|
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
|
||||||
? "en"
|
|
||||||
: (LOCALE_FILES as Record<string, string>)[normalizedLanguage];
|
|
||||||
if (!pluginLocaleResolver || !file) {
|
if (!pluginLocaleResolver || !file) {
|
||||||
callback(null, {});
|
callback(null, {});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
];
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Translations
|
# 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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UI_FONTS,
|
UI_FONTS,
|
||||||
} from "@/lib/theme";
|
} from "@/lib/theme";
|
||||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||||
|
import { LANGUAGES } from "@/i18n/languages";
|
||||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||||
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||||
|
|
||||||
@@ -48,44 +49,6 @@ const THEME_PALETTES: Record<
|
|||||||
gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" },
|
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<ThemeId, string> = {
|
export const THEME_LABEL_KEYS: Record<ThemeId, string> = {
|
||||||
system: "newUi.sidebar.userProfile.themeSystem",
|
system: "newUi.sidebar.userProfile.themeSystem",
|
||||||
light: "newUi.sidebar.userProfile.themeLight",
|
light: "newUi.sidebar.userProfile.themeLight",
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||||
import { Select2 } from "@/components/select2";
|
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 { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
||||||
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
|
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
|
||||||
import { isPrerelease } from "@/plugins/plugin-model";
|
import { isPrerelease } from "@/plugins/plugin-model";
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { describe, expect, it, beforeEach } from "vitest";
|
|||||||
import {
|
import {
|
||||||
changeAppLanguage,
|
changeAppLanguage,
|
||||||
consumeLoginLanguage,
|
consumeLoginLanguage,
|
||||||
|
LOCALE_FILES,
|
||||||
normalizeLanguageCode,
|
normalizeLanguageCode,
|
||||||
rememberLoginLanguage,
|
rememberLoginLanguage,
|
||||||
} from "../../i18n/i18n";
|
} from "../../i18n/i18n";
|
||||||
|
import { LANGUAGES, TRANSLATED_LANGUAGES } from "../../i18n/languages";
|
||||||
|
|
||||||
describe("i18n language handling", () => {
|
describe("i18n language handling", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -20,6 +22,34 @@ describe("i18n language handling", () => {
|
|||||||
expect(normalizeLanguageCode("unknown")).toBe("en");
|
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 () => {
|
it("stores the normalized language after a successful switch", async () => {
|
||||||
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
|
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
|
||||||
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
|
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
|
||||||
|
|||||||
Reference in new issue
Block a user