i18n.js
1 // RESTai internationalization setup. 2 // 3 // Language resolution order: 4 // 1. Authenticated user.options.language (applied from JWTAuthContext 5 // once whoami resolves — see `applyLanguage` below) 6 // 2. localStorage `restai_language` (set by the topbar picker and by 7 // applyLanguage for pre-login continuity across sessions) 8 // 3. `navigator.language` via the language-detector 9 // 4. `en` (fallback + default) 10 // 11 // Locale files live at ./locales/<code>/translation.json. Keep the key 12 // tree scoped by page/area (e.g. `dashboard.cardProjects`) so hunting 13 // strings during translation is easy. 14 15 import i18n from "i18next"; 16 import { initReactI18next } from "react-i18next"; 17 import LanguageDetector from "i18next-browser-languagedetector"; 18 19 import en from "./locales/en/translation.json"; 20 import ptPT from "./locales/pt-PT/translation.json"; 21 import zhCN from "./locales/zh-CN/translation.json"; 22 import da from "./locales/da/translation.json"; 23 import de from "./locales/de/translation.json"; 24 import es from "./locales/es/translation.json"; 25 import fr from "./locales/fr/translation.json"; 26 import it from "./locales/it/translation.json"; 27 import nl from "./locales/nl/translation.json"; 28 29 export const SUPPORTED_LANGUAGES = [ 30 { code: "en", label: "English", nativeLabel: "English" }, 31 { code: "da", label: "Danish", nativeLabel: "Dansk" }, 32 { code: "de", label: "German", nativeLabel: "Deutsch" }, 33 { code: "es", label: "Spanish", nativeLabel: "Español" }, 34 { code: "fr", label: "French", nativeLabel: "Français" }, 35 { code: "it", label: "Italian", nativeLabel: "Italiano" }, 36 { code: "nl", label: "Dutch", nativeLabel: "Nederlands" }, 37 { code: "pt-PT", label: "Portuguese (Portugal)", nativeLabel: "Português" }, 38 { code: "zh-CN", label: "Chinese (Simplified)", nativeLabel: "中文" }, 39 ]; 40 41 // Resolve the initial language BEFORE init() so i18next builds the 42 // fallback chain (`languages` array) against a supported code from 43 // the start. Without this, a browser locale like "en-US" lands in 44 // `i18n.languages = ["en"]` and later `changeLanguage("pt-PT")` fails 45 // to rebuild the chain — the translator keeps resolving through "en" 46 // and returns English strings despite i18n.language having changed. 47 const SUPPORTED_CODES = SUPPORTED_LANGUAGES.map((l) => l.code); 48 function pickInitialLanguage() { 49 try { 50 const fromLs = typeof localStorage !== "undefined" && localStorage.getItem("restai_language"); 51 if (fromLs && SUPPORTED_CODES.includes(fromLs)) return fromLs; 52 } catch {} 53 const nav = typeof navigator !== "undefined" && (navigator.language || ""); 54 if (nav) { 55 if (SUPPORTED_CODES.includes(nav)) return nav; 56 const base = nav.split("-")[0]; 57 const match = SUPPORTED_CODES.find((c) => c.split("-")[0] === base); 58 if (match) return match; 59 } 60 return "en"; 61 } 62 63 i18n 64 .use(LanguageDetector) 65 .use(initReactI18next) 66 .init({ 67 resources: { 68 "en": { translation: en }, 69 "da": { translation: da }, 70 "de": { translation: de }, 71 "es": { translation: es }, 72 "fr": { translation: fr }, 73 "it": { translation: it }, 74 "nl": { translation: nl }, 75 "pt-PT": { translation: ptPT }, 76 "zh-CN": { translation: zhCN }, 77 }, 78 lng: pickInitialLanguage(), 79 fallbackLng: "en", 80 supportedLngs: SUPPORTED_CODES, 81 // DO NOT enable nonExplicitSupportedLngs — with supportedLngs 82 // containing only full region codes (pt-PT, zh-CN), it strips 83 // "pt-PT" → "pt" before the supportedLngs check and rejects it, 84 // then toResolveHierarchy collapses to just the fallback "en" and 85 // every t() returns English no matter what i18n.language says. 86 // Base-code browser locales (eg. "pt") are already normalized in 87 // pickInitialLanguage() above, so this flag is redundant anyway. 88 load: "currentOnly", 89 interpolation: { escapeValue: false }, // React already escapes 90 detection: { 91 order: ["localStorage", "navigator"], 92 lookupLocalStorage: "restai_language", 93 caches: ["localStorage"], 94 }, 95 react: { 96 useSuspense: false, 97 bindI18n: "languageChanged loaded", 98 }, 99 }); 100 101 /** 102 * Apply a language choice end-to-end: switch i18next, persist to 103 * localStorage so the pre-login screens pick it up too. Call this 104 * from (a) the topbar picker onChange and (b) JWTAuthContext after 105 * whoami resolves with a user who has `options.language` set. 106 */ 107 export function applyLanguage(lang) { 108 if (!lang) return; 109 try { localStorage.setItem("restai_language", lang); } catch {} 110 if (i18n.language !== lang) i18n.changeLanguage(lang); 111 } 112 113 export default i18n;