Kord/apps/bot/src/i18n/index.ts

152 lines
4.6 KiB
TypeScript

/**
* 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<SupportedLocale, TranslationSchema> = { 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, string>,
): 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<string, unknown>)[part];
}
return typeof current === 'string' ? current : undefined;
}
// ── Re-exports ─────────────────────────────────────────────
export { SupportedLocale, DEFAULT_LOCALE, SUPPORTED_LOCALES } from './types';
export type { I18nProvider, LocaleResolutionOptions, TranslationSchema } from './types';