mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
+1
-38
@@ -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,
|
||||
|
||||
+19
-41
@@ -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<string, string>;
|
||||
export const LOCALE_FILES: Record<string, string> = Object.fromEntries(
|
||||
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
|
||||
);
|
||||
|
||||
const translatedFiles = import.meta.glob<LocaleModule>(
|
||||
"../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<string, string>)[normalizedLanguage];
|
||||
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
|
||||
if (!pluginLocaleResolver || !file) {
|
||||
callback(null, {});
|
||||
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
|
||||
|
||||
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,
|
||||
} 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<ThemeId, string> = {
|
||||
system: "newUi.sidebar.userProfile.themeSystem",
|
||||
light: "newUi.sidebar.userProfile.themeLight",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user