Skip to content

Commit 360873e

Browse files
committed
refactor: i18next implementation to align with odc runtime and allow registering events and non-react usage
1 parent ed99280 commit 360873e

File tree

11 files changed

+299
-136
lines changed

11 files changed

+299
-136
lines changed

.storybook/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Meta } from '@storybook/blocks';
1+
import { Meta } from '@storybook/addon-docs/blocks';
22

33
<Meta title="Welcome" />
44

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,45 @@
1-
import { useCallback } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22

3-
import { useStore } from 'zustand';
3+
import type {
4+
TranslateFunction,
5+
TranslationKey,
6+
TranslationKeyForNamespace,
7+
TranslationNamespace,
8+
TranslatorType
9+
} from '@/i18n';
410

5-
import { translationStore } from '@/i18n';
6-
import type { TranslateFunction, TranslationNamespace } from '@/i18n';
7-
import { getTranslation } from '@/i18n/internal';
11+
// this is required since our storybook manager plugin cannot use vite aliases
12+
import { i18n } from '../../i18n';
813

9-
export function useTranslation<TNamespace extends TranslationNamespace | undefined = undefined>(
10-
namespace?: TNamespace
11-
) {
12-
const changeLanguage = useStore(translationStore, (store) => store.changeLanguage);
13-
const fallbackLanguage = useStore(translationStore, (store) => store.fallbackLanguage);
14-
const resolvedLanguage = useStore(translationStore, (store) => store.resolvedLanguage);
15-
const translations = useStore(translationStore, (store) => {
16-
if (namespace) {
17-
return store.translations[namespace];
18-
}
19-
return store.translations;
20-
});
14+
export function useTranslation(): TranslatorType<TranslationKey>;
15+
export function useTranslation<TNamespace extends TranslationNamespace>(
16+
namespace: TNamespace
17+
): TranslatorType<TranslationKeyForNamespace<TNamespace>>;
18+
export function useTranslation(namespace?: TranslationNamespace): TranslatorType<string> {
19+
const [resolvedLanguage, setResolvedLanguage] = useState(i18n.resolvedLanguage);
20+
const { changeLanguage, t } = useMemo(() => {
21+
const t: TranslateFunction<string> = (target, options) => {
22+
if (typeof target === 'object') {
23+
return i18n.t(target, options);
24+
}
25+
return i18n.t((namespace ? `${namespace}.${target}` : target) as TranslationKey, options);
26+
};
27+
return {
28+
changeLanguage: i18n.changeLanguage.bind(i18n),
29+
t
30+
};
31+
}, []);
2132

22-
const t: TranslateFunction<TNamespace> = useCallback(
23-
(target, ...args) => {
24-
return getTranslation(target, { fallbackLanguage, resolvedLanguage, translations }, ...args);
25-
},
26-
[fallbackLanguage, resolvedLanguage, translations]
27-
);
33+
useEffect(() => {
34+
i18n.addEventListener('languageChange', setResolvedLanguage);
35+
return () => {
36+
i18n.removeEventListener('languageChange', setResolvedLanguage);
37+
};
38+
}, []);
2839

29-
return { changeLanguage, resolvedLanguage, t };
40+
return {
41+
changeLanguage,
42+
resolvedLanguage,
43+
t
44+
};
3045
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { Translator } from '../translator';
4+
5+
describe('Translator', () => {
6+
const translator = new Translator();
7+
8+
it('should initially not be initialized', () => {
9+
expect(translator.isInitialized).toBe(false);
10+
});
11+
12+
it('should not allow accessing the changeLanguage method before initialization', () => {
13+
expect(() => translator.changeLanguage('fr')).toThrow(
14+
"Cannot access method 'changeLanguage' of Translator instance before initialization"
15+
);
16+
});
17+
18+
it('should not allow accessing the resolvedLanguage method before initialization', () => {
19+
expect(() => translator.resolvedLanguage).toThrow(
20+
"Cannot access getter 'resolvedLanguage' of Translator instance before initialization"
21+
);
22+
});
23+
24+
it('should allow initialization', () => {
25+
translator.init({ defaultLanguage: 'en', translations: {} });
26+
expect(translator.isInitialized).toBe(true);
27+
});
28+
29+
it('should not allow reinitialization', () => {
30+
expect(() => translator.init({ defaultLanguage: 'en', translations: {} })).toThrow(
31+
'Cannot reinitialize Translator'
32+
);
33+
});
34+
35+
it('should have set the document language to en', () => {
36+
expect(document.documentElement.getAttribute('lang')).toBe('en');
37+
});
38+
39+
it('should allow accessing translations', () => {
40+
expect(translator.t('libui.days.monday')).toBe('Monday');
41+
expect(translator.t({ en: 'Yes', fr: 'Oui' })).toBe('Yes');
42+
});
43+
44+
it('should allow changing the language', () => {
45+
translator.changeLanguage('fr');
46+
expect(translator.resolvedLanguage).toBe('fr');
47+
expect(document.documentElement.getAttribute('lang')).toBe('fr');
48+
expect(translator.t('libui.days.monday')).toBe('Lundi');
49+
expect(translator.t({ en: 'Yes', fr: 'Oui' })).toBe('Oui');
50+
});
51+
52+
it('should return the language from the defaultLanguage, if the resolvedLanguage is unavailable', () => {
53+
expect(translator.resolvedLanguage).toBe('fr');
54+
expect(translator.t({ en: 'Yes' })).toBe('Yes');
55+
});
56+
57+
it('should return an empty string if no translation is available', () => {
58+
vi.spyOn(console, 'error').mockImplementationOnce(() => undefined);
59+
expect(translator.t({})).toBe('');
60+
expect(console.error).toHaveBeenLastCalledWith("Failed to extract translation from object '{}'");
61+
vi.restoreAllMocks();
62+
});
63+
64+
it('should allow adding and removing event listeners', () => {
65+
const handleLanguageChange1 = vi.fn();
66+
const handleLanguageChange2 = vi.fn();
67+
translator.addEventListener('languageChange', handleLanguageChange1);
68+
translator.addEventListener('languageChange', handleLanguageChange2);
69+
translator.changeLanguage('en');
70+
expect(translator.removeEventListener('languageChange', handleLanguageChange1)).toBe(true);
71+
translator.changeLanguage('fr');
72+
expect(translator.removeEventListener('languageChange', handleLanguageChange2)).toBe(true);
73+
expect(handleLanguageChange1).toHaveBeenCalledTimes(1);
74+
expect(handleLanguageChange2).toHaveBeenCalledTimes(2);
75+
});
76+
77+
it('should apply arguments', () => {
78+
expect(translator.t({ en: 'Hello, {}' }, { args: ['World'] })).toBe('Hello, World');
79+
expect(
80+
translator.t(
81+
{ en: 'Hello, {}', fr: 'Bonjour, {}' },
82+
{
83+
args: {
84+
en: ['World'],
85+
fr: ['tout le monde']
86+
}
87+
}
88+
)
89+
).toBe('Bonjour, tout le monde');
90+
});
91+
});

src/i18n/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1-
export * from './store.ts';
1+
import { Translator } from './translator.ts';
2+
3+
export const i18n = new Translator();
4+
25
export * from './types.ts';

src/i18n/internal.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

src/i18n/store.ts

Lines changed: 0 additions & 69 deletions
This file was deleted.

src/i18n/translator.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { format } from '@douglasneuroinformatics/libjs';
2+
import { get } from 'lodash-es';
3+
import type { SetOptional } from 'type-fest';
4+
5+
import libui from './translations/libui.json';
6+
7+
import type {
8+
Language,
9+
TranslateFormatArgs,
10+
TranslateOptions,
11+
TranslationKey,
12+
Translations,
13+
TranslatorType
14+
} from './types';
15+
16+
type TranslatorEventMap = {
17+
languageChange: (...args: [language: Language]) => void;
18+
};
19+
20+
type TranslatorConfig = {
21+
defaultLanguage?: Language;
22+
translations: SetOptional<Translations, 'libui'>;
23+
};
24+
25+
function InitializedOnly<T extends Translator, TArgs extends any[], TReturn>(
26+
target: (this: T, ...args: TArgs) => TReturn,
27+
context: ClassGetterDecoratorContext<T> | ClassMethodDecoratorContext<T> | ClassSetterDecoratorContext<T>
28+
) {
29+
const name = context.name.toString();
30+
function replacementMethod(this: T, ...args: TArgs): TReturn {
31+
if (!this.isInitialized) {
32+
throw new Error(`Cannot access ${context.kind} '${name}' of Translator instance before initialization`);
33+
}
34+
return target.call(this, ...args);
35+
}
36+
return replacementMethod;
37+
}
38+
39+
export class Translator implements TranslatorType<TranslationKey> {
40+
#config: Required<TranslatorConfig>;
41+
#eventHandlers: {
42+
[K in keyof TranslatorEventMap]: Set<TranslatorEventMap[K]>;
43+
};
44+
#resolvedLanguage: Language;
45+
46+
constructor() {
47+
// in the implementation, these should only be accessed in methods decorated with @InitializedOnly
48+
this.#config = null!;
49+
this.#eventHandlers = {
50+
languageChange: new Set()
51+
};
52+
this.#resolvedLanguage = null!;
53+
}
54+
55+
get isInitialized() {
56+
return this.#config !== null;
57+
}
58+
59+
@InitializedOnly
60+
get resolvedLanguage() {
61+
return this.#resolvedLanguage;
62+
}
63+
64+
addEventListener<TKey extends keyof TranslatorEventMap>(key: TKey, handler: TranslatorEventMap[TKey]) {
65+
this.#eventHandlers[key].add(handler);
66+
}
67+
68+
@InitializedOnly
69+
changeLanguage(language: Language) {
70+
this.#resolvedLanguage = language;
71+
document.documentElement.lang = language;
72+
this.emitEvent('languageChange', [language]);
73+
}
74+
75+
init({ defaultLanguage, translations }: TranslatorConfig) {
76+
if (this.isInitialized) {
77+
throw new Error('Cannot reinitialize Translator');
78+
}
79+
this.#config = {
80+
defaultLanguage: defaultLanguage ?? 'en',
81+
translations: {
82+
libui,
83+
...translations
84+
}
85+
};
86+
this.changeLanguage(this.#config.defaultLanguage);
87+
}
88+
89+
removeEventListener<TKey extends keyof TranslatorEventMap>(key: TKey, handler: TranslatorEventMap[TKey]) {
90+
return this.#eventHandlers[key].delete(handler);
91+
}
92+
93+
@InitializedOnly
94+
t(target: TranslationKey | { [L in Language]?: string }, { args }: TranslateOptions = {}): string {
95+
let obj: { [key: string]: string };
96+
if (typeof target === 'string') {
97+
obj = (get(this.#config.translations, target) ?? {}) as { [key: string]: string };
98+
} else {
99+
obj = target;
100+
}
101+
const value = obj[this.#resolvedLanguage] ?? obj[this.#config.defaultLanguage];
102+
if (!value) {
103+
console.error(`Failed to extract translation from object '${JSON.stringify(obj)}'`);
104+
return '';
105+
}
106+
if (!args) {
107+
return value;
108+
}
109+
return format(value, ...this.getFormatArgs(args));
110+
}
111+
112+
private emitEvent<TKey extends keyof TranslatorEventMap>(key: TKey, payload: Parameters<TranslatorEventMap[TKey]>) {
113+
this.#eventHandlers[key].forEach((fn: (...args: any[]) => any) => {
114+
fn(...payload);
115+
});
116+
}
117+
118+
private getFormatArgs(args: TranslateFormatArgs) {
119+
if (Array.isArray(args)) {
120+
return args;
121+
}
122+
const result = args[this.#resolvedLanguage] ?? args[this.#config.defaultLanguage];
123+
if (!result) {
124+
console.error(`Failed to extract args from object '${JSON.stringify(args)}'`);
125+
return [];
126+
}
127+
return result;
128+
}
129+
}

0 commit comments

Comments
 (0)