mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
a965b2c75e
isomorphic-dompurify builds a jsdom window as soon as it loads on Node, and the root barrel is what the server and each of its ~900 test files import, so every one of them loaded jsdom without ever sanitising. The sanitisers move behind @trek/shared/sanitize (a build entry of its own, like roadtrip), escapeHtml stays in the root from its own module, and the client's translation context imports the sanitiser from the subpath. The production server stops loading jsdom at boot as well.
162 lines
6.8 KiB
TypeScript
162 lines
6.8 KiB
TypeScript
import { 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,
|
|
} from '@trek/shared'
|
|
import { sanitizeInlineHtml } from '@trek/shared/sanitize'
|
|
import type { TranslationStrings } from '@trek/shared/i18n'
|
|
import { importChunk } from '../utils/chunkReload'
|
|
import { resolveTemplate } from './resolveTemplate'
|
|
|
|
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'),
|
|
et: () => import('@trek/shared/i18n/et'),
|
|
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'),
|
|
az: () => import('@trek/shared/i18n/az'),
|
|
br: () => import('@trek/shared/i18n/br'),
|
|
cs: () => import('@trek/shared/i18n/cs'),
|
|
sk: () => import('@trek/shared/i18n/sk'),
|
|
pl: () => import('@trek/shared/i18n/pl'),
|
|
ja: () => import('@trek/shared/i18n/ja'),
|
|
ko: () => import('@trek/shared/i18n/ko'),
|
|
th: () => import('@trek/shared/i18n/th'),
|
|
uk: () => import('@trek/shared/i18n/uk'),
|
|
gr: () => import('@trek/shared/i18n/gr'),
|
|
sv: () => import('@trek/shared/i18n/sv'),
|
|
vi: () => import('@trek/shared/i18n/vi'),
|
|
ca: () => import('@trek/shared/i18n/ca'),
|
|
}
|
|
|
|
// 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 }
|
|
|
|
export { detectBrowserLanguage } from './detectBrowserLanguage'
|
|
|
|
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'
|
|
// The catalogue together with the language it belongs to: while another
|
|
// language loads, or when its chunk fails, the strings on screen are still
|
|
// the previous ones, and their plural forms follow that language's rule.
|
|
const [catalogue, setCatalogue] = useState<{ language: string; strings: TranslationStrings }>({ language: 'en', strings: en })
|
|
|
|
useEffect(() => {
|
|
document.documentElement.lang = getIntlLanguage(language)
|
|
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
|
}, [language])
|
|
|
|
useEffect(() => {
|
|
const loader = localeLoaders[language as SupportedLanguageCode]
|
|
if (!loader) return
|
|
|
|
let cancelled = false
|
|
importChunk(loader).then(mod => {
|
|
// The same table for the same language keeps the state, and with it t():
|
|
// English arrives as the very table the provider started with.
|
|
if (!cancelled) {
|
|
setCatalogue(prev => (prev.language === language && prev.strings === mod.default ? prev : { language, strings: mod.default }))
|
|
}
|
|
}).catch(err => {
|
|
// The locale chunk can be gone after a deploy. Keep the strings we have —
|
|
// an untranslated UI beats an unhandled rejection and a blank screen.
|
|
console.error('[i18n] locale chunk failed to load', err)
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [language])
|
|
|
|
const value = useMemo((): TranslationContextValue => {
|
|
const intlLanguage = getIntlLanguage(catalogue.language)
|
|
const template = (key: string, params?: Record<string, string | number>): string =>
|
|
resolveTemplate(catalogue.strings, intlLanguage, key, params)
|
|
|
|
function t(key: string, params?: Record<string, string | number>): string {
|
|
let val = template(key, params)
|
|
if (params) {
|
|
Object.entries(params).forEach(([k, v]) => {
|
|
// Function replacement: a value carrying `$&` or `$1` (a trip named
|
|
// "A$&B") would otherwise be expanded as a replacement pattern.
|
|
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), () => String(v))
|
|
})
|
|
}
|
|
return val
|
|
}
|
|
|
|
function tHtml(key: string, params?: Record<string, string | number>): string {
|
|
let val = template(key, params)
|
|
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) }
|
|
}, [catalogue, language])
|
|
|
|
return <TranslationContext value={value}>{children}</TranslationContext>
|
|
}
|
|
|
|
export function useTranslation(): TranslationContextValue {
|
|
return useContext(TranslationContext)
|
|
}
|