@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
@@ -160,7 +160,7 @@ export function I18nProvider({
160
160
  }
161
161
  };
162
162
 
163
- // hua-i18n-sdk 언어 전환 이벤트 감지
163
+ // HUA i18n 언어 전환 이벤트 감지
164
164
  window.addEventListener('huaI18nLanguageChange', handleLanguageChange as EventListener);
165
165
 
166
166
  // 일반적인 언어 변경 이벤트도 감지
@@ -226,6 +226,26 @@ export function I18nProvider({
226
226
  return { namespace: 'common', key };
227
227
  }, []);
228
228
 
229
+ // 네스티드 키 해석 (예: "nav.docs" → obj.nav.docs)
230
+ const resolveNestedKey = useCallback((obj: Record<string, unknown>, key: string): string | null => {
231
+ // 1차: flat 접근 시도 (키에 점이 없거나 flat 구조인 경우)
232
+ if (key in obj && typeof obj[key] === 'string') {
233
+ return obj[key] as string;
234
+ }
235
+
236
+ // 2차: 네스티드 접근 (점 경로 탐색)
237
+ const parts = key.split('.');
238
+ let current: unknown = obj;
239
+ for (const part of parts) {
240
+ if (current && typeof current === 'object' && current !== null && part in (current as Record<string, unknown>)) {
241
+ current = (current as Record<string, unknown>)[part];
242
+ } else {
243
+ return null;
244
+ }
245
+ }
246
+ return typeof current === 'string' ? current : null;
247
+ }, []);
248
+
229
249
  // SSR 번역에서 찾기
