152 lines
4.6 KiB
TypeScript
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';
|