/** * Kord i18n Core Module. * * Provides the `t()` translation function and `resolveLocale()` utility. * Uses the I18nProvider interface so the backend can be swapped * (e.g. from static TS files to DB/API-based provider). */ import { SupportedLocale, SUPPORTED_LOCALES, DEFAULT_LOCALE, I18nProvider, LocaleResolutionOptions, TranslationSchema, } from './types'; import { en } from './locales/en'; import { ko } from './locales/ko'; // ── Static Provider (Default) ────────────────────────────── const localeMap: Record = { en, ko }; /** * Default translation provider backed by static TypeScript objects. * Implements I18nProvider for future swap-ability. */ class StaticI18nProvider implements I18nProvider { get(locale: SupportedLocale, key: string): string | undefined { return getNestedValue(localeMap[locale], key); } isSupported(locale: string): locale is SupportedLocale { return SUPPORTED_LOCALES.includes(locale as SupportedLocale); } getSupportedLocales(): readonly SupportedLocale[] { return SUPPORTED_LOCALES; } } // ── Active Provider (swappable) ──────────────────────────── let activeProvider: I18nProvider = new StaticI18nProvider(); /** * Replace the active translation provider. * Call this at boot time to swap to a different backend. */ export function setI18nProvider(provider: I18nProvider): void { activeProvider = provider; } /** Get the active translation provider (for testing or advanced use). */ export function getI18nProvider(): I18nProvider { return activeProvider; } // ── Locale Resolution ────────────────────────────────────── /** * Determine the locale to use, with priority: * 1. User DB setting * 2. Guild DB setting * 3. Discord interaction.locale (auto-detect) * 4. Default ('en') */ export function resolveLocale(options?: LocaleResolutionOptions): SupportedLocale { const candidates = [ options?.userLocale, options?.guildLocale, normalizeDiscordLocale(options?.discordLocale), ]; for (const candidate of candidates) { if (candidate && activeProvider.isSupported(candidate)) { return candidate as SupportedLocale; } } return DEFAULT_LOCALE; } /** * Normalize Discord locale strings like 'ko' or 'en-US' to our format. * Discord sends BCP47 tags; we use the language part only. */ function normalizeDiscordLocale(locale?: string): string | undefined { if (!locale) return undefined; // 'en-US' → 'en', 'ko' → 'ko', 'zh-CN' → 'zh' return locale.split('-')[0].toLowerCase(); } // ── Translation Function ─────────────────────────────────── /** * Get a translated string by dot-notation key. * * @param locale - Target locale * @param key - Dot-notation key (e.g. 'voice.responses.channelLocked') * @param vars - Template variables (e.g. { user: '<@123>' }) * @returns Translated string with variables interpolated * * Fallback order: * 1. Target locale value * 2. Default locale (en) value * 3. The key itself (last resort) */ export function t( locale: SupportedLocale, key: string, vars?: Record, ): string { let value = activeProvider.get(locale, key) ?? activeProvider.get(DEFAULT_LOCALE, key) ?? key; if (vars) { for (const [k, v] of Object.entries(vars)) { value = value.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v); } } return value; } // ── Helpers ──────────────────────────────────────────────── /** * Retrieve a nested value from an object using dot-notation key. * e.g. getNestedValue(obj, 'voice.responses.channelLocked') */ function getNestedValue(obj: unknown, key: string): string | undefined { const parts = key.split('.'); let current: unknown = obj; for (const part of parts) { if (current === null || current === undefined || typeof current !== 'object') { return undefined; } current = (current as Record)[part]; } return typeof current === 'string' ? current : undefined; } // ── Re-exports ───────────────────────────────────────────── export { SupportedLocale, DEFAULT_LOCALE, SUPPORTED_LOCALES } from './types'; export type { I18nProvider, LocaleResolutionOptions, TranslationSchema } from './types';