Files
Termix/src/ui/i18n/i18n.ts
T
62ac931468 v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
2026-10-12 11:35:48 -05:00

174 lines
5.1 KiB
TypeScript

import i18n, { type BackendModule, type ResourceKey } from "i18next";
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: Record<string, string> = Object.fromEntries(
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
);
const translatedFiles = import.meta.glob<LocaleModule>(
"../locales/translated/*.json",
);
const localeLoaders: Record<string, () => Promise<LocaleModule>> =
Object.fromEntries(
Object.entries(LOCALE_FILES).map(([code, file]) => [
code,
() => {
const load = translatedFiles[`../locales/translated/${file}.json`];
return load ? load() : Promise.reject(new Error(`no ${file}`));
},
]),
);
/**
* Loads a plugin namespace. Set by the plugin loader; until then a plugin
* namespace resolves to nothing and keys fall back to core.
*/
export type PluginLocaleResolver = (
namespace: string,
language: string,
file: string,
) => Promise<ResourceKey | null>;
let pluginLocaleResolver: PluginLocaleResolver | null = null;
export function setPluginLocaleResolver(
resolver: PluginLocaleResolver | null,
): void {
pluginLocaleResolver = resolver;
}
export const supportedLngs = ["en", ...Object.keys(localeLoaders)];
const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language";
/** Core strings. Each plugin gets a namespace named after its id. */
export const CORE_NAMESPACE = "translation";
export function normalizeLanguageCode(language?: string | null): string {
if (!language) return "en";
const normalized = language.replaceAll("_", "-");
if (supportedLngs.includes(normalized)) return normalized;
const exactMatch = supportedLngs.find(
(supported) => supported.toLowerCase() === normalized.toLowerCase(),
);
if (exactMatch) return exactMatch;
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 = {
type: "backend",
init: () => {},
read: (language, namespace, callback) => {
const normalizedLanguage = normalizeLanguageCode(language);
if (namespace !== CORE_NAMESPACE) {
const file =
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
if (!pluginLocaleResolver || !file) {
callback(null, {});
return;
}
pluginLocaleResolver(namespace, normalizedLanguage, file)
.then((resources) => callback(null, resources ?? {}))
.catch(() => callback(null, {}));
return;
}
if (normalizedLanguage === "en") {
callback(null, enTranslation);
return;
}
const loadLocale = localeLoaders[normalizedLanguage];
if (!loadLocale) {
callback(null, enTranslation);
return;
}
loadLocale()
.then((module) => callback(null, module.default))
.catch(() => callback(null, enTranslation));
},
};
i18n
.use(localeBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
supportedLngs,
fallbackLng: "en",
// A plugin's namespace falls back to core, so plugins keep using the
// shared strings (common.*, hosts.*) without copying them.
fallbackNS: CORE_NAMESPACE,
debug: false,
detection: {
order: ["localStorage", "cookie"],
caches: ["localStorage", "cookie"],
lookupLocalStorage: "i18nextLng",
lookupCookie: "i18nextLng",
},
resources: {
en: {
translation: enTranslation,
},
},
partialBundledLanguages: true,
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
// Repaint when a plugin enabled later loads its strings.
bindI18n: "languageChanged loaded",
},
});
export async function changeAppLanguage(language: string): Promise<string> {
const normalizedLanguage = normalizeLanguageCode(language);
await i18n.changeLanguage(normalizedLanguage);
localStorage.setItem("i18nextLng", normalizedLanguage);
return normalizedLanguage;
}
export function rememberLoginLanguage(language: string): string {
const normalizedLanguage = normalizeLanguageCode(language);
sessionStorage.setItem(PENDING_LOGIN_LANGUAGE_KEY, normalizedLanguage);
return normalizedLanguage;
}
export function consumeLoginLanguage(): string | null {
const language = sessionStorage.getItem(PENDING_LOGIN_LANGUAGE_KEY);
sessionStorage.removeItem(PENDING_LOGIN_LANGUAGE_KEY);
return language ? normalizeLanguageCode(language) : null;
}
export default i18n;