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"; 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; const translatedFiles = import.meta.glob( "../locales/translated/*.json", ); const localeLoaders: Record Promise> = 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; 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]; return supportedLngs.includes(baseLanguage) ? 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 as Record)[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 { 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;