Spaces:
Sleeping
Sleeping
File size: 6,216 Bytes
cd99321 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import en from '@trek/shared/i18n/en'
import type { SupportedLanguageCode } from '@trek/shared'
import {
SUPPORTED_LANGUAGES,
getLocaleForLanguage,
getIntlLanguage,
isRtlLanguage,
escapeHtml,
sanitizeInlineHtml,
} from '@trek/shared'
import type { TranslationStrings } from '@trek/shared/i18n'
export { SUPPORTED_LANGUAGES }
// One explicit dynamic import per locale — Vite code-splits a separate chunk per locale.
// Only the active locale is fetched; en is always available synchronously as the fallback.
const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: TranslationStrings }>> = {
en: () => Promise.resolve({ default: en }),
de: () => import('@trek/shared/i18n/de'),
es: () => import('@trek/shared/i18n/es'),
fr: () => import('@trek/shared/i18n/fr'),
hu: () => import('@trek/shared/i18n/hu'),
it: () => import('@trek/shared/i18n/it'),
tr: () => import('@trek/shared/i18n/tr'),
ru: () => import('@trek/shared/i18n/ru'),
zh: () => import('@trek/shared/i18n/zh'),
'zh-TW': () => import('@trek/shared/i18n/zh-TW'),
nl: () => import('@trek/shared/i18n/nl'),
id: () => import('@trek/shared/i18n/id'),
ar: () => import('@trek/shared/i18n/ar'),
br: () => import('@trek/shared/i18n/br'),
cs: () => import('@trek/shared/i18n/cs'),
pl: () => import('@trek/shared/i18n/pl'),
ja: () => import('@trek/shared/i18n/ja'),
ko: () => import('@trek/shared/i18n/ko'),
uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'),
}
// Re-export pure helpers that live in shared so downstream consumers can import them
// through this module without changing their import path.
export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage }
// Detects the user's preferred language from browser/OS settings.
// Returns null if no supported language matches.
export function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const browserLangs = navigator.languages?.length
? navigator.languages
: navigator.language ? [navigator.language] : []
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
for (const lang of browserLangs) {
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
if (exactMatch) return exactMatch
// pt-BR has no exact match (our code is 'br'), so map it explicitly.
// pt-PT and bare 'pt' are NOT mapped — they fall through to null.
if (lang.toLowerCase() === 'pt-br') return 'br'
const prefix = lang.split('-')[0]?.toLowerCase()
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
if (prefixMatch) return prefixMatch
}
return null
}
interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string
/**
* HTML-aware variant of `t()`. Use ONLY when the translated template
* legitimately contains markup (e.g. `'Turn <strong>{title}</strong> into a Journey'`).
*
* Defence in depth, two layers:
* 1. Every interpolated param is HTML-escaped before substitution, so a
* user-controlled value like `<script>` cannot inject markup at all.
* 2. The fully-substituted string is then passed through
* `sanitizeInlineHtml`, so even if a translator ships a malformed
* template the runtime output is still tag-restricted.
*
* Prefer the `<TransHtml>` component for the typical "translate + render"
* pattern; reach for `tHtml()` directly only when you need the raw string
* (e.g. constructing an `aria-label`).
*/
tHtml: (key: string, params?: Record<string, string | number>) => string
language: string
locale: string
}
const TranslationContext = createContext<TranslationContextValue>({
t: (k: string) => k,
tHtml: (k: string) => k,
language: 'en',
locale: 'en-US',
})
interface TranslationProviderProps {
children: ReactNode
}
export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'en'
const [strings, setStrings] = useState<TranslationStrings>(en)
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
}, [language])
useEffect(() => {
const loader = localeLoaders[language as SupportedLanguageCode]
if (!loader) return
let cancelled = false
loader().then(mod => {
if (!cancelled) setStrings(mod.default)
})
return () => { cancelled = true }
}, [language])
const value = useMemo((): TranslationContextValue => {
function t(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
})
}
return val
}
function tHtml(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
// Escape BEFORE substitution so a user-controlled value with `<` or
// `&` cannot break out of the surrounding template's markup.
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), escapeHtml(String(v)))
})
}
// Then re-sanitise the fully-built string: even if a translator ships a
// template with stray `<script>` or `onclick`, the rendered output is
// restricted to the inline tag allow-list.
return sanitizeInlineHtml(val)
}
return { t, tHtml, language, locale: getLocaleForLanguage(language) }
}, [strings, language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation(): TranslationContextValue {
return useContext(TranslationContext)
}
|