230
250
  const findInSSRTranslations = useCallback((key: string, targetLang: string): string | null => {
231
251
  if (!config.initialTranslations) {
@@ -233,90 +253,102 @@ export function I18nProvider({
233
253
  }
234
254
 
235
255
  const { namespace, key: actualKey } = parseKey(key);
236
-
256
+
237
257
  // 현재 언어의 SSR 번역 확인
238
258
  const ssrTranslations = config.initialTranslations[targetLang]?.[namespace];
239
- if (ssrTranslations && ssrTranslations[actualKey]) {
240
- const value = ssrTranslations[actualKey];
241
- if (typeof value === 'string') {
259
+ if (ssrTranslations) {
260
+ const value = resolveNestedKey(ssrTranslations as Record<string, unknown>, actualKey);
261
+ if (value !== null) {
242
262
  return value;
243
263
  }
244
264
  }
245
-
265
+
246
266
  // 폴백 언어의 SSR 번역 확인
247
267
  const fallbackLang = config.fallbackLanguage || 'en';
248
268
  if (targetLang !== fallbackLang) {
249
269
  const fallbackTranslations = config.initialTranslations[fallbackLang]?.[namespace];
250
- if (fallbackTranslations && fallbackTranslations[actualKey]) {
251
- const value = fallbackTranslations[actualKey];
252
- if (typeof value === 'string') {
270
+ if (fallbackTranslations) {
271
+ const value = resolveNestedKey(fallbackTranslations as Record<string, unknown>, actualKey);
272
+ if (value !== null) {
253
273
  return value;
254
274
  }
255
275
  }
256
276
  }
257
-
277
+
258
278
  return null;
259
- }, [config.initialTranslations, config.fallbackLanguage, parseKey]);
279
+ }, [config.initialTranslations, config.fallbackLanguage, parseKey, resolveNestedKey]);
260
280
 
261
281
  // 기본 번역에서 찾기
262
282
  const findInDefaultTranslations = useCallback((key: string, targetLang: string): string | null => {
263
283
  const { namespace, key: actualKey } = parseKey(key);
264
284
  const defaultTranslations = getDefaultTranslations(targetLang, namespace);
265
285
  const fallbackTranslations = getDefaultTranslations(config.fallbackLanguage || 'en', namespace);
266
-
267
- return defaultTranslations[actualKey] || fallbackTranslations[actualKey] || null;
268
- }, [config.fallbackLanguage, parseKey]);
286
+
287
+ return resolveNestedKey(defaultTranslations as Record<string, unknown>, actualKey)
288
+ || resolveNestedKey(fallbackTranslations as Record<string, unknown>, actualKey)
289
+ || null;
290
+ }, [config.fallbackLanguage, parseKey, resolveNestedKey]);
269
291
 
270
292
  // hua-api 스타일의 간단한 번역 함수 (메모이제이션)
271
293
  // translationVersion과 currentLanguage에 의존하여 번역 로드 및 언어 변경 시 리렌더링 트리거
272
- const t = useCallback((key: string, language?: string) => {
294
+ const t = useCallback((key: string, paramsOrLang?: TranslationParams | string, language?: string) => {
273
295
  // translationVersion과 currentLanguage를 참조하여 번역 로드 및 언어 변경 시 리렌더링 트리거
274
- // 의존성 배열에 포함되어 있어서 값이 변경되면 함수가 재생성됨
275
296
  void translationVersion;
276
297
  void currentLanguage;
277
-
298
+
278
299
  if (!translator) {
279
300
  return key;
280
301
  }
281
-
282
- const targetLang = language || currentLanguage;
283
-
284
- // 1단계: translator.translate() 시도
302
+
303
+ // 번째 인자 타입으로 분기
304
+ let params: TranslationParams | undefined;
305
+ let lang: string | undefined;
306
+ if (typeof paramsOrLang === 'string') {
307
+ lang = paramsOrLang;
308
+ } else if (typeof paramsOrLang === 'object' && paramsOrLang !== null) {
309
+ params = paramsOrLang;
310
+ lang = language;
311
+ }
312
+
313
+ const targetLang = lang || currentLanguage;
314
+
315
+ // 1단계: translator.translate() 시도 (params가 있으면 translate에 위임)
285
316
  try {
286
- const result = translator.translate(key, language);
317
+ const result = translator.translate(key, params || lang, params ? lang : undefined);
287
318
  if (result && result !== key && result !== '') {
288
319
  return result;
289
320
  }
290
321
  } catch (error) {
291
322
  // translator.translate() 실패 시 다음 단계로 진행
292
323
  }
293
-
324
+
325
+ // interpolate 헬퍼
326
+ const interpolate = (text: string) => {
327
+ if (!params) return text;
328
+ return text.replace(/\{\{(\w+)\}\}/g, (match, k) => {
329
+ const value = params![k];
330
+ return value !== undefined ? String(value) : match;
331
+ });
332
+ };
333
+
294
334
  // 2단계: SSR 번역 데이터에서 찾기
295
335
  const ssrResult = findInSSRTranslations(key, targetLang);
296
336
  if (ssrResult) {
297
- return ssrResult;
337
+ return interpolate(ssrResult);
298
338
  }
299
-
339
+
300
340
  // 3단계: 기본 번역 데이터에서 찾기
301
341
  const defaultResult = findInDefaultTranslations(key, targetLang);
302
342
  if (defaultResult) {
303
- return defaultResult;
343
+ return interpolate(defaultResult);
304
344
  }
305
-
345
+
306
346
  // 모든 단계에서 번역을 찾지 못한 경우
307
347
  if (config.debug) {
308
- return key; // 개발 환경에서는 키를 표시하여 디버깅 가능
348
+ return interpolate(key); // 개발 환경에서는 키를 표시하여 디버깅 가능
309
349
  }
310
350
  return ''; // 프로덕션에서는 빈 문자열 반환하여 미싱 키 노출 방지
311
- }, [translator, config.debug, currentLanguage, config.fallbackLanguage, translationVersion, findInSSRTranslations, findInDefaultTranslations]) as (key: string, language?: string) => string;
312
-
313
- // 파라미터가 있는 번역 함수 (메모이제이션)
314
- const tWithParams = useCallback((key: string, params?: TranslationParams, language?: string) => {
315
- if (!translator || !isInitialized) {
316
- return key;
317
- }
318
- return translator.translateWithParams(key, params, language);
319
- }, [translator, isInitialized]);
351
+ }, [translator, config.debug, currentLanguage, config.fallbackLanguage, translationVersion, findInSSRTranslations, findInDefaultTranslations]) as (key: string, paramsOrLang?: TranslationParams | string, language?: string) => string;
320
352
 
321
353
  // 기존 비동기 번역 함수 (하위 호환성)
322
354
  const tAsync = useCallback(async (key: string, params?: TranslationParams) => {
@@ -361,6 +393,26 @@ export function I18nProvider({
361
393
  return translator.getRawValue(key, language);
362
394
  }, [translator, isInitialized]);
363
395
 
396
+ // 배열 번역 값 가져오기 (타입 안전)
397
+ const tArray = useCallback((key: string, language?: string): string[] => {
398
+ void translationVersion;
399
+ void currentLanguage;
400
+ if (!translator || !isInitialized) {
401
+ return [];
402
+ }
403
+ return translator.tArray(key, language);
404
+ }, [translator, isInitialized, translationVersion, currentLanguage]);
405
+
406
+ // 복수형 번역 (ICU / Intl.PluralRules 기반)
407
+ const tPlural = useCallback((key: string, count: number, params?: Record<string, unknown>, language?: string): string => {
408
+ void translationVersion;
409
+ void currentLanguage;
410
+ if (!translator || !isInitialized) {
411
+ return key;
412
+ }
413
+ return translator.tPlural(key, count, params, language);
414
+ }, [translator, isInitialized, translationVersion, currentLanguage]);
415
+
364
416
  // 개발자 도구 (메모이제이션)
365
417
  const debug = useMemo(() => ({
366
418
  getCurrentLanguage: () => {
@@ -462,7 +514,8 @@ export function I18nProvider({
462
514
  currentLanguage,
463
515
  setLanguage,
464
516
  t,
465
- tWithParams,
517
+ tPlural,
518
+ tArray,
466
519
  tAsync,
467
520
  tSync,
468
521
  getRawValue,
@@ -470,12 +523,9 @@ export function I18nProvider({
470
523
  error,
471
524
  supportedLanguages: config.supportedLanguages,
472
525
  debug,
473
- isInitialized, // 추가: 초기화 상태 직접 노출
474
- translationVersion, // 번역 로드 완료 시 리렌더링 트리거
475
- }), [currentLanguage, setLanguage, t, tWithParams, tAsync, tSync, getRawValue, isLoading, error, config.supportedLanguages, debug, isInitialized, translationVersion]);
476
-
477
- // 의존성 배열은 이미 최적화되어 있음
478
- // t, tWithParams, tAsync, tSync, getRawValue는 모두 useCallback으로 메모이제이션됨
526
+ isInitialized,
527
+ translationVersion,
528
+ }), [currentLanguage, setLanguage, t, tPlural, tArray, tAsync, tSync, getRawValue, isLoading, error, config.supportedLanguages, debug, isInitialized, translationVersion]);
479
529
 
480
530
  return (
481
531
  <I18nContext.Provider value={value}>
@@ -495,10 +545,11 @@ export function useI18n(): I18nContextType {
495
545
  currentLanguage: 'ko',
496
546
  setLanguage: () => {},
497
547
  t: (key: string) => key,
498
- tWithParams: (key: string) => key,
548
+ tPlural: (key: string) => key,
499
549
  tAsync: async (key: string) => key,
500
550
  tSync: (key: string) => key,
501
551
  getRawValue: () => undefined,
552
+ tArray: () => [],
502
553
  isLoading: false,
503
554
  error: null,
504
555
  supportedLanguages: [
@@ -522,74 +573,4 @@ export function useI18n(): I18nContextType {
522
573
  return context;
523
574
  }
524
575
 
525
- /**
526
- * 간단한 번역 훅 (hua-api 스타일)
527
- */
528
- export function useTranslation() {
529
- const { t, tWithParams, currentLanguage, setLanguage, isLoading, error, supportedLanguages } = useI18n();
530
-
531
- return {
532
- t,
533
- tWithParams,
534
- currentLanguage,
535
- setLanguage,
536
- isLoading,
537
- error,
538
- supportedLanguages,
539
- };
540
- }
541
-
542
- /**
543
- * 언어 변경 훅
544
- */
545
- export function useLanguageChange() {
546
- const context = useContext(I18nContext);
547
-
548
- // Provider 밖에서 호출되면 기본값 반환
549
- if (!context) {
550
- return {
551
- currentLanguage: 'ko',
552
- changeLanguage: () => {},
553
- supportedLanguages: [
554
- { code: 'ko', name: 'Korean', nativeName: '한국어' },
555
- { code: 'en', name: 'English', nativeName: 'English' },
556
- ],
557
- };
558
- }
559
-
560
- const { currentLanguage, setLanguage, supportedLanguages } = context;
561
-
562
- const changeLanguage = useCallback((language: string) => {
563
- const supported = supportedLanguages.find(lang => lang.code === language);
564
- if (supported) {
565
- setLanguage(language);
566
- } else {
567
- console.warn(`Language ${language} is not supported`);
568
- }
569
- }, [setLanguage, supportedLanguages]);
570
-
571
- return {
572
- currentLanguage,
573
- changeLanguage,
574
- supportedLanguages,
575
- };
576
- }
577
-
578
- // 기존 훅들 (하위 호환성을 위해 유지)
579
- export function usePreloadTranslations() {
580
- const context = useContext(I18nContext);
581
-
582
- const preload = useCallback(async (namespaces: string[]) => {
583
- if (!context) return;
584
-
585
- // 이미 초기화되어 있으므로 별도 로딩 불필요
586
- console.warn('usePreloadTranslations is deprecated. Translations are now preloaded automatically.');
587
- }, [context]);
588
-
589
- return { preload };
590
- }
591
576
 
592
- export function useAutoLoadNamespace(namespace: string) {
593
- // 이미 초기화되어 있으므로 별도 로딩 불필요
594
- console.warn('useAutoLoadNamespace is deprecated. All namespaces are now loaded automatically.');
595
- }
@@ -1,17 +1,18 @@
1
1
  "use client";
2
2
 
3
+ import { useCallback } from 'react';
3
4
  import { useI18n } from './useI18n';
4
5
 
5
6
  /**
6
7
  * 간단한 번역 훅 (원본 SDK와 호환)
7
- *
8
+ *
8
9
  * @example
9
10
  * ```tsx
10
11
  * import { useTranslation } from '@hua-labs/i18n-core';
11
- *
12
+ *
12
13
  * function MyComponent() {
13
14
  * const { t, currentLanguage, setLanguage, isLoading, error } = useTranslation();
14
- *
15
+ *
15
16
  * return (
16
17
  * <div>
17
18
  * <h1>{t('welcome')}</h1>
@@ -23,11 +24,12 @@ import { useI18n } from './useI18n';
23
24
  * ```
24
25
  */
25
26
  export function useTranslation() {
26
- const { t, tWithParams, currentLanguage, setLanguage, getRawValue, isLoading, error, supportedLanguages, debug, isInitialized } = useI18n();
27
-
27
+ const { t, tPlural, tArray, currentLanguage, setLanguage, getRawValue, isLoading, error, supportedLanguages, debug, isInitialized } = useI18n();
28
+
28
29
  return {
29
30
  t,
30
- tWithParams,
31
+ tPlural,
32
+ tArray,
31
33
  currentLanguage,
32
34
  setLanguage,
33
35
  getRawValue,
@@ -44,15 +46,15 @@ export function useTranslation() {
44
46
  */
45
47
  export function useLanguageChange() {
46
48
  const { currentLanguage, setLanguage, supportedLanguages } = useI18n();
47
-
48
- const changeLanguage = (language: string) => {
49
+
50
+ const changeLanguage = useCallback((language: string) => {
49
51
  const supported = supportedLanguages.find(lang => lang.code === language);
50
52
  if (supported) {
51
53
  setLanguage(language);
52
54
  } else {
53
- console.warn(`Language ${language} is not supported`);
55
+ if (process.env.NODE_ENV !== 'production') console.warn(`Language ${language} is not supported`);
54
56
  }
55
- };
57
+ }, [setLanguage, supportedLanguages]);
56
58
 
57
59
  return {
58
60
  currentLanguage,
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import React from 'react';
9
9
  import { I18nProvider, useI18n } from './hooks/useI18n';
10
10
  import { useTranslation, useLanguageChange } from './hooks/useTranslation';
11
11
  import { Translator, ssrTranslate, serverTranslate } from './core/translator';
12
- import { I18nConfig } from './types';
12
+ import { I18nConfig, I18nContextType, TranslationParams, TypedTranslationKeys, ResolveStringKey, ResolveArrayKey, ResolvePluralKey, PluralValue, PluralCategory } from './types';
13
13
 
14
14
  // Window 객체 타입 확장
15
15
  declare global {
@@ -56,10 +56,18 @@ declare global {
56
56
  }
57
57
  }
58
58
 
59
- // 기본 언어 설정
59
+ // 기본 언어 설정 (10개 언어 지원)
60
60
  const defaultLanguages = [
61
61
  { code: 'ko', name: 'Korean', nativeName: '한국어' },
62
62
  { code: 'en', name: 'English', nativeName: 'English' },
63
+ { code: 'en-IN', name: 'English (India)', nativeName: 'English (India)' },
64
+ { code: 'ja', name: 'Japanese', nativeName: '日本語' },
65
+ { code: 'zh', name: 'Chinese (Simplified)', nativeName: '简体中文' },
66
+ { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
67
+ { code: 'es', name: 'Spanish', nativeName: 'Español' },
68
+ { code: 'ru', name: 'Russian', nativeName: 'Русский' },
69
+ { code: 'de', name: 'German', nativeName: 'Deutsch' },
70
+ { code: 'fr', name: 'French', nativeName: 'Français' },
63
71
  ];
64
72
 
65
73
  /**
@@ -108,11 +116,26 @@ export function createCoreI18n(options?: {
108
116
  * 형식: { [language]: { [namespace]: { [key]: value } } }
109
117
  */
110
118
  initialTranslations?: Record<string, Record<string, Record<string, string>>>;
119
+ /**
120
+ * 지원 언어 목록 (LanguageConfig 배열 또는 언어 코드 문자열 배열)
121
+ * 기본값: ['ko', 'en']
122
+ */
123
+ supportedLanguages?: Array<{ code: string; name: string; nativeName: string }> | string[];
111
124
  /**
112
125
  * 자동 언어 동기화 활성화 여부
113
126
  * 기본값: false (Zustand 어댑터 등 외부에서 직접 처리하는 경우)
114
127
  */
115
128
  autoLanguageSync?: boolean;
129
+ /**
130
+ * 서버사이드 렌더링 시 사용할 기본 URL
131
+ * 환경 변수보다 우선 적용됨
132
+ */
133
+ baseUrl?: string;
134
+ /**
135
+ * 로컬 개발 환경 fallback URL
136
+ * 기본값: 'http://localhost:3010'
137
+ */
138
+ localFallbackBaseUrl?: string;
116
139
  }) {
117
140
  const {
118
141
  defaultLanguage = 'ko',
@@ -123,9 +146,83 @@ export function createCoreI18n(options?: {
123
146
  translationLoader = 'api',
124
147
  translationApiPath = '/api/translations',
125
148
  initialTranslations,
126
- autoLanguageSync = false // 기본값 false (Zustand 어댑터 등 외부에서 직접 처리)
149
+ supportedLanguages: providedSupportedLanguages,
150
+ autoLanguageSync = false, // 기본값 false (Zustand 어댑터 등 외부에서 직접 처리)
151
+ baseUrl,
152
+ localFallbackBaseUrl,
127
153
  } = options || {};
128
154
 
155
+ // supportedLanguages 처리: string[] 또는 LanguageConfig[] 모두 지원
156
+ let supportedLanguagesConfig: Array<{ code: string; name: string; nativeName: string }>;
157
+ if (providedSupportedLanguages) {
158
+ if (Array.isArray(providedSupportedLanguages) && providedSupportedLanguages.length > 0) {
159
+ // string[]인지 LanguageConfig[]인지 확인
160
+ if (typeof providedSupportedLanguages[0] === 'string') {
161
+ // string[]를 LanguageConfig[]로 변환
162
+ const languageMap: Record<string, { name: string; nativeName: string }> = {
163
+ ko: { name: 'Korean', nativeName: '한국어' },
164
+ en: { name: 'English', nativeName: 'English' },
165
+ ja: { name: 'Japanese', nativeName: '日本語' },
166
+ zh: { name: 'Chinese', nativeName: '中文' },
167
+ es: { name: 'Spanish', nativeName: 'Español' },
168
+ fr: { name: 'French', nativeName: 'Français' },
169
+ de: { name: 'German', nativeName: 'Deutsch' },
170
+ pt: { name: 'Portuguese', nativeName: 'Português' },
171
+ it: { name: 'Italian', nativeName: 'Italiano' },
172
+ ru: { name: 'Russian', nativeName: 'Русский' },
173
+ };
174
+ supportedLanguagesConfig = (providedSupportedLanguages as string[]).map(code => ({
175
+ code,
176
+ name: languageMap[code]?.name || code,
177
+ nativeName: languageMap[code]?.nativeName || code,
178
+ }));
179
+ } else {
180
+ // LanguageConfig[]인 경우 그대로 사용
181
+ supportedLanguagesConfig = providedSupportedLanguages as Array<{ code: string; name: string; nativeName: string }>;
182
+ }
183
+ } else {
184
+ supportedLanguagesConfig = defaultLanguages;
185
+ }
186
+ } else {
187
+ supportedLanguagesConfig = defaultLanguages;
188
+ }
189
+
190
+ /**
191
+ * 서버사이드/클라이언트사이드 URL 빌드 함수
192
+ * createApiTranslationLoader의 로직을 참고하여 구현
193
+ */
194
+ const buildUrl = (language: string, namespace: string): string => {
195
+ const safeNamespace = namespace.replace(/[^a-zA-Z0-9-_]/g, '');
196
+ const path = `${translationApiPath}/${language}/${safeNamespace}`;
197
+
198
+ // 클라이언트 사이드: 상대 경로 사용
199
+ if (typeof window !== 'undefined') {
200
+ return path;
201
+ }
202
+
203
+ // 서버사이드: 절대 URL 필요
204
+ // 1. baseUrl 옵션이 있으면 우선 사용
205
+ if (baseUrl) {
206
+ return `${baseUrl}${path}`;
207
+ }
208
+
209
+ // 2. NEXT_PUBLIC_SITE_URL 환경 변수 확인
210
+ if (process.env.NEXT_PUBLIC_SITE_URL) {
211
+ return `${process.env.NEXT_PUBLIC_SITE_URL}${path}`;
212
+ }
213
+
214
+ // 3. VERCEL_URL 환경 변수 확인
215
+ if (process.env.VERCEL_URL) {
216
+ const vercelUrl = process.env.VERCEL_URL.startsWith('http')
217
+ ? process.env.VERCEL_URL
218
+ : `https://${process.env.VERCEL_URL}`;
219
+ return `${vercelUrl}${path}`;
220
+ }
221
+
222
+ // 4. 로컬 개발 환경 fallback
223
+ const fallbackBase = localFallbackBaseUrl ?? 'http://localhost:3010';
224
+ return `${fallbackBase}${path}`;
225
+ };
129
226
  // API route 기반 로더 (기본값, 권장)
130
227
  const apiRouteLoader = async (language: string, namespace: string) => {
131
228
  try {
@@ -211,7 +308,7 @@ export function createCoreI18n(options?: {
211
308
  const config: I18nConfig = {
212
309
  defaultLanguage,
213
310
  fallbackLanguage,
214
- supportedLanguages: defaultLanguages,
311
+ supportedLanguages: supportedLanguagesConfig,
215
312
  namespaces,
216
313
  loadTranslations: translationLoader === 'custom' && loadTranslations
217
314
  ? loadTranslations
@@ -295,4 +392,4 @@ export { I18nProvider };
295
392
  export { Translator, ssrTranslate, serverTranslate };
296
393
 
297
394
  // 타입 export
298
- export type { I18nConfig };
395
+ export type { I18nConfig, I18nContextType, TranslationParams, TypedTranslationKeys, ResolveStringKey, ResolveArrayKey, ResolvePluralKey, PluralValue, PluralCategory };
package/src/server.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @hua-labs/i18n-core/server
3
+ *
4
+ * Server-only entry point — no React hooks, no "use client" directives.
5
+ * Safe to use in Next.js Server Components, API routes, and middleware.
6
+ */
7
+
8
+ export { Translator, ssrTranslate, serverTranslate } from './core/translator';
9
+ export type { I18nConfig } from './types';
@@ -1,5 +1,36 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Plural (ICU / Intl.PluralRules)
3
+ // ---------------------------------------------------------------------------
4
+ export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
5
+
6
+ export interface PluralValue {
7
+ zero?: string;
8
+ one?: string;
9
+ two?: string;
10
+ few?: string;
11
+ many?: string;
12
+ other: string; // 필수
13
+ }
14
+
15
+ const PLURAL_CATEGORIES = new Set<string>(['zero', 'one', 'two', 'few', 'many', 'other']);
16
+
17
+ export function isPluralValue(value: unknown): value is PluralValue {
18
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
19
+ const obj = value as Record<string, unknown>;
20
+ const keys = Object.keys(obj);
21
+ return (
22
+ keys.length > 0 &&
23
+ keys.every(k => PLURAL_CATEGORIES.has(k)) &&
24
+ Object.values(obj).every(v => typeof v === 'string') &&
25
+ typeof obj.other === 'string'
26
+ );
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Translation namespace
31
+ // ---------------------------------------------------------------------------
1
32
  export interface TranslationNamespace {
2
- [key: string]: string | TranslationNamespace;
33
+ [key: string]: string | string[] | PluralValue | TranslationNamespace;
3
34
  }
4
35
 
5
36
  export interface TranslationData {
@@ -120,17 +151,19 @@ export interface TranslationResult {
120
151
 
121
152
  export interface I18nContextType {
122
153
  currentLanguage: string;
123
- setLanguage: (language: string) => void;
124
- // hua-api 스타일의 간단한 번역 함수
125
- t: (key: string, language?: string) => string;
126
- // 파라미터가 있는 번역 함수
127
- tWithParams: (key: string, params?: TranslationParams, language?: string) => string;
154
+ setLanguage: (language: string) => void | Promise<void>;
155
+ // 통합 번역 함수: t(key), t(key, language), t(key, params), t(key, params, language)
156
+ t: (key: ResolveStringKey, paramsOrLang?: TranslationParams | string, language?: string) => string;
157
+ // 복수형 번역 함수: tPlural(key, count), tPlural(key, count, params), tPlural(key, count, params, language)
158
+ tPlural: (key: ResolvePluralKey, count: number, params?: Record<string, unknown>, language?: string) => string;
128
159
  // 기존 비동기 번역 함수 (하위 호환성)
129
160
  tAsync: (key: string, params?: TranslationParams) => Promise<string>;
130
161
  // 기존 동기 번역 함수 (하위 호환성)
131
162
  tSync: (key: string, namespace?: string, params?: TranslationParams) => string;
132
163
  // 원시 값 가져오기 (배열, 객체 포함)
133
164
  getRawValue: (key: string, language?: string) => unknown;
165
+ // 배열 번역 값 가져오기 (타입 안전)
166
+ tArray: (key: ResolveArrayKey, language?: string) => string[];
134
167
  isLoading: boolean;
135
168
  error: TranslationError | null;
136
169
  supportedLanguages: LanguageConfig[];
@@ -156,7 +189,30 @@ export interface I18nContextType {
156
189
 
157
190
  export interface TranslationParams {
158
191
  [key: string]: string | number;
159
- }
192
+ }
193
+
194
+ /**
195
+ * 타입 안전한 번역 키를 위한 augmentation point.
196
+ *
197
+ * 앱에서 declaration merging으로 좁힐 수 있음:
198
+ * ```ts
199
+ * declare module '@hua-labs/i18n-core' {
200
+ * interface TypedTranslationKeys {
201
+ * stringKey: TranslationStringKey;
202
+ * arrayKey: TranslationArrayKey;
203
+ * }
204
+ * }
205
+ * ```
206
+ *
207
+ * augmentation이 없으면 string으로 fallback (breaking 없음).
208
+ */
209
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
210
+ export interface TypedTranslationKeys {}
211
+
212
+ /** augmentation 시 좁혀진 타입, 미설정 시 string */
213
+ export type ResolveStringKey = TypedTranslationKeys extends { stringKey: infer K } ? K & string : string;
214
+ export type ResolveArrayKey = TypedTranslationKeys extends { arrayKey: infer K } ? K & string : string;
215
+ export type ResolvePluralKey = TypedTranslationKeys extends { pluralKey: infer K } ? K & string : string;
160
216
 
161
217
  // 타입 안전한 번역 키 시스템 (단순화된 버전)
162
218
  export type TranslationKey<T> = T extends Record<string, unknown>
@@ -170,10 +226,9 @@ export type TranslationKey<T> = T extends Record<string, unknown>
170
226
  : never;
171
227
 
172
228
  // 타입 안전한 번역 함수들
173
- export interface TypedI18nContextType<T extends TranslationData> extends Omit<I18nContextType, 't' | 'tWithParams' | 'tSync'> {
229
+ export interface TypedI18nContextType<T extends TranslationData> extends Omit<I18nContextType, 't' | 'tSync'> {
174
230
  // 타입 안전한 번역 함수
175
- t: <K extends TranslationKey<T>>(key: K, language?: string) => string;
176
- tWithParams: <K extends TranslationKey<T>>(key: K, params?: TranslationParams, language?: string) => string;
231
+ t: <K extends TranslationKey<T>>(key: K, paramsOrLang?: TranslationParams | string, language?: string) => string;
177
232
  tSync: <K extends TranslationKey<T>>(key: K, namespace?: string, params?: TranslationParams) => string;
178
233
  }
179
234
 
@@ -380,7 +435,7 @@ export const defaultErrorRecoveryStrategy: ErrorRecoveryStrategy = {
380
435
  backoffMultiplier: 2,
381
436
  shouldRetry: isRecoverableError,
382
437
  onRetry: (error: TranslationError, attempt: number) => {
383
- console.warn(`Retrying translation operation (attempt ${attempt}/${error.maxRetries}):`, error.message);
438
+ if (process.env.NODE_ENV !== 'production') console.warn(`Retrying translation operation (attempt ${attempt}/${error.maxRetries}):`, error.message);
384
439
  },
385
440
  onMaxRetriesExceeded: (error: TranslationError) => {
386
441
  console.error('Max retries exceeded for translation operation:', error.message);
@@ -430,7 +485,7 @@ export function logTranslationError(
430
485
  console.error('Translation Error:', logData);
431
486
  break;
432
487
  case 'warn':
433
- console.warn('Translation Warning:', logData);
488
+ if (process.env.NODE_ENV !== 'production') console.warn('Translation Warning:', logData);
434
489
  break;
435
490
  case 'info':
436
491
  console.info('Translation Info:', logData);