@hua-labs/i18n-core 2.1.0 → 2.2.1

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.
@@ -1,43 +1,52 @@
1
1
  "use client";
2
- import { useState, useEffect, useCallback, useContext, createContext, useMemo } from 'react';
3
- import { Translator } from '../core/translator';
4
- import { TranslatorFactory } from '../core/translator-factory';
5
- import {
6
- I18nConfig,
7
- I18nContextType,
8
- TranslationParams,
2
+ import {
3
+ useState,
4
+ useEffect,
5
+ useCallback,
6
+ useContext,
7
+ createContext,
8
+ useMemo,
9
+ } from "react";
10
+ import { Translator } from "../core/translator";
11
+ import { TranslatorFactory } from "../core/translator-factory";
12
+ import {
13
+ I18nConfig,
14
+ I18nContextType,
15
+ TranslationParams,
9
16
  TranslationError,
10
- validateI18nConfig
11
- } from '../types';
12
- import { getDefaultTranslations } from '../utils/default-translations';
17
+ validateI18nConfig,
18
+ webPlatformAdapter,
19
+ } from "../types";
20
+ import { getDefaultTranslations } from "../utils/default-translations";
13
21
 
14
22
  // React Context
15
23
  const I18nContext = createContext<I18nContextType | null>(null);
16
24
 
17
25
  /**
18
26
  * 초기 언어를 결정하는 헬퍼 함수
19
- * 우선순위: config.defaultLanguage > navigator.language 매칭 > supportedLanguages[0]
20
- * config.defaultLanguage가 명시적으로 제공되지 않은 경우에만 navigator.language 감지 동작
27
+ * 우선순위: config.defaultLanguage > platformAdapter.getDeviceLanguage 매칭 > supportedLanguages[0]
28
+ * config.defaultLanguage가 명시적으로 제공되지 않은 경우에만 디바이스 언어 감지 동작
21
29
  */
