@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.
Files changed (78) hide show
  1. package/README.md +57 -597
  2. package/dist/chunk-F4PDBJLO.mjs +973 -0
  3. package/dist/chunk-F4PDBJLO.mjs.map +1 -0
  4. package/dist/index.d.mts +249 -0
  5. package/dist/index.d.ts +117 -30
  6. package/dist/index.js +1818 -177
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +845 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/server-4TeBq6hp.d.mts +367 -0
  11. package/dist/server-4TeBq6hp.d.ts +367 -0
  12. package/dist/server.d.mts +1 -0
  13. package/dist/server.d.ts +1 -0
  14. package/dist/server.js +977 -0
  15. package/dist/server.js.map +1 -0
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +1 -0
  18. package/package.json +42 -19
  19. package/src/__tests__/debug-tools.test.ts +359 -0
  20. package/src/__tests__/default-translations.test.ts +179 -0
  21. package/src/__tests__/i18n-resource.test.ts +137 -0
  22. package/src/__tests__/lazy-loader.test.ts +109 -0
  23. package/src/__tests__/missing-key-overlay.test.tsx +339 -0
  24. package/src/__tests__/translator-factory.test.ts +120 -0
  25. package/src/__tests__/translator.test.ts +442 -0
  26. package/src/__tests__/types.test.ts +211 -0
  27. package/src/__tests__/useI18n.test.tsx +181 -0
  28. package/src/__tests__/useTranslation.test.tsx +110 -0
  29. package/src/components/MissingKeyOverlay.tsx +1 -1
  30. package/src/core/lazy-loader.ts +2 -2
  31. package/src/core/translator.tsx +151 -62
  32. package/src/hooks/useI18n.tsx +96 -115
  33. package/src/hooks/useTranslation.tsx +12 -10
  34. package/src/index.ts +102 -5
  35. package/src/server.ts +9 -0
  36. package/src/types/index.ts +67 -12
  37. package/LICENSE +0 -21
  38. package/dist/components/MissingKeyOverlay.d.ts +0 -33
  39. package/dist/components/MissingKeyOverlay.d.ts.map +0 -1
  40. package/dist/components/MissingKeyOverlay.js +0 -138
  41. package/dist/components/MissingKeyOverlay.js.map +0 -1
  42. package/dist/core/debug-tools.d.ts +0 -37
  43. package/dist/core/debug-tools.d.ts.map +0 -1
  44. package/dist/core/debug-tools.js +0 -241
  45. package/dist/core/debug-tools.js.map +0 -1
  46. package/dist/core/i18n-resource.d.ts +0 -59
  47. package/dist/core/i18n-resource.d.ts.map +0 -1
  48. package/dist/core/i18n-resource.js +0 -153
  49. package/dist/core/i18n-resource.js.map +0 -1
  50. package/dist/core/lazy-loader.d.ts +0 -82
  51. package/dist/core/lazy-loader.d.ts.map +0 -1
  52. package/dist/core/lazy-loader.js +0 -193
  53. package/dist/core/lazy-loader.js.map +0 -1
  54. package/dist/core/translator-factory.d.ts +0 -50
  55. package/dist/core/translator-factory.d.ts.map +0 -1
  56. package/dist/core/translator-factory.js +0 -117
  57. package/dist/core/translator-factory.js.map +0 -1
  58. package/dist/core/translator.d.ts +0 -202
  59. package/dist/core/translator.d.ts.map +0 -1
  60. package/dist/core/translator.js +0 -912
  61. package/dist/core/translator.js.map +0 -1
  62. package/dist/hooks/useI18n.d.ts +0 -39
  63. package/dist/hooks/useI18n.d.ts.map +0 -1
  64. package/dist/hooks/useI18n.js +0 -531
  65. package/dist/hooks/useI18n.js.map +0 -1
  66. package/dist/hooks/useTranslation.d.ts +0 -55
  67. package/dist/hooks/useTranslation.d.ts.map +0 -1
  68. package/dist/hooks/useTranslation.js +0 -58
  69. package/dist/hooks/useTranslation.js.map +0 -1
  70. package/dist/index.d.ts.map +0 -1
  71. package/dist/types/index.d.ts +0 -162
  72. package/dist/types/index.d.ts.map +0 -1
  73. package/dist/types/index.js +0 -191
  74. package/dist/types/index.js.map +0 -1
  75. package/dist/utils/default-translations.d.ts +0 -20
  76. package/dist/utils/default-translations.d.ts.map +0 -1
  77. package/dist/utils/default-translations.js +0 -123
  78. 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
@@ -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, timestamp] of this.loadHistory.entries()) {
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
  }
@@ -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
- if (this.config.debug) {
148
- console.log('✅ [TRANSLATOR] Initial translations loaded from SSR:', this.loadedNamespaces);
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
- const translations = this.allTranslations[targetLang]?.[namespace];
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
- console.log(`🔍 [TRANSLATOR] Not initialized, trying fallback:`, {
326
+ const translations = this.allTranslations[targetLang]?.[namespace];
327
+ console.log(`🔍 [TRANSLATOR] Not initialized, fallback failed:`, {
313
328
  namespace,
314
329
  actualKey,
315
- translations,
316
- hasTranslation: translations && translations[actualKey]
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
- const targetLang = language || this.currentLang;
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
- return this.translateBeforeInitialized(key, targetLang);
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
- return this.config.missingKeyHandler?.(key, targetLang, namespace) || key;
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 && typeof current === 'object' && !Array.isArray(current) && key in 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
- private interpolate(text: string, params: Record<string, unknown>): string {
533
- return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
534
- const value = params[key];
535
- return value !== undefined ? String(value) : match;
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
- translateWithParams(key: string, params?: Record<string, unknown>, language?: string): string {
543
- const translated = this.translate(key, language);
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
- if (!params) {
546
- return translated;
612
+ // fallback: plain string이면 interpolate만
613
+ if (typeof raw === 'string') {
614
+ return this.interpolate(raw, mergedParams);
547
615
  }
548
616
 
549
- return this.interpolate(translated, params);
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
- const dotIndex = key.indexOf('.');
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