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/.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 `/` 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 } > const resources: Record }> = {} 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 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