import { format } from '@douglasneuroinformatics/libjs'; import { get } from 'lodash-es'; import type { SetOptional } from 'type-fest'; import libui from './translations/libui.json' with { type: 'json' }; import type { Language, TranslateFormatArgs, TranslateOptions, TranslationKey, Translations, TranslatorType } from './types.ts'; type TranslatorEventMap = { languageChange: (...args: [language: Language]) => void; }; type TranslatorConfig = { defaultLanguage?: Language; translations: SetOptional; }; function InitializedOnly( target: (this: T, ...args: TArgs) => TReturn, context: ClassGetterDecoratorContext | ClassMethodDecoratorContext | ClassSetterDecoratorContext ) { const name = context.name.toString(); function replacementMethod(this: T, ...args: TArgs): TReturn { if (!this.isInitialized) { throw new Error(`Cannot access ${context.kind} '${name}' of Translator instance before initialization`); } return target.call(this, ...args); } return replacementMethod; } export class Translator implements TranslatorType { #config: Required; #eventHandlers: { [K in keyof TranslatorEventMap]: Set; }; #resolvedLanguage: Language; constructor() { // in the implementation, these should only be accessed in methods decorated with @InitializedOnly this.#config = null!; this.#eventHandlers = { languageChange: new Set() }; this.#resolvedLanguage = null!; const globalKey = '__LIBUI_TRANSLATOR_INSTANCES__'; const globalObj = globalThis as { [globalKey]?: Translator[] } & typeof window; globalObj[globalKey] ??= []; globalObj[globalKey].push(this); if (globalObj[globalKey].length > 1) { console.warn(`WARNING: Multiple Translator instances detected (existing: ${globalObj[globalKey].length})`); // Check if prototypes are the same (can occur if multiple library versions are loaded) const differentPrototype = globalObj[globalKey].some((inst) => { return Object.getPrototypeOf(inst) !== Object.getPrototypeOf(this); }); if (differentPrototype) { console.warn('WARNING: Detected different prototypes for Translator instances.'); } } } get isInitialized() { return this.#config !== null; } @InitializedOnly get resolvedLanguage() { return this.#resolvedLanguage; } addEventListener(key: TKey, handler: TranslatorEventMap[TKey]) { this.#eventHandlers[key].add(handler); } @InitializedOnly changeLanguage(language: Language) { this.#resolvedLanguage = language; if (typeof document !== 'undefined') { document.documentElement.lang = language; } this.emitEvent('languageChange', [language]); } init({ defaultLanguage, translations }: TranslatorConfig) { if (this.isInitialized) { throw new Error('Cannot reinitialize Translator'); } this.#config = { defaultLanguage: defaultLanguage ?? 'en', translations: { libui, ...translations } }; this.changeLanguage(this.#config.defaultLanguage); } removeEventListener(key: TKey, handler: TranslatorEventMap[TKey]) { return this.#eventHandlers[key].delete(handler); } @InitializedOnly t(target: TranslationKey | { [L in Language]?: string }, { args }: TranslateOptions = {}): string { let obj: { [key: string]: string }; if (typeof target === 'string') { obj = get(this.#config.translations, target) ?? {}; } else { obj = target; } const value = obj[this.#resolvedLanguage] ?? obj[this.#config.defaultLanguage]; if (!value) { console.error(`Failed to extract translation from object '${JSON.stringify(obj)}'`); return ''; } if (!args) { return value; } return format(value, ...this.getFormatArgs(args)); } private emitEvent(key: TKey, payload: Parameters) { this.#eventHandlers[key].forEach((fn: (...args: any[]) => any) => { fn(...payload); }); } private getFormatArgs(args: TranslateFormatArgs) { if (Array.isArray(args)) { return args; } const result = args[this.#resolvedLanguage] ?? args[this.#config.defaultLanguage]; if (!result) { console.error(`Failed to extract args from object '${JSON.stringify(args)}'`); return []; } return result; } }