feat: AI translations at release, 7 new languages and regional fallback

This commit is contained in:
LukeGus committed 2026-10-11 00:19:05 -05:00
1 parent 6873bc92bb
commit 874ad9f4fd
11 files changed
+356 -134

No files matched your search

+1 -38
View File
@@ -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
View File
@@ -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;
+17
View File
@@ -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 })),
];
+6 -2
View File
@@ -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.
+103
View File
@@ -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",
+2 -1
View File
@@ -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";
+30
View File
@@ -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");