91 lines
3.4 KiB
TypeScript
91 lines
3.4 KiB
TypeScript
import i18n from 'i18next'
|
|
import { initReactI18next } from 'react-i18next'
|
|
import en from './en.json'
|
|
|
|
// Typed tokens: every `t('key')` is checked against en.json's shape (i18next
|
|
// flattens it into dot-path keys), so a typo or removed key is a compile error.
|
|
// Other locale files are loaded dynamically and are not key-checked against en;
|
|
// a missing key falls back to the default locale at runtime.
|
|
declare module 'i18next' {
|
|
interface CustomTypeOptions {
|
|
defaultNS: 'translation'
|
|
resources: {
|
|
translation: typeof en
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add a language: drop `i18n/<lang>.json` in this folder — Vite's
|
|
// import.meta.glob auto-registers it below into `resources`, and
|
|
// `supportedLocales` is derived from the files present, so no edits are needed
|
|
// here. Content is reachable via the `/<lang>` URL prefix (e.g. `/de`, `/es`);
|
|
// the default locale (`en`) needs no prefix.
|
|
const localeModules = import.meta.glob('./*.json', { eager: true }) as Record<
|
|
string,
|
|
{ default: Record<string, unknown> }
|
|
>
|
|
|
|
const resources: Record<string, { translation: Record<string, unknown> }> = {}
|
|
for (const path in localeModules) {
|
|
const code = path.slice(path.lastIndexOf('/') + 1).replace(/\.json$/, '')
|
|
resources[code] = { translation: localeModules[path].default }
|
|
}
|
|
|
|
export const supportedLocales = Object.keys(resources)
|
|
export type Locale = string
|
|
export const defaultLocale = 'en'
|
|
|
|
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
|
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
|
// on <html dir> at the root so the browser mirrors the layout; see the
|
|
// pikku-rtl skill. Returns 'ltr' while the app is English-only.
|
|
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
|
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
|
}
|
|
|
|
export function detectLocale(pathname: string): Locale {
|
|
const segment = pathname.split('/')[1]
|
|
if (supportedLocales.includes(segment as Locale)) {
|
|
return segment as Locale
|
|
}
|
|
if (typeof navigator !== 'undefined') {
|
|
const browserLang = navigator.language?.split('-')[0]
|
|
if (supportedLocales.includes(browserLang as Locale)) {
|
|
return browserLang as Locale
|
|
}
|
|
}
|
|
return defaultLocale
|
|
}
|
|
|
|
// i18n debug mode: when enabled, every *translated* string is masked to block
|
|
// glyphs (█), so any readable text left on screen is text that never went
|
|
// through a token — a hardcoded/inlined string. Surfaces missing i18n at a
|
|
// glance. Toggle with `?i18n-debug` in the URL or `localStorage['i18n-debug']
|
|
// = '1'` (or `I18N_DEBUG=1` for server-rendered builds). Off by default.
|
|
export function isI18nDebug(): boolean {
|
|
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
|
if (typeof window === 'undefined') return false
|
|
const params = new URLSearchParams(window.location.search)
|
|
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
|
return window.localStorage?.getItem('i18n-debug') === '1'
|
|
}
|
|
|
|
const i18nDebugPostProcessor = {
|
|
type: 'postProcessor' as const,
|
|
name: 'i18nDebug',
|
|
process: (value: string) => (isI18nDebug() ? value.replace(/\S/g, '█') : value),
|
|
}
|
|
|
|
i18n
|
|
.use(initReactI18next)
|
|
.use(i18nDebugPostProcessor)
|
|
.init({
|
|
resources,
|
|
lng: typeof window !== 'undefined' ? detectLocale(window.location.pathname) : defaultLocale,
|
|
fallbackLng: defaultLocale,
|
|
interpolation: { escapeValue: false },
|
|
postProcess: ['i18nDebug'],
|
|
})
|
|
|
|
export default i18n
|