diff --git a/src/i18n/config.js b/src/i18n/config.js index 6fc2b425c..73d678fd4 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -5,21 +5,23 @@ import es from './locales/es/translations.json' import zh from './locales/zh/translations.json' import ptBR from './locales/pt-BR/translations.json' +import { getStoredLanguage, getBrowserLanguage, resolveLanguage, DEFAULT_LANGUAGE } from './language' + const LANGUAGE_STORAGE_KEY = 'ocl.language' const SUPPORTED_LANGUAGES = ['en', 'es', 'zh', 'pt-BR'] +// Priority: manual choice (localStorage) > browser preference > default. const getInitialLanguage = () => { if(typeof window === 'undefined') - return 'en' + return DEFAULT_LANGUAGE - try { - const storedLanguage = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) - return SUPPORTED_LANGUAGES.includes(storedLanguage) ? storedLanguage : 'en' - } catch(_) { - return 'en' - } + return resolveLanguage([ + () => getStoredLanguage(window.localStorage, LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES), + () => getBrowserLanguage(window.navigator, SUPPORTED_LANGUAGES) + ]) } +// Registered after init, so only user-triggered changes are persisted, not the detected initial language. const persistLanguage = language => { try { window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language) @@ -28,6 +30,11 @@ const persistLanguage = language => { } } +const applyDocumentLanguage = language => { + if(typeof document !== 'undefined') + document.documentElement.lang = language +} + i18n.use(initReactI18next).init({ fallbackLng: 'en', lng: getInitialLanguage(), @@ -50,6 +57,8 @@ i18n.use(initReactI18next).init({ }); i18n.languages = SUPPORTED_LANGUAGES; +applyDocumentLanguage(i18n.language) i18n.on('languageChanged', persistLanguage) +i18n.on('languageChanged', applyDocumentLanguage) export default i18n; diff --git a/src/i18n/language.js b/src/i18n/language.js new file mode 100644 index 000000000..6bb048246 --- /dev/null +++ b/src/i18n/language.js @@ -0,0 +1,51 @@ +export const DEFAULT_LANGUAGE = 'en' + +const baseOf = tag => tag.split('-')[0] + +/* + * Maps a BCP 47 tag (e.g. "pt-PT", "es_MX", "en-US") to one of the supported locales. + * An exact match wins; otherwise the primary language code is matched, so any regional + * variant of a supported language (pt-PT, es-AR, zh-TW, ...) resolves to it. + */ +export const matchLanguage = (tag, supported) => { + if(typeof tag !== 'string' || !tag) + return undefined + + const normalized = tag.trim().replace('_', '-').toLowerCase() + return supported.find(locale => locale.toLowerCase() === normalized) || + supported.find(locale => baseOf(locale.toLowerCase()) === baseOf(normalized)) +} + +export const getStoredLanguage = (storage, key, supported) => { + try { + const stored = storage.getItem(key) + return supported.includes(stored) ? stored : undefined + } catch(_) { + return undefined // storage unavailable (e.g. blocked or private mode) + } +} + +// navigator.languages is ordered by preference; the first tag that maps to a supported locale wins. +export const getBrowserLanguage = (nav, supported) => { + const tags = nav?.languages?.length ? nav.languages : [nav?.language] + for(const tag of tags) { + const match = matchLanguage(tag, supported) + if(match) + return match + } + return undefined +} + +/* + * Runs the sources in priority order and returns the first locale found. + * Each source is a function returning a supported locale or undefined, so a new + * signal is added by appending one entry to the list. + */ +export const resolveLanguage = (sources, fallback = DEFAULT_LANGUAGE) => { + for(const source of sources) { + const language = source() + if(language) + return language + } + return fallback +}