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