22
30
  function resolveInitialLanguage(
23
- config: I18nConfig & { autoLanguageSync?: boolean }
31
+ config: I18nConfig & { autoLanguageSync?: boolean },
24
32
  ): string {
25
33
  // 1. config.defaultLanguage가 명시적으로 제공된 경우 우선 사용
26
34
  if (config.defaultLanguage) {
27
35
  return config.defaultLanguage;
28
36
  }
29
37
 
30
- // 2. navigator.language 매칭 (SSR 환경에서는 navigator가 없으므로 체크 필요)
31
- if (typeof window !== 'undefined' && navigator?.language) {
32
- const browserLang = navigator.language.slice(0, 2).toLowerCase();
33
- const supportedCodes = config.supportedLanguages?.map(l => l.code) ?? [];
34
- if (supportedCodes.includes(browserLang)) {
35
- return browserLang;
38
+ // 2. 플랫폼 어댑터로 디바이스 언어 감지
39
+ const adapter = config.platformAdapter ?? webPlatformAdapter;
40
+ const deviceLang = adapter.getDeviceLanguage();
41
+ if (deviceLang) {
42
+ const supportedCodes = config.supportedLanguages?.map((l) => l.code) ?? [];
43
+ if (supportedCodes.includes(deviceLang)) {
44
+ return deviceLang;
36
45
  }
37
46
  }
38
47
 
39
48
  // 3. 첫 번째 지원 언어로 폴백
40
- return config.supportedLanguages?.[0]?.code ?? 'ko';
49
+ return config.supportedLanguages?.[0]?.code ?? "ko";
41
50
  }
42
51
 
43
52
  /**
@@ -45,12 +54,14 @@ function resolveInitialLanguage(
45
54
  */
46
55
  export function I18nProvider({
47
56
  config,
48
- children
57
+ children,
49
58
  }: {
50
59
  config: I18nConfig & { autoLanguageSync?: boolean };
51
60
  children: React.ReactNode;
52
61
  }) {
53
- const [currentLanguage, setCurrentLanguageState] = useState(() => resolveInitialLanguage(config));
62
+ const [currentLanguage, setCurrentLanguageState] = useState(() =>
63
+ resolveInitialLanguage(config),
64
+ );
54
65
  const [isLoading, setIsLoading] = useState(true);
55
66
  const [isInitialized, setIsInitialized] = useState(false);
56
67
  const [error, setError] = useState<TranslationError | null>(null);
@@ -68,7 +79,7 @@ export function I18nProvider({
68
79
  // Translator 인스턴스 초기화 (메모이제이션)
69
80
  const translator = useMemo(() => {
70
81
  if (!validateI18nConfig(config)) {
71
- throw new Error('Invalid I18nConfig provided to I18nProvider');
82
+ throw new Error("Invalid I18nConfig provided to I18nProvider");
72
83
  }
73
84
  return TranslatorFactory.create(config);
74
85
  }, [config]);
@@ -83,45 +94,52 @@ export function I18nProvider({
83
94
  // translator의 언어를 currentLanguage로 변경
84
95
  // 이는 외부에서 setLanguage를 호출했을 때 발생하는 정상적인 동기화
85
96
  if (config.debug) {
86
- console.log(`🔄 [USEI18N] Syncing translator language: ${translatorLang} -> ${currentLanguage} (already initialized)`);
97
+ console.log(
98
+ `🔄 [USEI18N] Syncing translator language: ${translatorLang} -> ${currentLanguage} (already initialized)`,
99
+ );
87
100
  }
88
101
  translator.setLanguage(currentLanguage);
89
102
  }
90
103
  return;
91
104
  }
92
-
105
+
93
106
  if (config.debug) {
94
- console.log('🔄 [USEI18N] useEffect triggered:', {
95
- hasTranslator: !!translator,
96
- currentLanguage,
107
+ console.log("🔄 [USEI18N] useEffect triggered:", {
108
+ hasTranslator: !!translator,
109
+ currentLanguage,
97
110
  debug: config.debug,
98
- isInitialized
111
+ isInitialized,
99
112
  });
100
113
  }
101
-
114
+
102
115
  const initializeTranslator = async () => {
103
116
  try {
104
117
  setIsLoading(true);
105
118
  setError(null);
106
-
119
+
107
120
  if (config.debug) {
108
- console.log('🚀 [USEI18N] Starting translator initialization...');
121
+ console.log("🚀 [USEI18N] Starting translator initialization...");
109
122
  }
110
-
123
+
111
124
  translator.setLanguage(currentLanguage);
112
-
125
+
113
126
  // 모든 번역 데이터 미리 로드
114
127
  await translator.initialize();
115
128
  setIsInitialized(true);
116
-
129
+
117
130
  if (config.debug) {
118
- console.log('✅ [USEI18N] Translator initialization completed successfully');
131
+ console.log(
132
+ "✅ [USEI18N] Translator initialization completed successfully",
133
+ );
119
134
  }
120
135
  } catch (err) {
121
136
  const initError = err as TranslationError;
122
137
  setError(initError);
123
138
  if (config.debug) {
124
- console.error('❌ [USEI18N] Failed to initialize translator:', initError);
139
+ console.error(
140
+ "❌ [USEI18N] Failed to initialize translator:",
141
+ initError,
142
+ );
125
143
  }
126
144
  // 에러가 발생해도 초기화 완료로 표시 (기본 번역 사용)
127
145
  setIsInitialized(true);
@@ -141,9 +159,9 @@ export function I18nProvider({
141
159
 
142
160
  const unsubscribe = translator.onTranslationLoaded(() => {
143
161
  // 번역이 로드되면 상태를 업데이트하여 리렌더링 트리거
144
- setTranslationVersion(prev => prev + 1);
162
+ setTranslationVersion((prev) => prev + 1);
145
163
  if (config.debug) {
146
- console.log('🔄 [USEI18N] Translation loaded, triggering re-render');
164
+ console.log("🔄 [USEI18N] Translation loaded, triggering re-render");
147
165
  }
148
166
  });
149
167
 
@@ -160,403 +178,521 @@ export function I18nProvider({
160
178
  const unsubscribe = translator.onLanguageChanged((newLanguage: string) => {
161
179
  if (newLanguage !== currentLanguage) {
162
180
  if (config.debug) {
163
- console.log(`🔄 [USEI18N] Language changed event: ${currentLanguage} -> ${newLanguage}`);
181
+ console.log(
182
+ `🔄 [USEI18N] Language changed event: ${currentLanguage} -> ${newLanguage}`,
183
+ );
164
184
  }
165
185
  setCurrentLanguageState(newLanguage);
166
- setTranslationVersion(prev => prev + 1); // 리렌더링 트리거
186
+ setTranslationVersion((prev) => prev + 1); // 리렌더링 트리거
167
187
  }
168
188
  });
169
189
 
170
190
  return unsubscribe;
171
191
  }, [translator, isInitialized, currentLanguage, config.debug]);
172
192
 
173
- // 자동 언어 전환 이벤트 처리
193
+ // 자동 언어 전환 이벤트 처리 (플랫폼 어댑터 위임)
174
194
  useEffect(() => {
175
- if (!config.autoLanguageSync || typeof window === 'undefined') {
195
+ if (!config.autoLanguageSync) {
176
196
  return;
177
197
  }
178
198
 
179
- const handleLanguageChange = (event: CustomEvent) => {
180
- const newLanguage = event.detail;
181
- if (typeof newLanguage === 'string' && newLanguage !== currentLanguage) {
199
+ const adapter = config.platformAdapter ?? webPlatformAdapter;
200
+ return adapter.onLanguageChange((newLanguage) => {
201
+ if (newLanguage !== currentLanguage) {
182
202
  if (config.debug) {
183
- console.log('🌐 Auto language sync:', newLanguage);
203
+ console.log("🌐 Auto language sync:", newLanguage);
184
204
  }
185
205
  setLanguage(newLanguage);
186
206
  }
187
- };
188
-
189
- // HUA i18n 언어 전환 이벤트 감지
190
- window.addEventListener('huaI18nLanguageChange', handleLanguageChange as EventListener);
191
-
192
- // 일반적인 언어 변경 이벤트도 감지
193
- window.addEventListener('i18nLanguageChanged', handleLanguageChange as EventListener);
194
-
195
- return () => {
196
- window.removeEventListener('huaI18nLanguageChange', handleLanguageChange as EventListener);
197
- window.removeEventListener('i18nLanguageChanged', handleLanguageChange as EventListener);
198
- };
199
- }, [config.autoLanguageSync, currentLanguage]);
207
+ });
208
+ }, [config.autoLanguageSync, config.platformAdapter, currentLanguage]);
200
209
 
201
210
  // 언어 변경 함수 (메모이제이션)
202
- const setLanguage = useCallback(async (language: string) => {
203
- if (!translator) {
204
- return;
205
- }
206
-
207
- // 현재 언어와 동일하면 스킵 (무한 루프 방지)
208
- const currentLang = translator.getCurrentLanguage();
209
- if (currentLang === language) {
210
- if (config.debug) {
211
- console.log(`⏭️ [USEI18N] Language unchanged, skipping: ${language}`);
211
+ const setLanguage = useCallback(
212
+ async (language: string) => {
213
+ if (!translator) {
214
+ return;
212
215
  }
213
- return;
214
- }
215
216
 
216
- if (config.debug) {
217
- if (config.debug) {
218
- console.log(`🔄 [USEI18N] setLanguage called: ${currentLang} -> ${language}`);
217
+ // 현재 언어와 동일하면 스킵 (무한 루프 방지)
218
+ const currentLang = translator.getCurrentLanguage();
219
+ if (currentLang === language) {
220
+ if (config.debug) {
221
+ console.log(`⏭️ [USEI18N] Language unchanged, skipping: ${language}`);
222
+ }
223
+ return;
219
224
  }
220
- }
221
-
222
- setIsLoading(true);
223
-
224
- try {
225
- // 언어 변경 (translate 함수에서 이전 언어의 번역을 임시로 반환하므로 깜빡임 방지)
226
- translator.setLanguage(language);
227
- setCurrentLanguageState(language);
228
-
229
- // 새로운 언어의 번역 데이터가 이미 로드되어 있는지 확인
230
- // 로드되지 않은 네임스페이스는 자동으로 로드됨 (translator 내부에서 처리)
231
- // 언어 변경 시 리렌더링 트리거 (번역 로드 완료 이벤트가 자동으로 발생)
232
- await new Promise(resolve => setTimeout(resolve, 0)); // 다음 틱에서 리렌더링
233
-
225
+
234
226
  if (config.debug) {
235
- console.log(`✅ [USEI18N] Language changed to ${language}`);
227
+ if (config.debug) {
228
+ console.log(
229
+ `🔄 [USEI18N] setLanguage called: ${currentLang} -> ${language}`,
230
+ );
231
+ }
236
232
  }
237
- } catch (error) {
238
- if (config.debug) {
239
- console.error(`❌ [USEI18N] Failed to change language to ${language}:`, error);
233
+
234
+ setIsLoading(true);
235
+
236
+ try {
237
+ // 언어 변경 (translate 함수에서 이전 언어의 번역을 임시로 반환하므로 깜빡임 방지)
238
+ translator.setLanguage(language);
239
+ setCurrentLanguageState(language);
240
+
241
+ // 새로운 언어의 번역 데이터가 이미 로드되어 있는지 확인
242
+ // 로드되지 않은 네임스페이스는 자동으로 로드됨 (translator 내부에서 처리)
243
+ // 언어 변경 시 리렌더링 트리거 (번역 로드 완료 이벤트가 자동으로 발생)
244
+ await new Promise((resolve) => setTimeout(resolve, 0)); // 다음 틱에서 리렌더링
245
+
246
+ if (config.debug) {
247
+ console.log(`✅ [USEI18N] Language changed to ${language}`);
248
+ }
249
+ } catch (error) {
250
+ if (config.debug) {
251
+ console.error(
252
+ `❌ [USEI18N] Failed to change language to ${language}:`,
253
+ error,
254
+ );
255
+ }
256
+ } finally {
257
+ setIsLoading(false);
240
258
  }
241
- } finally {
242
- setIsLoading(false);
243
- }
244
- }, [translator, config.debug]);
259
+ },
260
+ [translator, config.debug],
261
+ );
245
262
 
246
263
  // parseKey 함수를 메모이제이션하여 성능 최적화
247
264
  const parseKey = useCallback((key: string) => {
248
- const parts = key.split(':');
265
+ const parts = key.split(":");
249
266
  if (parts.length >= 2) {
250
- return { namespace: parts[0], key: parts.slice(1).join(':') };
267
+ return { namespace: parts[0], key: parts.slice(1).join(":") };
251
268
  }
252
- return { namespace: 'common', key };
269
+ return { namespace: "common", key };
253
270
  }, []);
254
271
 
255
272
  // 네스티드 키 해석 (예: "nav.docs" → obj.nav.docs)
256
- const resolveNestedKey = useCallback((obj: Record<string, unknown>, key: string): string | null => {
257
- // 1차: flat 접근 시도 (키에 점이 없거나 flat 구조인 경우)
258
- if (key in obj && typeof obj[key] === 'string') {
259
- return obj[key] as string;
260
- }
273
+ const resolveNestedKey = useCallback(
274
+ (obj: Record<string, unknown>, key: string): string | null => {
275
+ // 1차: flat 접근 시도 (키에 점이 없거나 flat 구조인 경우)
276
+ if (key in obj && typeof obj[key] === "string") {
277
+ return obj[key] as string;
278
+ }
261
279
 
262
- // 2차: 네스티드 접근 (점 경로 탐색)
263
- const parts = key.split('.');
264
- let current: unknown = obj;
265
- for (const part of parts) {
266
- if (current && typeof current === 'object' && current !== null && part in (current as Record<string, unknown>)) {
267
- current = (current as Record<string, unknown>)[part];
268
- } else {
269
- return null;
280
+ // 2차: 네스티드 접근 (점 경로 탐색)
281
+ const parts = key.split(".");
282
+ let current: unknown = obj;
283
+ for (const part of parts) {
284
+ if (
285
+ current &&
286
+ typeof current === "object" &&
287
+ current !== null &&
288
+ part in (current as Record<string, unknown>)
289
+ ) {
290
+ current = (current as Record<string, unknown>)[part];
291
+ } else {
292
+ return null;
293
+ }
270
294
  }
271
- }
272
- return typeof current === 'string' ? current : null;
273
- }, []);
295
+ return typeof current === "string" ? current : null;
296
+ },
297
+ [],
298
+ );
274
299
 
275
300
  // SSR 번역에서 찾기
276
- const findInSSRTranslations = useCallback((key: string, targetLang: string): string | null => {
277
- if (!config.initialTranslations) {
278
- return null;
279
- }
280
-
281
- const { namespace, key: actualKey } = parseKey(key);
282
-
283
- // 현재 언어의 SSR 번역 확인
284
- const ssrTranslations = config.initialTranslations[targetLang]?.[namespace];
285
- if (ssrTranslations) {
286
- const value = resolveNestedKey(ssrTranslations as Record<string, unknown>, actualKey);
287
- if (value !== null) {
288
- return value;
301
+ const findInSSRTranslations = useCallback(
302
+ (key: string, targetLang: string): string | null => {
303
+ if (!config.initialTranslations) {
304
+ return null;
289
305
  }
290
- }
291
306
 
292
- // 폴백 언어의 SSR 번역 확인
293
- const fallbackLang = config.fallbackLanguage || 'en';
294
- if (targetLang !== fallbackLang) {
295
- const fallbackTranslations = config.initialTranslations[fallbackLang]?.[namespace];
296
- if (fallbackTranslations) {
297
- const value = resolveNestedKey(fallbackTranslations as Record<string, unknown>, actualKey);
307
+ const { namespace, key: actualKey } = parseKey(key);
308
+
309
+ // 현재 언어의 SSR 번역 확인
310
+ const ssrTranslations =
311
+ config.initialTranslations[targetLang]?.[namespace];
312
+ if (ssrTranslations) {
313
+ const value = resolveNestedKey(
314
+ ssrTranslations as Record<string, unknown>,
315
+ actualKey,
316
+ );
298
317
  if (value !== null) {
299
318
  return value;
300
319
  }
301
320
  }
302
- }
303
321
 
304
- return null;
305
- }, [config.initialTranslations, config.fallbackLanguage, parseKey, resolveNestedKey]);
322
+ // 폴백 언어의 SSR 번역 확인
323
+ const fallbackLang = config.fallbackLanguage || "en";
324
+ if (targetLang !== fallbackLang) {
325
+ const fallbackTranslations =
326
+ config.initialTranslations[fallbackLang]?.[namespace];
327
+ if (fallbackTranslations) {
328
+ const value = resolveNestedKey(
329
+ fallbackTranslations as Record<string, unknown>,
330
+ actualKey,
331
+ );
332
+ if (value !== null) {
333
+ return value;
334
+ }
335
+ }
336
+ }
337
+
338
+ return null;
339
+ },
340
+ [
341
+ config.initialTranslations,
342
+ config.fallbackLanguage,
343
+ parseKey,
344
+ resolveNestedKey,
345
+ ],
346
+ );
306
347
 
307
348
  // 기본 번역에서 찾기
308
- const findInDefaultTranslations = useCallback((key: string, targetLang: string): string | null => {
309
- const { namespace, key: actualKey } = parseKey(key);
310
- const defaultTranslations = getDefaultTranslations(targetLang, namespace);
311
- const fallbackTranslations = getDefaultTranslations(config.fallbackLanguage || 'en', namespace);
312
-
313
- return resolveNestedKey(defaultTranslations as Record<string, unknown>, actualKey)
314
- || resolveNestedKey(fallbackTranslations as Record<string, unknown>, actualKey)
315
- || null;
316
- }, [config.fallbackLanguage, parseKey, resolveNestedKey]);
349
+ const findInDefaultTranslations = useCallback(
350
+ (key: string, targetLang: string): string | null => {
351
+ const { namespace, key: actualKey } = parseKey(key);
352
+ const defaultTranslations = getDefaultTranslations(targetLang, namespace);
353
+ const fallbackTranslations = getDefaultTranslations(
354
+ config.fallbackLanguage || "en",
355
+ namespace,
356
+ );
357
+
358
+ return (
359
+ resolveNestedKey(
360
+ defaultTranslations as Record<string, unknown>,
361
+ actualKey,
362
+ ) ||
363
+ resolveNestedKey(
364
+ fallbackTranslations as Record<string, unknown>,
365
+ actualKey,
366
+ ) ||
367
+ null
368
+ );
369
+ },
370
+ [config.fallbackLanguage, parseKey, resolveNestedKey],
371
+ );
317
372
 
318
373
  // hua-api 스타일의 간단한 번역 함수 (메모이제이션)
319
374
  // translationVersion과 currentLanguage에 의존하여 번역 로드 및 언어 변경 시 리렌더링 트리거
320
- const t = useCallback((key: string, paramsOrLang?: TranslationParams | string, language?: string) => {
321
- // translationVersion과 currentLanguage를 참조하여 번역 로드 및 언어 변경 시 리렌더링 트리거
322
- void translationVersion;
323
- void currentLanguage;
324
-
325
- if (!translator) {
326
- return key;
327
- }
328
-
329
- // 두 번째 인자 타입으로 분기
330
- let params: TranslationParams | undefined;
331
- let lang: string | undefined;
332
- if (typeof paramsOrLang === 'string') {
333
- lang = paramsOrLang;
334
- } else if (typeof paramsOrLang === 'object' && paramsOrLang !== null) {
335
- params = paramsOrLang;
336
- lang = language;
337
- }
338
-
339
- const targetLang = lang || currentLanguage;
340
-
341
- // 1단계: translator.translate() 시도 (params가 있으면 translate에 위임)
342
- try {
343
- const result = translator.translate(key, params || lang, params ? lang : undefined);
344
- if (result && result !== key && result !== '') {
345
- return result;
375
+ const t = useCallback(
376
+ (
377
+ key: string,
378
+ paramsOrLang?: TranslationParams | string,
379
+ language?: string,
380
+ ) => {
381
+ // translationVersion과 currentLanguage를 참조하여 번역 로드 및 언어 변경 시 리렌더링 트리거
382
+ void translationVersion;
383
+ void currentLanguage;
384
+
385
+ if (!translator) {
386
+ return key;
346
387
  }
347
- } catch (error) {
348
- // translator.translate() 실패 시 다음 단계로 진행
349
- }
350
-
351
- // interpolate 헬퍼
352
- const interpolate = (text: string) => {
353
- if (!params) return text;
354
- return text.replace(/\{\{(\w+)\}\}/g, (match, k) => {
355
- const value = params![k];
356
- return value !== undefined ? String(value) : match;
357
- });
358
- };
359
388
 
360
- // 2단계: SSR 번역 데이터에서 찾기
361
- const ssrResult = findInSSRTranslations(key, targetLang);
362
- if (ssrResult) {
363
- return interpolate(ssrResult);
364
- }
389
+ // 번째 인자 타입으로 분기
390
+ let params: TranslationParams | undefined;
391
+ let lang: string | undefined;
392
+ if (typeof paramsOrLang === "string") {
393
+ lang = paramsOrLang;
394
+ } else if (typeof paramsOrLang === "object" && paramsOrLang !== null) {
395
+ params = paramsOrLang;
396
+ lang = language;
397
+ }
365
398
 
366
- // 3단계: 기본 번역 데이터에서 찾기
367
- const defaultResult = findInDefaultTranslations(key, targetLang);
368
- if (defaultResult) {
369
- return interpolate(defaultResult);
370
- }
399
+ const targetLang = lang || currentLanguage;
371
400
 
372
- // 모든 단계에서 번역을 찾지 못한 경우
373
- if (config.debug) {
374
- return interpolate(key); // 개발 환경에서는 키를 표시하여 디버깅 가능
375
- }
376
- return ''; // 프로덕션에서는 빈 문자열 반환하여 미싱 키 노출 방지
377
- }, [translator, config.debug, currentLanguage, config.fallbackLanguage, translationVersion, findInSSRTranslations, findInDefaultTranslations]) as (key: string, paramsOrLang?: TranslationParams | string, language?: string) => string;
378
-
379
- // 기존 비동기 번역 함수 (하위 호환성)
380
- const tAsync = useCallback(async (key: string, params?: TranslationParams) => {
381
- if (!translator) {
382
- if (config.debug) {
383
- console.warn('Translator not initialized');
401
+ // 1단계: translator.translate() 시도 (params가 있으면 translate에 위임)
402
+ try {
403
+ const result = translator.translate(
404
+ key,
405
+ params || lang,
406
+ params ? lang : undefined,
407
+ );
408
+ if (result && result !== key && result !== "") {
409
+ return result;
410
+ }
411
+ } catch (error) {
412
+ // translator.translate() 실패 시 다음 단계로 진행
384
413
  }
385
- return key;
386
- }
387
414
 
388
- setIsLoading(true);
389
- try {
390
- const result = await translator.translateAsync(key, params);
391
- return result;
392
- } catch (error) {
393
- if (config.debug) {
394
- console.error('Translation error:', error);
415
+ // interpolate 헬퍼
416
+ const interpolate = (text: string) => {
417
+ if (!params) return text;
418
+ return text.replace(/\{\{(\w+)\}\}/g, (match, k) => {
419
+ const value = params![k];
420
+ return value !== undefined ? String(value) : match;
421
+ });
422
+ };
423
+
424
+ // 2단계: SSR 번역 데이터에서 찾기
425
+ const ssrResult = findInSSRTranslations(key, targetLang);
426
+ if (ssrResult) {
427
+ return interpolate(ssrResult);
395
428
  }
396
- return key;
397
- } finally {
398
- setIsLoading(false);
399
- }
400
- }, [translator, config.debug]);
401
429
 
402
- // 기존 동기 번역 함수 (하위 호환성)
403
- const tSync = useCallback((key: string, namespace?: string, params?: TranslationParams) => {
404
- if (!translator) {
405
- if (config.debug) {
406
- console.warn('Translator not initialized');
430
+ // 3단계: 기본 번역 데이터에서 찾기
431
+ const defaultResult = findInDefaultTranslations(key, targetLang);
432
+ if (defaultResult) {
433
+ return interpolate(defaultResult);
407
434
  }
408
- return key;
409
- }
410
-
411
- return translator.translateSync(key, params);
412
- }, [translator, config.debug]);
413
-
414
- // 원시 값 가져오기 (배열, 객체 포함)
415
- const getRawValue = useCallback((key: string, language?: string): unknown => {
416
- if (!translator || !isInitialized) {
417
- return undefined;
418
- }
419
- return translator.getRawValue(key, language);
420
- }, [translator, isInitialized]);
421
-
422
- // 배열 번역 값 가져오기 (타입 안전)
423
- const tArray = useCallback((key: string, language?: string): string[] => {
424
- void translationVersion;
425
- void currentLanguage;
426
- if (!translator || !isInitialized) {
427
- return [];
428
- }
429
- return translator.tArray(key, language);
430
- }, [translator, isInitialized, translationVersion, currentLanguage]);
431
-
432
- // 복수형 번역 (ICU / Intl.PluralRules 기반)
433
- const tPlural = useCallback((key: string, count: number, params?: Record<string, unknown>, language?: string): string => {
434
- void translationVersion;
435
- void currentLanguage;
436
- if (!translator || !isInitialized) {
437
- return key;
438
- }
439
- return translator.tPlural(key, count, params, language);
440
- }, [translator, isInitialized, translationVersion, currentLanguage]);
441
435
 
442
- // 개발자 도구 (메모이제이션)
443
- const debug = useMemo(() => ({
444
- getCurrentLanguage: () => {
445
- try {
446
- return translator?.getCurrentLanguage() || currentLanguage;
447
- } catch {
448
- return currentLanguage;
436
+ // 모든 단계에서 번역을 찾지 못한 경우
437
+ // defaultValue가 제공된 경우 반환 (프로덕션/디버그 모두 적용)
438
+ if (
439
+ typeof paramsOrLang === "object" &&
440
+ paramsOrLang !== null &&
441
+ "defaultValue" in paramsOrLang &&
442
+ typeof paramsOrLang.defaultValue === "string"
443
+ ) {
444
+ return interpolate(paramsOrLang.defaultValue);
449
445
  }
450
- },
451
- getSupportedLanguages: () => {
452
- try {
453
- return translator?.getSupportedLanguages() || config.supportedLanguages?.map(l => l.code) || [];
454
- } catch {
455
- return config.supportedLanguages?.map(l => l.code) || [];
446
+
447
+ if (config.debug) {
448
+ return interpolate(key); // 개발 환경에서는 키를 표시하여 디버깅 가능
456
449
  }
450
+ return ""; // 프로덕션에서는 빈 문자열 반환하여 미싱 키 노출 방지
457
451
  },
458
- getLoadedNamespaces: () => {
459
- try {
460
- const debugInfo = translator?.debug();
461
- if (debugInfo && debugInfo.loadedNamespaces) {
462
- return Array.from(debugInfo.loadedNamespaces);
463
- }
464
- // 번역 데이터가 있으면 네임스페이스 추정
465
- if (debugInfo && debugInfo.allTranslations) {
466
- const namespaces = new Set<string>();
467
- Object.values(debugInfo.allTranslations).forEach((langData: unknown) => {
468
- if (langData && typeof langData === 'object') {
469
- Object.keys(langData).forEach(namespace => {
470
- namespaces.add(namespace);
471
- });
472
- }
473
- });
474
- return Array.from(namespaces);
452
+ [
453
+ translator,
454
+ config.debug,
455
+ currentLanguage,
456
+ config.fallbackLanguage,
457
+ translationVersion,
458
+ findInSSRTranslations,
459
+ findInDefaultTranslations,
460
+ ],
461
+ ) as (
462
+ key: string,
463
+ paramsOrLang?: TranslationParams | string,
464
+ language?: string,
465
+ ) => string;
466
+
467
+ // 기존 비동기 번역 함수 (하위 호환성)
468
+ const tAsync = useCallback(
469
+ async (key: string, params?: TranslationParams) => {
470
+ if (!translator) {
471
+ if (config.debug) {
472
+ console.warn("Translator not initialized");
475
473
  }
476
- return [];
477
- } catch (error) {
478
- return [];
474
+ return key;
479
475
  }
480
- },
481
- getAllTranslations: () => {
476
+
477
+ setIsLoading(true);
482
478
  try {
483
- return translator?.debug()?.allTranslations || {};
479
+ const result = await translator.translateAsync(key, params);
480
+ return result;
484
481
  } catch (error) {
485
- return {};
482
+ if (config.debug) {
483
+ console.error("Translation error:", error);
484
+ }
485
+ return key;
486
+ } finally {
487
+ setIsLoading(false);
486
488
  }
487
489
  },
488
- isReady: () => {
489
- try {
490
- return translator?.isReady() || isInitialized;
491
- } catch {
492
- return isInitialized;
490
+ [translator, config.debug],
491
+ );
492
+
493
+ // 기존 동기 번역 함수 (하위 호환성)
494
+ const tSync = useCallback(
495
+ (key: string, namespace?: string, params?: TranslationParams) => {
496
+ if (!translator) {
497
+ if (config.debug) {
498
+ console.warn("Translator not initialized");
499
+ }
500
+ return key;
493
501
  }
502
+
503
+ return translator.translateSync(key, params);
494
504
  },
495
- getInitializationError: () => {
496
- try {
497
- return translator?.getInitializationError() || error;
498
- } catch {
499
- return error;
505
+ [translator, config.debug],
506
+ );
507
+
508
+ // 원시 값 가져오기 (배열, 객체 포함) — 제네릭으로 타입 캐스팅 가능
509
+ const getRawValue = useCallback(
510
+ <T = unknown,>(key: string, language?: string): T | undefined => {
511
+ if (!translator || !isInitialized) {
512
+ return undefined;
500
513
  }
514
+ return translator.getRawValue<T>(key, language);
501
515
  },
502
- clearCache: () => {
503
- try {
504
- translator?.clearCache();
505
- } catch {
506
- // 무시
516
+ [translator, isInitialized],
517
+ );
518
+
519
+ // 배열 번역 값 가져오기 (타입 안전)
520
+ const tArray = useCallback(
521
+ (key: string, language?: string): string[] => {
522
+ void translationVersion;
523
+ void currentLanguage;
524
+ if (!translator || !isInitialized) {
525
+ return [];
507
526
  }
527
+ return translator.tArray(key, language);
508
528
  },
509
- getCacheStats: () => {
510
- try {
511
- const debugInfo = translator?.debug();
512
- if (debugInfo && debugInfo.cacheStats) {
513
- return {
514
- size: debugInfo.cacheSize || 0,
515
- hits: debugInfo.cacheStats.hits || 0,
516
- misses: debugInfo.cacheStats.misses || 0
517
- };
518
- }
519
- return { size: 0, hits: 0, misses: 0 };
520
- } catch (error) {
521
- return { size: 0, hits: 0, misses: 0 };
529
+ [translator, isInitialized, translationVersion, currentLanguage],
530
+ );
531
+
532
+ // 복수형 번역 (ICU / Intl.PluralRules 기반)
533
+ const tPlural = useCallback(
534
+ (
535
+ key: string,
536
+ count: number,
537
+ params?: Record<string, unknown>,
538
+ language?: string,
539
+ ): string => {
540
+ void translationVersion;
541
+ void currentLanguage;
542
+ if (!translator || !isInitialized) {
543
+ return key;
522
544
  }
545
+ return translator.tPlural(key, count, params, language);
523
546
  },
524
- reloadTranslations: async () => {
525
- if (translator) {
526
- setIsLoading(true);
527
- setError(null);
547
+ [translator, isInitialized, translationVersion, currentLanguage],
548
+ );
549
+
550
+ // 개발자 도구 (메모이제이션)
551
+ const debug = useMemo(
552
+ () => ({
553
+ getCurrentLanguage: () => {
528
554
  try {
529
- await translator.initialize();
530
- } catch (err) {
531
- setError(err as TranslationError);
532
- } finally {
533
- setIsLoading(false);
555
+ return translator?.getCurrentLanguage() || currentLanguage;
556
+ } catch {
557
+ return currentLanguage;
534
558
  }
535
- }
536
- },
537
- }), [translator, currentLanguage, error, isInitialized, config.supportedLanguages]);
538
-
539
- const value: I18nContextType = useMemo(() => ({
540
- currentLanguage,
541
- setLanguage,
542
- t,
543
- tPlural,
544
- tArray,
545
- tAsync,
546
- tSync,
547
- getRawValue,
548
- isLoading,
549
- error,
550
- supportedLanguages: config.supportedLanguages,
551
- debug,
552
- isInitialized,
553
- }), [currentLanguage, setLanguage, t, tPlural, tArray, tAsync, tSync, getRawValue, isLoading, error, config.supportedLanguages, debug, isInitialized]);
554
-
555
- return (
556
- <I18nContext.Provider value={value}>
557
- {children}
558
- </I18nContext.Provider>
559
+ },
560
+ getSupportedLanguages: () => {
561
+ try {
562
+ return (
563
+ translator?.getSupportedLanguages() ||
564
+ config.supportedLanguages?.map((l) => l.code) ||
565
+ []
566
+ );
567
+ } catch {
568
+ return config.supportedLanguages?.map((l) => l.code) || [];
569
+ }
570
+ },
571
+ getLoadedNamespaces: () => {
572
+ try {
573
+ const debugInfo = translator?.debug();
574
+ if (debugInfo && debugInfo.loadedNamespaces) {
575
+ return Array.from(debugInfo.loadedNamespaces);
576
+ }
577
+ // 번역 데이터가 있으면 네임스페이스 추정
578
+ if (debugInfo && debugInfo.allTranslations) {
579
+ const namespaces = new Set<string>();
580
+ Object.values(debugInfo.allTranslations).forEach(
581
+ (langData: unknown) => {
582
+ if (langData && typeof langData === "object") {
583
+ Object.keys(langData).forEach((namespace) => {
584
+ namespaces.add(namespace);
585
+ });
586
+ }
587
+ },
588
+ );
589
+ return Array.from(namespaces);
590
+ }
591
+ return [];
592
+ } catch (error) {
593
+ return [];
594
+ }
595
+ },
596
+ getAllTranslations: () => {
597
+ try {
598
+ return translator?.debug()?.allTranslations || {};
599
+ } catch (error) {
600
+ return {};
601
+ }
602
+ },
603
+ isReady: () => {
604
+ try {
605
+ return translator?.isReady() || isInitialized;
606
+ } catch {
607
+ return isInitialized;
608
+ }
609
+ },
610
+ getInitializationError: () => {
611
+ try {
612
+ return translator?.getInitializationError() || error;
613
+ } catch {
614
+ return error;
615
+ }
616
+ },
617
+ clearCache: () => {
618
+ try {
619
+ translator?.clearCache();
620
+ } catch {
621
+ // 무시
622
+ }
623
+ },
624
+ getCacheStats: () => {
625
+ try {
626
+ const debugInfo = translator?.debug();
627
+ if (debugInfo && debugInfo.cacheStats) {
628
+ return {
629
+ size: debugInfo.cacheSize || 0,
630
+ hits: debugInfo.cacheStats.hits || 0,
631
+ misses: debugInfo.cacheStats.misses || 0,
632
+ };
633
+ }
634
+ return { size: 0, hits: 0, misses: 0 };
635
+ } catch (error) {
636
+ return { size: 0, hits: 0, misses: 0 };
637
+ }
638
+ },
639
+ reloadTranslations: async () => {
640
+ if (translator) {
641
+ setIsLoading(true);
642
+ setError(null);
643
+ try {
644
+ await translator.initialize();
645
+ } catch (err) {
646
+ setError(err as TranslationError);
647
+ } finally {
648
+ setIsLoading(false);
649
+ }
650
+ }
651
+ },
652
+ }),
653
+ [
654
+ translator,
655
+ currentLanguage,
656
+ error,
657
+ isInitialized,
658
+ config.supportedLanguages,
659
+ ],
660
+ );
661
+
662
+ const value: I18nContextType = useMemo(
663
+ () => ({
664
+ currentLanguage,
665
+ setLanguage,
666
+ t,
667
+ tPlural,
668
+ tArray,
669
+ tAsync,
670
+ tSync,
671
+ getRawValue,
672
+ isLoading,
673
+ error,
674
+ supportedLanguages: config.supportedLanguages,
675
+ debug,
676
+ isInitialized,
677
+ }),
678
+ [
679
+ currentLanguage,
680
+ setLanguage,
681
+ t,
682
+ tPlural,
683
+ tArray,
684
+ tAsync,
685
+ tSync,
686
+ getRawValue,
687
+ isLoading,
688
+ error,
689
+ config.supportedLanguages,
690
+ debug,
691
+ isInitialized,
692
+ ],
559
693
  );
694
+
695
+ return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
560
696
  }
561
697
 
562
698
  /**
@@ -567,24 +703,34 @@ export function useI18n(): I18nContextType {
567
703
  if (!context) {
568
704
  // Provider 밖에서 호출되면 기본값 반환
569
705
  return {
570
- currentLanguage: 'ko',
706
+ currentLanguage: "ko",
571
707
  setLanguage: () => {},
572
- t: (key: string) => key,
708
+ t: (key: string, paramsOrLang?: TranslationParams | string) => {
709
+ if (
710
+ typeof paramsOrLang === "object" &&
711
+ paramsOrLang !== null &&
712
+ "defaultValue" in paramsOrLang &&
713
+ typeof paramsOrLang.defaultValue === "string"
714
+ ) {
715
+ return paramsOrLang.defaultValue;
716
+ }
717
+ return key;
718
+ },
573
719
  tPlural: (key: string) => key,
574
720
  tAsync: async (key: string) => key,
575
721
  tSync: (key: string) => key,
576
- getRawValue: () => undefined,
722
+ getRawValue: <T = unknown,>() => undefined as T | undefined,
577
723
  tArray: () => [],
578
724
  isLoading: false,
579
725
  error: null,
580
726
  supportedLanguages: [
581
- { code: 'ko', name: 'Korean', nativeName: '한국어' },
582
- { code: 'en', name: 'English', nativeName: 'English' },
727
+ { code: "ko", name: "Korean", nativeName: "한국어" },
728
+ { code: "en", name: "English", nativeName: "English" },
583
729
  ],
584
730
  isInitialized: false,
585
731
  debug: {
586
- getCurrentLanguage: () => 'ko',
587
- getSupportedLanguages: () => ['ko', 'en'],
732
+ getCurrentLanguage: () => "ko",
733
+ getSupportedLanguages: () => ["ko", "en"],
588
734
  getLoadedNamespaces: () => [],
589
735
  getAllTranslations: () => ({}),
590
736
  isReady: () => false,
@@ -597,5 +743,3 @@ export function useI18n(): I18nContextType {
597
743
  }
598
744
  return context;
599
745
  }
600
-
601
-