@hua-labs/i18n-core 2.0.0 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -597
- package/dist/chunk-F4PDBJLO.mjs +973 -0
- package/dist/chunk-F4PDBJLO.mjs.map +1 -0
- package/dist/index.d.mts +249 -0
- package/dist/index.d.ts +117 -30
- package/dist/index.js +1818 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +845 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server-4TeBq6hp.d.mts +367 -0
- package/dist/server-4TeBq6hp.d.ts +367 -0
- package/dist/server.d.mts +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +977 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +3 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +42 -19
- package/src/__tests__/debug-tools.test.ts +359 -0
- package/src/__tests__/default-translations.test.ts +179 -0
- package/src/__tests__/i18n-resource.test.ts +137 -0
- package/src/__tests__/lazy-loader.test.ts +109 -0
- package/src/__tests__/missing-key-overlay.test.tsx +339 -0
- package/src/__tests__/translator-factory.test.ts +120 -0
- package/src/__tests__/translator.test.ts +442 -0
- package/src/__tests__/types.test.ts +211 -0
- package/src/__tests__/useI18n.test.tsx +181 -0
- package/src/__tests__/useTranslation.test.tsx +110 -0
- package/src/components/MissingKeyOverlay.tsx +1 -1
- package/src/core/lazy-loader.ts +2 -2
- package/src/core/translator.tsx +151 -62
- package/src/hooks/useI18n.tsx +96 -115
- package/src/hooks/useTranslation.tsx +12 -10
- package/src/index.ts +102 -5
- package/src/server.ts +9 -0
- package/src/types/index.ts +67 -12
- package/LICENSE +0 -21
- package/dist/components/MissingKeyOverlay.d.ts +0 -33
- package/dist/components/MissingKeyOverlay.d.ts.map +0 -1
- package/dist/components/MissingKeyOverlay.js +0 -138
- package/dist/components/MissingKeyOverlay.js.map +0 -1
- package/dist/core/debug-tools.d.ts +0 -37
- package/dist/core/debug-tools.d.ts.map +0 -1
- package/dist/core/debug-tools.js +0 -241
- package/dist/core/debug-tools.js.map +0 -1
- package/dist/core/i18n-resource.d.ts +0 -59
- package/dist/core/i18n-resource.d.ts.map +0 -1
- package/dist/core/i18n-resource.js +0 -153
- package/dist/core/i18n-resource.js.map +0 -1
- package/dist/core/lazy-loader.d.ts +0 -82
- package/dist/core/lazy-loader.d.ts.map +0 -1
- package/dist/core/lazy-loader.js +0 -193
- package/dist/core/lazy-loader.js.map +0 -1
- package/dist/core/translator-factory.d.ts +0 -50
- package/dist/core/translator-factory.d.ts.map +0 -1
- package/dist/core/translator-factory.js +0 -117
- package/dist/core/translator-factory.js.map +0 -1
- package/dist/core/translator.d.ts +0 -202
- package/dist/core/translator.d.ts.map +0 -1
- package/dist/core/translator.js +0 -912
- package/dist/core/translator.js.map +0 -1
- package/dist/hooks/useI18n.d.ts +0 -39
- package/dist/hooks/useI18n.d.ts.map +0 -1
- package/dist/hooks/useI18n.js +0 -531
- package/dist/hooks/useI18n.js.map +0 -1
- package/dist/hooks/useTranslation.d.ts +0 -55
- package/dist/hooks/useTranslation.d.ts.map +0 -1
- package/dist/hooks/useTranslation.js +0 -58
- package/dist/hooks/useTranslation.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/types/index.d.ts +0 -162
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -191
- package/dist/types/index.js.map +0 -1
- package/dist/utils/default-translations.d.ts +0 -20
- package/dist/utils/default-translations.d.ts.map +0 -1
- package/dist/utils/default-translations.js +0 -123
- package/dist/utils/default-translations.js.map +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { I18nProvider, useI18n } from '../hooks/useI18n';
|
|
5
|
+
import { TranslatorFactory } from '../core/translator-factory';
|
|
6
|
+
import { I18nConfig } from '../types';
|
|
7
|
+
|
|
8
|
+
function createMockConfig(overrides?: Partial<I18nConfig>): I18nConfig {
|
|
9
|
+
return {
|
|
10
|
+
defaultLanguage: 'ko',
|
|
11
|
+
fallbackLanguage: 'en',
|
|
12
|
+
supportedLanguages: [
|
|
13
|
+
{ code: 'ko', name: 'Korean', nativeName: '한국어' },
|
|
14
|
+
{ code: 'en', name: 'English', nativeName: 'English' },
|
|
15
|
+
],
|
|
16
|
+
namespaces: ['common'],
|
|
17
|
+
loadTranslations: vi.fn().mockImplementation(async (lang: string, ns: string) => {
|
|
18
|
+
const data: Record<string, Record<string, any>> = {
|
|
19
|
+
'ko:common': { greeting: '안녕하세요', welcome: '{name}님 환영합니다' },
|
|
20
|
+
'en:common': { greeting: 'Hello', welcome: 'Welcome {name}' },
|
|
21
|
+
};
|
|
22
|
+
return data[`${lang}:${ns}`] || {};
|
|
23
|
+
}),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createWrapper(config: I18nConfig) {
|
|
29
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
30
|
+
return React.createElement(I18nProvider, { config }, children);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('useI18n', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
TranslatorFactory.clear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('without Provider', () => {
|
|
40
|
+
it('should return default values without error', () => {
|
|
41
|
+
const { result } = renderHook(() => useI18n());
|
|
42
|
+
|
|
43
|
+
expect(result.current.currentLanguage).toBe('ko');
|
|
44
|
+
expect(result.current.isLoading).toBe(false);
|
|
45
|
+
expect(result.current.isInitialized).toBe(false);
|
|
46
|
+
expect(result.current.error).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return key as-is for t() without provider', () => {
|
|
50
|
+
const { result } = renderHook(() => useI18n());
|
|
51
|
+
expect(result.current.t('common:greeting')).toBe('common:greeting');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return empty array for tArray() without provider', () => {
|
|
55
|
+
const { result } = renderHook(() => useI18n());
|
|
56
|
+
expect(result.current.tArray('common:items')).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return default debug utilities', () => {
|
|
60
|
+
const { result } = renderHook(() => useI18n());
|
|
61
|
+
expect(result.current.debug.getCurrentLanguage()).toBe('ko');
|
|
62
|
+
expect(result.current.debug.getSupportedLanguages()).toEqual(['ko', 'en']);
|
|
63
|
+
expect(result.current.debug.isReady()).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('with Provider', () => {
|
|
68
|
+
it('should initialize and provide translations', async () => {
|
|
69
|
+
const config = createMockConfig();
|
|
70
|
+
const { result } = renderHook(() => useI18n(), {
|
|
71
|
+
wrapper: createWrapper(config),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Initially loading
|
|
75
|
+
expect(result.current.isLoading).toBe(true);
|
|
76
|
+
|
|
77
|
+
// Wait for initialization
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(result.current.isInitialized).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.current.currentLanguage).toBe('ko');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should translate keys after initialization', async () => {
|
|
86
|
+
const config = createMockConfig();
|
|
87
|
+
const { result } = renderHook(() => useI18n(), {
|
|
88
|
+
wrapper: createWrapper(config),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(result.current.isInitialized).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// t() should return translated text
|
|
96
|
+
expect(result.current.t('common:greeting')).toBe('안녕하세요');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should change language', async () => {
|
|
100
|
+
const config = createMockConfig();
|
|
101
|
+
const { result } = renderHook(() => useI18n(), {
|
|
102
|
+
wrapper: createWrapper(config),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
expect(result.current.isInitialized).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
await result.current.setLanguage('en');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.current.currentLanguage).toBe('en');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should handle initialTranslations for SSR', async () => {
|
|
117
|
+
const config = createMockConfig({
|
|
118
|
+
initialTranslations: {
|
|
119
|
+
ko: { common: { greeting: 'SSR 안녕' } },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const { result } = renderHook(() => useI18n(), {
|
|
123
|
+
wrapper: createWrapper(config),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(result.current.isInitialized).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(result.current.t('common:greeting')).toBe('SSR 안녕');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should provide debug utilities', async () => {
|
|
134
|
+
const config = createMockConfig();
|
|
135
|
+
const { result } = renderHook(() => useI18n(), {
|
|
136
|
+
wrapper: createWrapper(config),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(result.current.isInitialized).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.current.debug.getCurrentLanguage()).toBe('ko');
|
|
144
|
+
expect(result.current.debug.getSupportedLanguages()).toContain('ko');
|
|
145
|
+
expect(result.current.debug.isReady()).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle translation errors gracefully', async () => {
|
|
149
|
+
// Use a loader that fails once then returns empty - avoids slow retry backoff
|
|
150
|
+
let callCount = 0;
|
|
151
|
+
const config = createMockConfig({
|
|
152
|
+
loadTranslations: vi.fn().mockImplementation(async () => {
|
|
153
|
+
callCount++;
|
|
154
|
+
if (callCount <= 2) throw new Error('Load failed');
|
|
155
|
+
return {};
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
const { result } = renderHook(() => useI18n(), {
|
|
159
|
+
wrapper: createWrapper(config),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Should eventually initialize even with errors
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(result.current.isInitialized).toBe(true);
|
|
165
|
+
}, { timeout: 15000 });
|
|
166
|
+
|
|
167
|
+
// Should not crash - returns empty/fallback
|
|
168
|
+
expect(result.current.isLoading).toBe(false);
|
|
169
|
+
}, 20000);
|
|
170
|
+
|
|
171
|
+
it('should provide supported languages from config', async () => {
|
|
172
|
+
const config = createMockConfig();
|
|
173
|
+
const { result } = renderHook(() => useI18n(), {
|
|
174
|
+
wrapper: createWrapper(config),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.current.supportedLanguages).toHaveLength(2);
|
|
178
|
+
expect(result.current.supportedLanguages[0].code).toBe('ko');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useTranslation, useLanguageChange } from '../hooks/useTranslation';
|
|
5
|
+
import { I18nProvider } from '../hooks/useI18n';
|
|
6
|
+
import { TranslatorFactory } from '../core/translator-factory';
|
|
7
|
+
import { I18nConfig } from '../types';
|
|
8
|
+
|
|
9
|
+
function createMockConfig(overrides?: Partial<I18nConfig>): I18nConfig {
|
|
10
|
+
return {
|
|
11
|
+
defaultLanguage: 'ko',
|
|
12
|
+
fallbackLanguage: 'en',
|
|
13
|
+
supportedLanguages: [
|
|
14
|
+
{ code: 'ko', name: 'Korean', nativeName: '한국어' },
|
|
15
|
+
{ code: 'en', name: 'English', nativeName: 'English' },
|
|
16
|
+
],
|
|
17
|
+
namespaces: ['common'],
|
|
18
|
+
loadTranslations: vi.fn().mockImplementation(async (lang: string, ns: string) => {
|
|
19
|
+
const data: Record<string, Record<string, any>> = {
|
|
20
|
+
'ko:common': { greeting: '안녕하세요' },
|
|
21
|
+
'en:common': { greeting: 'Hello' },
|
|
22
|
+
};
|
|
23
|
+
return data[`${lang}:${ns}`] || {};
|
|
24
|
+
}),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createWrapper(config: I18nConfig) {
|
|
30
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
31
|
+
return React.createElement(I18nProvider, { config }, children);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useTranslation', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
TranslatorFactory.clear();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return all translation utilities', async () => {
|
|
41
|
+
const config = createMockConfig();
|
|
42
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
43
|
+
wrapper: createWrapper(config),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await waitFor(() => {
|
|
47
|
+
expect(result.current.isInitialized).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.current.t).toBeDefined();
|
|
51
|
+
expect(result.current.tPlural).toBeDefined();
|
|
52
|
+
expect(result.current.tArray).toBeDefined();
|
|
53
|
+
expect(result.current.currentLanguage).toBe('ko');
|
|
54
|
+
expect(result.current.setLanguage).toBeDefined();
|
|
55
|
+
expect(result.current.debug).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should translate using t()', async () => {
|
|
59
|
+
const config = createMockConfig();
|
|
60
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
61
|
+
wrapper: createWrapper(config),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(result.current.isInitialized).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.current.t('common:greeting')).toBe('안녕하세요');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should work without provider (returns defaults)', () => {
|
|
72
|
+
const { result } = renderHook(() => useTranslation());
|
|
73
|
+
expect(result.current.currentLanguage).toBe('ko');
|
|
74
|
+
expect(result.current.isInitialized).toBe(false);
|
|
75
|
+
expect(result.current.t('key')).toBe('key');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('useLanguageChange', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
TranslatorFactory.clear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should provide language change utilities', async () => {
|
|
85
|
+
const config = createMockConfig();
|
|
86
|
+
const { result } = renderHook(() => useLanguageChange(), {
|
|
87
|
+
wrapper: createWrapper(config),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.current.currentLanguage).toBe('ko');
|
|
91
|
+
expect(result.current.changeLanguage).toBeDefined();
|
|
92
|
+
expect(result.current.supportedLanguages).toHaveLength(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should warn for unsupported language', async () => {
|
|
96
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
97
|
+
const config = createMockConfig();
|
|
98
|
+
const { result } = renderHook(() => useLanguageChange(), {
|
|
99
|
+
wrapper: createWrapper(config),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
// wait for initialization
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
result.current.changeLanguage('fr');
|
|
107
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('fr'));
|
|
108
|
+
warnSpy.mockRestore();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -195,7 +195,7 @@ export const reportMissingKey = (key: string, options: {
|
|
|
195
195
|
}));
|
|
196
196
|
|
|
197
197
|
// 콘솔에도 로그
|
|
198
|
-
console.warn(`Missing translation key: ${key}`, {
|
|
198
|
+
if (process.env.NODE_ENV === 'development') console.warn(`Missing translation key: ${key}`, {
|
|
199
199
|
namespace: options.namespace,
|
|
200
200
|
language: options.language,
|
|
201
201
|
component: options.component
|
package/src/core/lazy-loader.ts
CHANGED
|
@@ -181,8 +181,8 @@ export class LazyLoader {
|
|
|
181
181
|
*/
|
|
182
182
|
analyzeUsagePatterns(): Record<string, number> {
|
|
183
183
|
const usage: Record<string, number> = {};
|
|
184
|
-
|
|
185
|
-
for (const [key
|
|
184
|
+
|
|
185
|
+
for (const [key] of this.loadHistory.entries()) {
|
|
186
186
|
const namespace = key.split(':')[1];
|
|
187
187
|
usage[namespace] = (usage[namespace] || 0) + 1;
|
|
188
188
|
}
|
package/src/core/translator.tsx
CHANGED
|
@@ -10,21 +10,26 @@ import {
|
|
|
10
10
|
logTranslationError,
|
|
11
11
|
defaultErrorRecoveryStrategy,
|
|
12
12
|
defaultErrorLoggingConfig,
|
|
13
|
-
isRecoverableError
|
|
13
|
+
isRecoverableError,
|
|
14
|
+
isPluralValue,
|
|
15
|
+
PluralCategory
|
|
14
16
|
} from '../types';
|
|
15
17
|
|
|
16
18
|
export interface TranslatorInterface {
|
|
17
|
-
translate(key: string, language?: string): string;
|
|
19
|
+
translate(key: string, paramsOrLang?: Record<string, unknown> | string, language?: string): string;
|
|
20
|
+
tPlural(key: string, count: number, params?: Record<string, unknown>, language?: string): string;
|
|
18
21
|
setLanguage(lang: string): void;
|
|
19
22
|
getCurrentLanguage(): string;
|
|
20
23
|
initialize(): Promise<void>;
|
|
21
24
|
isReady(): boolean;
|
|
22
25
|
debug(): unknown;
|
|
23
26
|
getRawValue(key: string, language?: string): unknown;
|
|
27
|
+
tArray(key: string, language?: string): string[];
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export class Translator implements TranslatorInterface {
|
|
27
31
|
private cache = new Map<string, CacheEntry>();
|
|
32
|
+
private pluralRulesCache = new Map<string, Intl.PluralRules>();
|
|
28
33
|
private loadedNamespaces = new Set<string>();
|
|
29
34
|
private loadingPromises = new Map<string, Promise<TranslationNamespace>>();
|
|
30
35
|
private allTranslations: Record<string, Record<string, TranslationNamespace>> = {};
|
|
@@ -144,9 +149,9 @@ export class Translator implements TranslatorInterface {
|
|
|
144
149
|
this.loadedNamespaces.add(`${language}:${namespace}`);
|
|
145
150
|
}
|
|
146
151
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
152
|
+
// initialTranslations가 있으면 초기화 완료로 간주 (SSR에서 이미 로드됨)
|
|
153
|
+
// 이렇게 하면 초기화 전 상태에서도 번역을 사용할 수 있음
|
|
154
|
+
this.isInitialized = true;
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
157
|
|
|
@@ -305,28 +310,27 @@ export class Translator implements TranslatorInterface {
|
|
|
305
310
|
console.warn('Translator not initialized. Call initialize() first.');
|
|
306
311
|
}
|
|
307
312
|
|
|
313
|
+
// 초기화되지 않았을 때도 기본 번역 시도 (initialTranslations 사용)
|
|
308
314
|
const { namespace, key: actualKey } = this.parseKey(key);
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
|
|
316
|
+
// findInNamespace를 사용하여 중첩 키도 처리
|
|
317
|
+
const result = this.findInNamespace(namespace, actualKey, targetLang);
|
|
318
|
+
if (result) {
|
|
319
|
+
if (this.config.debug) {
|
|
320
|
+
console.log(`✅ [TRANSLATOR] Found fallback translation from initialTranslations:`, result);
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
311
325
|
if (this.config.debug) {
|
|
312
|
-
|
|
326
|
+
const translations = this.allTranslations[targetLang]?.[namespace];
|
|
327
|
+
console.log(`🔍 [TRANSLATOR] Not initialized, fallback failed:`, {
|
|
313
328
|
namespace,
|
|
314
329
|
actualKey,
|
|
315
|
-
translations,
|
|
316
|
-
|
|
330
|
+
hasTranslations: !!translations,
|
|
331
|
+
translationsKeys: translations ? Object.keys(translations) : []
|
|
317
332
|
});
|
|
318
333
|
}
|
|
319
|
-
|
|
320
|
-
if (translations && translations[actualKey]) {
|
|
321
|
-
const value = translations[actualKey];
|
|
322
|
-
if (typeof value === 'string') {
|
|
323
|
-
if (this.config.debug) {
|
|
324
|
-
console.log(`✅ [TRANSLATOR] Found fallback translation:`, value);
|
|
325
|
-
}
|
|
326
|
-
return value;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
334
|
return this.config.missingKeyHandler?.(key, targetLang, 'default') || key;
|
|
331
335
|
}
|
|
332
336
|
|
|
@@ -372,12 +376,23 @@ export class Translator implements TranslatorInterface {
|
|
|
372
376
|
/**
|
|
373
377
|
* 번역 키를 번역된 텍스트로 변환
|
|
374
378
|
*/
|
|
375
|
-
translate(key: string, language?: string): string {
|
|
376
|
-
|
|
379
|
+
translate(key: string, paramsOrLang?: Record<string, unknown> | string, language?: string): string {
|
|
380
|
+
// 두 번째 인자 타입으로 분기
|
|
381
|
+
let params: Record<string, unknown> | undefined;
|
|
382
|
+
let targetLang: string;
|
|
383
|
+
if (typeof paramsOrLang === 'string') {
|
|
384
|
+
targetLang = paramsOrLang;
|
|
385
|
+
} else if (typeof paramsOrLang === 'object' && paramsOrLang !== null) {
|
|
386
|
+
params = paramsOrLang;
|
|
387
|
+
targetLang = language || this.currentLang;
|
|
388
|
+
} else {
|
|
389
|
+
targetLang = this.currentLang;
|
|
390
|
+
}
|
|
377
391
|
|
|
378
392
|
// 초기화되지 않은 경우 처리
|
|
379
393
|
if (!this.isInitialized) {
|
|
380
|
-
|
|
394
|
+
const raw = this.translateBeforeInitialized(key, targetLang);
|
|
395
|
+
return params ? this.interpolate(raw, params) : raw;
|
|
381
396
|
}
|
|
382
397
|
|
|
383
398
|
const { namespace, key: actualKey } = this.parseKey(key);
|
|
@@ -386,28 +401,29 @@ export class Translator implements TranslatorInterface {
|
|
|
386
401
|
let result: string | null = this.findInNamespace(namespace, actualKey, targetLang);
|
|
387
402
|
if (result) {
|
|
388
403
|
this.cacheStats.hits++;
|
|
389
|
-
return result;
|
|
404
|
+
return params ? this.interpolate(result, params) : result;
|
|
390
405
|
}
|
|
391
|
-
|
|
406
|
+
|
|
392
407
|
// 2단계: 다른 로드된 언어에서 찾기 (언어 변경 중 깜빡임 방지)
|
|
393
408
|
result = this.findInOtherLanguages(namespace, actualKey, targetLang);
|
|
394
409
|
if (result) {
|
|
395
|
-
return result;
|
|
410
|
+
return params ? this.interpolate(result, params) : result;
|
|
396
411
|
}
|
|
397
412
|
|
|
398
413
|
// 3단계: 폴백 언어에서 찾기
|
|
399
414
|
result = this.findInFallbackLanguage(namespace, actualKey, targetLang);
|
|
400
415
|
if (result) {
|
|
401
|
-
return result;
|
|
416
|
+
return params ? this.interpolate(result, params) : result;
|
|
402
417
|
}
|
|
403
418
|
|
|
404
419
|
// 모든 단계에서 찾지 못한 경우
|
|
405
420
|
this.cacheStats.misses++;
|
|
406
|
-
|
|
421
|
+
|
|
407
422
|
if (this.config.debug) {
|
|
408
|
-
|
|
423
|
+
const missing = this.config.missingKeyHandler?.(key, targetLang, namespace) || key;
|
|
424
|
+
return params ? this.interpolate(missing, params) : missing;
|
|
409
425
|
}
|
|
410
|
-
|
|
426
|
+
|
|
411
427
|
// 프로덕션에서는 빈 문자열 반환 (미싱 키 노출 방지)
|
|
412
428
|
return '';
|
|
413
429
|
}
|
|
@@ -442,12 +458,18 @@ export class Translator implements TranslatorInterface {
|
|
|
442
458
|
if (this.isStringValue(directValue)) {
|
|
443
459
|
return directValue;
|
|
444
460
|
}
|
|
461
|
+
if (this.isStringArray(directValue)) {
|
|
462
|
+
return directValue[Math.floor(Math.random() * directValue.length)];
|
|
463
|
+
}
|
|
445
464
|
|
|
446
465
|
// 중첩 키 매칭 (예: "user.profile.name")
|
|
447
466
|
const nestedValue = this.getNestedValue(translations, key);
|
|
448
467
|
if (this.isStringValue(nestedValue)) {
|
|
449
468
|
return nestedValue;
|
|
450
469
|
}
|
|
470
|
+
if (this.isStringArray(nestedValue)) {
|
|
471
|
+
return nestedValue[Math.floor(Math.random() * nestedValue.length)];
|
|
472
|
+
}
|
|
451
473
|
|
|
452
474
|
if (this.config.debug) {
|
|
453
475
|
console.warn(`❌ [TRANSLATOR] No match found for key: ${key} in ${language}/${namespace}`);
|
|
@@ -457,6 +479,7 @@ export class Translator implements TranslatorInterface {
|
|
|
457
479
|
|
|
458
480
|
/**
|
|
459
481
|
* 중첩된 객체에서 값을 가져오기
|
|
482
|
+
* 배열도 지원: 최종 값이 string[]이면 그대로 반환
|
|
460
483
|
*/
|
|
461
484
|
private getNestedValue(obj: unknown, path: string): unknown {
|
|
462
485
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
@@ -464,7 +487,12 @@ export class Translator implements TranslatorInterface {
|
|
|
464
487
|
}
|
|
465
488
|
|
|
466
489
|
return path.split('.').reduce((current: unknown, key: string) => {
|
|
467
|
-
if (current
|
|
490
|
+
if (current == null) return undefined;
|
|
491
|
+
if (Array.isArray(current)) {
|
|
492
|
+
const idx = Number(key);
|
|
493
|
+
return Number.isInteger(idx) ? current[idx] : undefined;
|
|
494
|
+
}
|
|
495
|
+
if (typeof current === 'object' && key in (current as Record<string, unknown>)) {
|
|
468
496
|
return (current as Record<string, unknown>)[key];
|
|
469
497
|
}
|
|
470
498
|
return undefined;
|
|
@@ -478,6 +506,14 @@ export class Translator implements TranslatorInterface {
|
|
|
478
506
|
return typeof value === 'string' && value.length > 0;
|
|
479
507
|
}
|
|
480
508
|
|
|
509
|
+
/**
|
|
510
|
+
* string[] 배열인지 확인하는 타입 가드
|
|
511
|
+
* 배열 값이 t()에 전달되면 랜덤으로 하나를 선택하여 반환
|
|
512
|
+
*/
|
|
513
|
+
private isStringArray(value: unknown): value is string[] {
|
|
514
|
+
return Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string');
|
|
515
|
+
}
|
|
516
|
+
|
|
481
517
|
/**
|
|
482
518
|
* 원시 값 가져오기 (배열, 객체 포함)
|
|
483
519
|
*/
|
|
@@ -527,26 +563,77 @@ export class Translator implements TranslatorInterface {
|
|
|
527
563
|
}
|
|
528
564
|
|
|
529
565
|
/**
|
|
530
|
-
*
|
|
566
|
+
* 배열 번역 값 가져오기 (타입 안전)
|
|
531
567
|
*/
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return
|
|
536
|
-
}
|
|
568
|
+
tArray(key: string, language?: string): string[] {
|
|
569
|
+
const raw = this.getRawValue(key, language);
|
|
570
|
+
if (Array.isArray(raw) && raw.every((v: unknown) => typeof v === 'string')) {
|
|
571
|
+
return raw as string[];
|
|
572
|
+
}
|
|
573
|
+
if (process.env.NODE_ENV === 'development') {
|
|
574
|
+
console.warn(`tArray: "${key}" is not a string array`);
|
|
575
|
+
}
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Intl.PluralRules 인스턴스 (언어별 캐시)
|
|
581
|
+
*/
|
|
582
|
+
private getPluralRules(language: string): Intl.PluralRules {
|
|
583
|
+
let rules = this.pluralRulesCache.get(language);
|
|
584
|
+
if (!rules) {
|
|
585
|
+
rules = new Intl.PluralRules(language);
|
|
586
|
+
this.pluralRulesCache.set(language, rules);
|
|
587
|
+
}
|
|
588
|
+
return rules;
|
|
537
589
|
}
|
|
538
590
|
|
|
539
591
|
/**
|
|
540
|
-
*
|
|
592
|
+
* 복수형 번역 (ICU / Intl.PluralRules 기반)
|
|
593
|
+
*
|
|
594
|
+
* JSON: { "other": "총 {count}개" } (ko)
|
|
595
|
+
* { "one": "{count} item", "other": "{count} items" } (en)
|
|
596
|
+
*
|
|
597
|
+
* tPlural('common:total_count', 1) → en: "1 item" / ko: "총 1개"
|
|
598
|
+
* tPlural('common:total_count', 5) → en: "5 items" / ko: "총 5개"
|
|
541
599
|
*/
|
|
542
|
-
|
|
543
|
-
const
|
|
600
|
+
tPlural(key: string, count: number, params?: Record<string, unknown>, language?: string): string {
|
|
601
|
+
const targetLang = language || this.currentLang;
|
|
602
|
+
const raw = this.getRawValue(key, targetLang);
|
|
603
|
+
const mergedParams: Record<string, unknown> = { count, ...params };
|
|
604
|
+
|
|
605
|
+
// PluralValue 객체인 경우: Intl.PluralRules로 카테고리 결정
|
|
606
|
+
if (isPluralValue(raw)) {
|
|
607
|
+
const category = this.getPluralRules(targetLang).select(count) as PluralCategory;
|
|
608
|
+
const text = raw[category] ?? raw.other;
|
|
609
|
+
return this.interpolate(text, mergedParams);
|
|
610
|
+
}
|
|
544
611
|
|
|
545
|
-
|
|
546
|
-
|
|
612
|
+
// fallback: plain string이면 interpolate만
|
|
613
|
+
if (typeof raw === 'string') {
|
|
614
|
+
return this.interpolate(raw, mergedParams);
|
|
547
615
|
}
|
|
548
616
|
|
|
549
|
-
|
|
617
|
+
// 키를 찾지 못한 경우
|
|
618
|
+
if (this.config.debug) {
|
|
619
|
+
return this.interpolate(key, mergedParams);
|
|
620
|
+
}
|
|
621
|
+
return '';
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 매개변수 보간
|
|
626
|
+
*
|
|
627
|
+
* 지원 형식:
|
|
628
|
+
* - {key} - 단일 중괄호 (일반적인 i18n 형식)
|
|
629
|
+
* - {{key}} - 이중 중괄호 (하위 호환성)
|
|
630
|
+
*/
|
|
631
|
+
private interpolate(text: string, params: Record<string, unknown>): string {
|
|
632
|
+
// 단일 중괄호 {key} 또는 이중 중괄호 {{key}} 모두 지원
|
|
633
|
+
return text.replace(/\{\{?(\w+)\}?\}/g, (match, key) => {
|
|
634
|
+
const value = params[key];
|
|
635
|
+
return value !== undefined ? String(value) : match;
|
|
636
|
+
});
|
|
550
637
|
}
|
|
551
638
|
|
|
552
639
|
/**
|
|
@@ -866,23 +953,25 @@ export class Translator implements TranslatorInterface {
|
|
|
866
953
|
}
|
|
867
954
|
|
|
868
955
|
/**
|
|
869
|
-
* 키 파싱 (네임스페이스:키
|
|
870
|
-
*
|
|
956
|
+
* 키 파싱 (네임스페이스:키 형식)
|
|
957
|
+
*
|
|
958
|
+
* - 콜론(:)만 네임스페이스 구분자로 사용
|
|
959
|
+
* - 점(.)은 키 이름의 일부로 취급 (중첩 객체 접근용)
|
|
960
|
+
*
|
|
961
|
+
* @example
|
|
962
|
+
* parseKey("home:hero.badge") → { namespace: "home", key: "hero.badge" }
|
|
963
|
+
* parseKey("hero.badge") → { namespace: "common", key: "hero.badge" }
|
|
964
|
+
* parseKey("save") → { namespace: "common", key: "save" }
|
|
871
965
|
*/
|
|
872
966
|
private parseKey(key: string): { namespace: string; key: string } {
|
|
873
|
-
// :
|
|
967
|
+
// 콜론(:)만 네임스페이스 구분자로 사용
|
|
874
968
|
const colonIndex = key.indexOf(':');
|
|
875
969
|
if (colonIndex !== -1) {
|
|
876
970
|
return { namespace: key.substring(0, colonIndex), key: key.substring(colonIndex + 1) };
|
|
877
971
|
}
|
|
878
972
|
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
if (dotIndex !== -1) {
|
|
882
|
-
return { namespace: key.substring(0, dotIndex), key: key.substring(dotIndex + 1) };
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// 구분자가 없으면 common 네임스페이스로 간주
|
|
973
|
+
// 콜론이 없으면 common 네임스페이스로 간주
|
|
974
|
+
// 점(.)은 키 이름의 일부 (중첩 객체 접근은 getNestedValue에서 처리)
|
|
886
975
|
return { namespace: 'common', key };
|
|
887
976
|
}
|
|
888
977
|
|
|
@@ -1063,20 +1152,20 @@ function isStringValue(value: unknown): value is string {
|
|
|
1063
1152
|
return typeof value === 'string' && value.length > 0;
|
|
1064
1153
|
}
|
|
1065
1154
|
|
|
1155
|
+
/**
|
|
1156
|
+
* 키 파싱 (네임스페이스:키 형식) - SSR용 standalone 함수
|
|
1157
|
+
*
|
|
1158
|
+
* - 콜론(:)만 네임스페이스 구분자로 사용
|
|
1159
|
+
* - 점(.)은 키 이름의 일부로 취급 (중첩 객체 접근용)
|
|
1160
|
+
*/
|
|
1066
1161
|
function parseKey(key: string): { namespace: string; key: string } {
|
|
1067
|
-
// :
|
|
1162
|
+
// 콜론(:)만 네임스페이스 구분자로 사용
|
|
1068
1163
|
const colonIndex = key.indexOf(':');
|
|
1069
1164
|
if (colonIndex !== -1) {
|
|
1070
1165
|
return { namespace: key.substring(0, colonIndex), key: key.substring(colonIndex + 1) };
|
|
1071
1166
|
}
|
|
1072
1167
|
|
|
1073
|
-
//
|
|
1074
|
-
const dotIndex = key.indexOf('.');
|
|
1075
|
-
if (dotIndex !== -1) {
|
|
1076
|
-
return { namespace: key.substring(0, dotIndex), key: key.substring(dotIndex + 1) };
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// 구분자가 없으면 common 네임스페이스로 간주
|
|
1168
|
+
// 콜론이 없으면 common 네임스페이스로 간주
|
|
1080
1169
|
return { namespace: 'common', key };
|
|
1081
1170
|
}
|
|
1082
1171
|
|