@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.
- package/LICENSE +21 -0
- package/README.md +9 -3
- package/dist/chunk-4IYWT7MS.mjs +1104 -0
- package/dist/chunk-4IYWT7MS.mjs.map +1 -0
- package/dist/index.cjs +2086 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +22 -22
- package/dist/index.d.ts +249 -0
- package/dist/index.mjs +373 -264
- package/dist/index.mjs.map +1 -1
- package/dist/{server-4TeBq6hp.d.mts → server-CQztOmd-.d.mts} +48 -11
- package/dist/server-CQztOmd-.d.ts +404 -0
- package/dist/{chunk-EZL5TNH5.mjs → server.cjs} +148 -46
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.mts +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.mjs +1 -1
- package/package.json +16 -13
- package/src/__tests__/default-value.test.ts +149 -0
- package/src/components/MissingKeyOverlay.tsx +6 -4
- package/src/core/translator.tsx +392 -195
- package/src/hooks/useI18n.tsx +511 -367
- package/src/index.ts +5 -2
- package/src/types/index.ts +341 -156
- package/dist/chunk-EZL5TNH5.mjs.map +0 -1
package/src/core/translator.tsx
CHANGED
|
@@ -12,18 +12,27 @@ import {
|
|
|
12
12
|
defaultErrorLoggingConfig,
|
|
13
13
|
isRecoverableError,
|
|
14
14
|
isPluralValue,
|
|
15
|
-
PluralCategory
|
|
16
|
-
} from
|
|
15
|
+
PluralCategory,
|
|
16
|
+
} from "../types";
|
|
17
17
|
|
|
18
18
|
export interface TranslatorInterface {
|
|
19
|
-
translate(
|
|
20
|
-
|
|
19
|
+
translate(
|
|
20
|
+
key: string,
|
|
21
|
+
paramsOrLang?: Record<string, unknown> | string,
|
|
22
|
+
language?: string,
|
|
23
|
+
): string;
|
|
24
|
+
tPlural(
|
|
25
|
+
key: string,
|
|
26
|
+
count: number,
|
|
27
|
+
params?: Record<string, unknown>,
|
|
28
|
+
language?: string,
|
|
29
|
+
): string;
|
|
21
30
|
setLanguage(lang: string): void;
|
|
22
31
|
getCurrentLanguage(): string;
|
|
23
32
|
initialize(): Promise<void>;
|
|
24
33
|
isReady(): boolean;
|
|
25
34
|
debug(): unknown;
|
|
26
|
-
getRawValue(key: string, language?: string):
|
|
35
|
+
getRawValue<T = unknown>(key: string, language?: string): T | undefined;
|
|
27
36
|
tArray(key: string, language?: string): string[];
|
|
28
37
|
}
|
|
29
38
|
|
|
@@ -32,11 +41,14 @@ export class Translator implements TranslatorInterface {
|
|
|
32
41
|
private pluralRulesCache = new Map<string, Intl.PluralRules>();
|
|
33
42
|
private loadedNamespaces = new Set<string>();
|
|
34
43
|
private loadingPromises = new Map<string, Promise<TranslationNamespace>>();
|
|
35
|
-
private allTranslations: Record<
|
|
44
|
+
private allTranslations: Record<
|
|
45
|
+
string,
|
|
46
|
+
Record<string, TranslationNamespace>
|
|
47
|
+
> = {};
|
|
36
48
|
private isInitialized = false;
|
|
37
49
|
private initializationError: TranslationError | null = null;
|
|
38
50
|
private config: I18nConfig;
|
|
39
|
-
private currentLang: string =
|
|
51
|
+
private currentLang: string = "en";
|
|
40
52
|
private cacheStats = {
|
|
41
53
|
hits: 0,
|
|
42
54
|
misses: 0,
|
|
@@ -44,12 +56,13 @@ export class Translator implements TranslatorInterface {
|
|
|
44
56
|
// 번역 로드 완료 시 React 리렌더링을 위한 콜백
|
|
45
57
|
private onTranslationLoadedCallbacks: Set<() => void> = new Set();
|
|
46
58
|
// 언어 변경 시 React 리렌더링을 위한 콜백
|
|
47
|
-
private onLanguageChangedCallbacks: Set<(language: string) => void> =
|
|
59
|
+
private onLanguageChangedCallbacks: Set<(language: string) => void> =
|
|
60
|
+
new Set();
|
|
48
61
|
// 디바운싱을 위한 타이머
|
|
49
|
-
private notifyTimer:
|
|
62
|
+
private notifyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
63
|
// 최근 알림한 네임스페이스 (중복 알림 방지)
|
|
51
64
|
private recentlyNotified = new Set<string>();
|
|
52
|
-
|
|
65
|
+
|
|
53
66
|
/**
|
|
54
67
|
* 번역 로드 완료 콜백 등록
|
|
55
68
|
*/
|
|
@@ -74,77 +87,79 @@ export class Translator implements TranslatorInterface {
|
|
|
74
87
|
* 언어 변경 이벤트 발생
|
|
75
88
|
*/
|
|
76
89
|
private notifyLanguageChanged(language: string): void {
|
|
77
|
-
this.onLanguageChangedCallbacks.forEach(callback => {
|
|
90
|
+
this.onLanguageChangedCallbacks.forEach((callback) => {
|
|
78
91
|
try {
|
|
79
92
|
callback(language);
|
|
80
93
|
} catch (error) {
|
|
81
94
|
if (this.config.debug) {
|
|
82
|
-
console.error(
|
|
95
|
+
console.error("Error in language changed callback:", error);
|
|
83
96
|
}
|
|
84
97
|
}
|
|
85
98
|
});
|
|
86
99
|
}
|
|
87
|
-
|
|
100
|
+
|
|
88
101
|
/**
|
|
89
102
|
* 번역 로드 완료 이벤트 발생 (디바운싱 적용)
|
|
90
103
|
*/
|
|
91
104
|
private notifyTranslationLoaded(language: string, namespace: string): void {
|
|
92
105
|
const cacheKey = `${language}:${namespace}`;
|
|
93
|
-
|
|
106
|
+
|
|
94
107
|
// 최근에 알림한 네임스페이스는 스킵 (중복 알림 방지)
|
|
95
108
|
if (this.recentlyNotified.has(cacheKey)) {
|
|
96
109
|
return;
|
|
97
110
|
}
|
|
98
|
-
|
|
111
|
+
|
|
99
112
|
this.recentlyNotified.add(cacheKey);
|
|
100
|
-
|
|
113
|
+
|
|
101
114
|
// 디바운싱: 짧은 시간 내 여러 번역이 로드되면 한 번만 알림
|
|
102
115
|
if (this.notifyTimer) {
|
|
103
116
|
clearTimeout(this.notifyTimer);
|
|
104
117
|
}
|
|
105
|
-
|
|
118
|
+
|
|
106
119
|
this.notifyTimer = setTimeout(() => {
|
|
107
|
-
this.onTranslationLoadedCallbacks.forEach(callback => {
|
|
120
|
+
this.onTranslationLoadedCallbacks.forEach((callback) => {
|
|
108
121
|
try {
|
|
109
122
|
callback();
|
|
110
123
|
} catch (error) {
|
|
111
124
|
if (this.config.debug) {
|
|
112
|
-
console.warn(
|
|
125
|
+
console.warn("Error in translation loaded callback:", error);
|
|
113
126
|
}
|
|
114
127
|
}
|
|
115
128
|
});
|
|
116
|
-
|
|
129
|
+
|
|
117
130
|
// 100ms 후 recentlyNotified 초기화 (같은 네임스페이스도 다시 알림 가능하도록)
|
|
118
131
|
setTimeout(() => {
|
|
119
132
|
this.recentlyNotified.clear();
|
|
120
133
|
}, 100);
|
|
121
|
-
|
|
134
|
+
|
|
122
135
|
this.notifyTimer = null;
|
|
123
136
|
}, 50); // 50ms 디바운싱
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
constructor(config: I18nConfig) {
|
|
127
140
|
if (!validateI18nConfig(config)) {
|
|
128
|
-
throw new Error(
|
|
141
|
+
throw new Error("Invalid I18nConfig provided");
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
this.config = {
|
|
132
|
-
fallbackLanguage:
|
|
133
|
-
namespaces: [
|
|
145
|
+
fallbackLanguage: "en",
|
|
146
|
+
namespaces: ["common"],
|
|
134
147
|
debug: false,
|
|
135
148
|
missingKeyHandler: (key: string) => key,
|
|
136
149
|
errorHandler: (error: Error) => {
|
|
137
150
|
// Silent by default, user can override
|
|
138
151
|
},
|
|
139
|
-
...config
|
|
152
|
+
...config,
|
|
140
153
|
};
|
|
141
154
|
this.currentLang = config.defaultLanguage;
|
|
142
|
-
|
|
155
|
+
|
|
143
156
|
// SSR에서 전달된 초기 번역 데이터가 있으면 즉시 설정 (네트워크 요청 없음)
|
|
144
157
|
if (config.initialTranslations) {
|
|
145
158
|
this.allTranslations = config.initialTranslations;
|
|
146
159
|
// 로드된 네임스페이스 마킹
|
|
147
|
-
for (const [language, namespaces] of Object.entries(
|
|
160
|
+
for (const [language, namespaces] of Object.entries(
|
|
161
|
+
config.initialTranslations,
|
|
162
|
+
)) {
|
|
148
163
|
for (const namespace of Object.keys(namespaces)) {
|
|
149
164
|
this.loadedNamespaces.add(`${language}:${namespace}`);
|
|
150
165
|
}
|
|
@@ -161,13 +176,13 @@ export class Translator implements TranslatorInterface {
|
|
|
161
176
|
async initialize(): Promise<void> {
|
|
162
177
|
if (this.isInitialized) {
|
|
163
178
|
if (this.config.debug) {
|
|
164
|
-
console.log(
|
|
179
|
+
console.log("🚫 [TRANSLATOR] Already initialized, skipping");
|
|
165
180
|
}
|
|
166
181
|
return;
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
if (this.config.debug) {
|
|
170
|
-
console.log(
|
|
185
|
+
console.log("🚀 [TRANSLATOR] Starting initialization...");
|
|
171
186
|
}
|
|
172
187
|
|
|
173
188
|
try {
|
|
@@ -177,10 +192,13 @@ export class Translator implements TranslatorInterface {
|
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
const languages = [this.currentLang];
|
|
180
|
-
if (
|
|
195
|
+
if (
|
|
196
|
+
this.config.fallbackLanguage &&
|
|
197
|
+
this.config.fallbackLanguage !== this.currentLang
|
|
198
|
+
) {
|
|
181
199
|
languages.push(this.config.fallbackLanguage);
|
|
182
200
|
}
|
|
183
|
-
|
|
201
|
+
|
|
184
202
|
// 초기 번역 데이터가 이미 있으면 해당 네임스페이스는 스킵
|
|
185
203
|
const skipNamespaces = new Set<string>();
|
|
186
204
|
for (const language of languages) {
|
|
@@ -192,14 +210,20 @@ export class Translator implements TranslatorInterface {
|
|
|
192
210
|
}
|
|
193
211
|
|
|
194
212
|
if (this.config.debug) {
|
|
195
|
-
console.log(
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
console.log(
|
|
214
|
+
"🌍 [TRANSLATOR] Initializing translator with languages:",
|
|
215
|
+
languages,
|
|
216
|
+
);
|
|
217
|
+
console.log("📍 [TRANSLATOR] Current language:", this.currentLang);
|
|
218
|
+
console.log(
|
|
219
|
+
"📦 [TRANSLATOR] Config namespaces:",
|
|
220
|
+
this.config.namespaces,
|
|
221
|
+
);
|
|
198
222
|
}
|
|
199
223
|
|
|
200
224
|
for (const language of languages) {
|
|
201
225
|
if (this.config.debug) {
|
|
202
|
-
console.log(
|
|
226
|
+
console.log("Processing language:", language);
|
|
203
227
|
}
|
|
204
228
|
|
|
205
229
|
if (!this.allTranslations[language]) {
|
|
@@ -208,35 +232,45 @@ export class Translator implements TranslatorInterface {
|
|
|
208
232
|
|
|
209
233
|
for (const namespace of this.config.namespaces || []) {
|
|
210
234
|
const cacheKey = `${language}:${namespace}`;
|
|
211
|
-
|
|
235
|
+
|
|
212
236
|
// 이미 초기 번역 데이터가 있으면 스킵 (네트워크 요청 없음)
|
|
213
237
|
if (skipNamespaces.has(cacheKey)) {
|
|
214
238
|
if (this.config.debug) {
|
|
215
|
-
console.log(
|
|
239
|
+
console.log(
|
|
240
|
+
"⏭️ [TRANSLATOR] Skipping",
|
|
241
|
+
namespace,
|
|
242
|
+
"for",
|
|
243
|
+
language,
|
|
244
|
+
"(already loaded from SSR)",
|
|
245
|
+
);
|
|
216
246
|
}
|
|
217
247
|
continue;
|
|
218
248
|
}
|
|
219
|
-
|
|
249
|
+
|
|
220
250
|
if (this.config.debug) {
|
|
221
|
-
console.log(
|
|
251
|
+
console.log(
|
|
252
|
+
"Loading namespace:",
|
|
253
|
+
namespace,
|
|
254
|
+
"for language:",
|
|
255
|
+
language,
|
|
256
|
+
);
|
|
222
257
|
}
|
|
223
258
|
|
|
224
259
|
try {
|
|
225
260
|
const data = await this.safeLoadTranslations(language, namespace);
|
|
226
261
|
|
|
227
262
|
if (this.config.debug) {
|
|
228
|
-
console.log(
|
|
263
|
+
console.log("Loaded data for", language, namespace, ":", data);
|
|
229
264
|
}
|
|
230
265
|
|
|
231
266
|
this.allTranslations[language][namespace] = data;
|
|
232
267
|
this.loadedNamespaces.add(`${language}:${namespace}`);
|
|
233
|
-
|
|
234
268
|
} catch (error) {
|
|
235
269
|
const translationError = this.createTranslationError(
|
|
236
|
-
|
|
270
|
+
"LOAD_FAILED",
|
|
237
271
|
error as Error,
|
|
238
272
|
language,
|
|
239
|
-
namespace
|
|
273
|
+
namespace,
|
|
240
274
|
);
|
|
241
275
|
|
|
242
276
|
this.logError(translationError);
|
|
@@ -246,19 +280,22 @@ export class Translator implements TranslatorInterface {
|
|
|
246
280
|
// 폴백 언어로 시도
|
|
247
281
|
if (language !== this.config.fallbackLanguage) {
|
|
248
282
|
try {
|
|
249
|
-
const fallbackData = await this.safeLoadTranslations(
|
|
283
|
+
const fallbackData = await this.safeLoadTranslations(
|
|
284
|
+
this.config.fallbackLanguage || "en",
|
|
285
|
+
namespace,
|
|
286
|
+
);
|
|
250
287
|
this.allTranslations[language][namespace] = fallbackData;
|
|
251
288
|
this.loadedNamespaces.add(`${language}:${namespace}`);
|
|
252
289
|
|
|
253
290
|
if (this.config.debug) {
|
|
254
|
-
console.log(
|
|
291
|
+
console.log("Using fallback data for", language, namespace);
|
|
255
292
|
}
|
|
256
293
|
} catch (fallbackError) {
|
|
257
294
|
const fallbackTranslationError = this.createTranslationError(
|
|
258
|
-
|
|
295
|
+
"FALLBACK_LOAD_FAILED",
|
|
259
296
|
fallbackError as Error,
|
|
260
297
|
this.config.fallbackLanguage,
|
|
261
|
-
namespace
|
|
298
|
+
namespace,
|
|
262
299
|
);
|
|
263
300
|
|
|
264
301
|
this.logError(fallbackTranslationError);
|
|
@@ -281,14 +318,13 @@ export class Translator implements TranslatorInterface {
|
|
|
281
318
|
this.isInitialized = true;
|
|
282
319
|
|
|
283
320
|
if (this.config.debug) {
|
|
284
|
-
console.log(
|
|
285
|
-
console.log(
|
|
321
|
+
console.log("Translator initialized successfully");
|
|
322
|
+
console.log("Loaded translations:", this.allTranslations);
|
|
286
323
|
}
|
|
287
|
-
|
|
288
324
|
} catch (error) {
|
|
289
325
|
this.initializationError = this.createTranslationError(
|
|
290
|
-
|
|
291
|
-
error as Error
|
|
326
|
+
"INITIALIZATION_FAILED",
|
|
327
|
+
error as Error,
|
|
292
328
|
);
|
|
293
329
|
|
|
294
330
|
this.logError(this.initializationError);
|
|
@@ -297,7 +333,9 @@ export class Translator implements TranslatorInterface {
|
|
|
297
333
|
this.isInitialized = true;
|
|
298
334
|
|
|
299
335
|
if (this.config.debug) {
|
|
300
|
-
console.warn(
|
|
336
|
+
console.warn(
|
|
337
|
+
"Translator initialized with errors, using fallback translations",
|
|
338
|
+
);
|
|
301
339
|
}
|
|
302
340
|
}
|
|
303
341
|
}
|
|
@@ -307,38 +345,48 @@ export class Translator implements TranslatorInterface {
|
|
|
307
345
|
*/
|
|
308
346
|
private translateBeforeInitialized(key: string, targetLang: string): string {
|
|
309
347
|
if (this.config.debug) {
|
|
310
|
-
console.warn(
|
|
348
|
+
console.warn("Translator not initialized. Call initialize() first.");
|
|
311
349
|
}
|
|
312
|
-
|
|
350
|
+
|
|
313
351
|
// 초기화되지 않았을 때도 기본 번역 시도 (initialTranslations 사용)
|
|
314
352
|
const { namespace, key: actualKey } = this.parseKey(key);
|
|
315
|
-
|
|
353
|
+
|
|
316
354
|
// findInNamespace를 사용하여 중첩 키도 처리
|
|
317
355
|
const result = this.findInNamespace(namespace, actualKey, targetLang);
|
|
318
356
|
if (result) {
|
|
319
357
|
if (this.config.debug) {
|
|
320
|
-
console.log(
|
|
358
|
+
console.log(
|
|
359
|
+
`✅ [TRANSLATOR] Found fallback translation from initialTranslations:`,
|
|
360
|
+
result,
|
|
361
|
+
);
|
|
321
362
|
}
|
|
322
363
|
return result;
|
|
323
364
|
}
|
|
324
|
-
|
|
365
|
+
|
|
325
366
|
if (this.config.debug) {
|
|
326
367
|
const translations = this.allTranslations[targetLang]?.[namespace];
|
|
327
368
|
console.log(`🔍 [TRANSLATOR] Not initialized, fallback failed:`, {
|
|
328
369
|
namespace,
|
|
329
370
|
actualKey,
|
|
330
371
|
hasTranslations: !!translations,
|
|
331
|
-
translationsKeys: translations ? Object.keys(translations) : []
|
|
372
|
+
translationsKeys: translations ? Object.keys(translations) : [],
|
|
332
373
|
});
|
|
333
374
|
}
|
|
334
|
-
return this.config.missingKeyHandler?.(key, targetLang,
|
|
375
|
+
return this.config.missingKeyHandler?.(key, targetLang, "default") || key;
|
|
335
376
|
}
|
|
336
377
|
|
|
337
378
|
/**
|
|
338
379
|
* 다른 로드된 언어에서 번역 찾기 (언어 변경 중 깜빡임 방지)
|
|
339
380
|
*/
|
|
340
|
-
private findInOtherLanguages(
|
|
341
|
-
|
|
381
|
+
private findInOtherLanguages(
|
|
382
|
+
namespace: string,
|
|
383
|
+
key: string,
|
|
384
|
+
targetLang: string,
|
|
385
|
+
): string | null {
|
|
386
|
+
if (
|
|
387
|
+
!this.allTranslations ||
|
|
388
|
+
Object.keys(this.allTranslations).length === 0
|
|
389
|
+
) {
|
|
342
390
|
return null;
|
|
343
391
|
}
|
|
344
392
|
|
|
@@ -351,15 +399,19 @@ export class Translator implements TranslatorInterface {
|
|
|
351
399
|
}
|
|
352
400
|
}
|
|
353
401
|
}
|
|
354
|
-
|
|
402
|
+
|
|
355
403
|
return null;
|
|
356
404
|
}
|
|
357
405
|
|
|
358
406
|
/**
|
|
359
407
|
* 폴백 언어에서 번역 찾기
|
|
360
408
|
*/
|
|
361
|
-
private findInFallbackLanguage(
|
|
362
|
-
|
|
409
|
+
private findInFallbackLanguage(
|
|
410
|
+
namespace: string,
|
|
411
|
+
key: string,
|
|
412
|
+
targetLang: string,
|
|
413
|
+
): string | null {
|
|
414
|
+
const fallbackLang = this.config.fallbackLanguage || "en";
|
|
363
415
|
if (targetLang === fallbackLang) {
|
|
364
416
|
return null;
|
|
365
417
|
}
|
|
@@ -369,20 +421,24 @@ export class Translator implements TranslatorInterface {
|
|
|
369
421
|
this.cacheStats.hits++;
|
|
370
422
|
return result;
|
|
371
423
|
}
|
|
372
|
-
|
|
424
|
+
|
|
373
425
|
return null;
|
|
374
426
|
}
|
|
375
427
|
|
|
376
428
|
/**
|
|
377
429
|
* 번역 키를 번역된 텍스트로 변환
|
|
378
430
|
*/
|
|
379
|
-
translate(
|
|
431
|
+
translate(
|
|
432
|
+
key: string,
|
|
433
|
+
paramsOrLang?: Record<string, unknown> | string,
|
|
434
|
+
language?: string,
|
|
435
|
+
): string {
|
|
380
436
|
// 두 번째 인자 타입으로 분기
|
|
381
437
|
let params: Record<string, unknown> | undefined;
|
|
382
438
|
let targetLang: string;
|
|
383
|
-
if (typeof paramsOrLang ===
|
|
439
|
+
if (typeof paramsOrLang === "string") {
|
|
384
440
|
targetLang = paramsOrLang;
|
|
385
|
-
} else if (typeof paramsOrLang ===
|
|
441
|
+
} else if (typeof paramsOrLang === "object" && paramsOrLang !== null) {
|
|
386
442
|
params = paramsOrLang;
|
|
387
443
|
targetLang = language || this.currentLang;
|
|
388
444
|
} else {
|
|
@@ -392,13 +448,28 @@ export class Translator implements TranslatorInterface {
|
|
|
392
448
|
// 초기화되지 않은 경우 처리
|
|
393
449
|
if (!this.isInitialized) {
|
|
394
450
|
const raw = this.translateBeforeInitialized(key, targetLang);
|
|
451
|
+
// translateBeforeInitialized returns key or missingKeyHandler result on miss
|
|
452
|
+
// Check defaultValue before returning the raw key
|
|
453
|
+
if (
|
|
454
|
+
(!raw || raw === key) &&
|
|
455
|
+
params &&
|
|
456
|
+
typeof params === "object" &&
|
|
457
|
+
"defaultValue" in params &&
|
|
458
|
+
typeof params.defaultValue === "string"
|
|
459
|
+
) {
|
|
460
|
+
return this.interpolate(params.defaultValue, params);
|
|
461
|
+
}
|
|
395
462
|
return params ? this.interpolate(raw, params) : raw;
|
|
396
463
|
}
|
|
397
464
|
|
|
398
465
|
const { namespace, key: actualKey } = this.parseKey(key);
|
|
399
466
|
|
|
400
467
|
// 1단계: 현재 언어에서 찾기
|
|
401
|
-
let result: string | null = this.findInNamespace(
|
|
468
|
+
let result: string | null = this.findInNamespace(
|
|
469
|
+
namespace,
|
|
470
|
+
actualKey,
|
|
471
|
+
targetLang,
|
|
472
|
+
);
|
|
402
473
|
if (result) {
|
|
403
474
|
this.cacheStats.hits++;
|
|
404
475
|
return params ? this.interpolate(result, params) : result;
|
|
@@ -419,38 +490,61 @@ export class Translator implements TranslatorInterface {
|
|
|
419
490
|
// 모든 단계에서 찾지 못한 경우
|
|
420
491
|
this.cacheStats.misses++;
|
|
421
492
|
|
|
493
|
+
// defaultValue가 제공된 경우 반환 (프로덕션/디버그 모두 적용)
|
|
494
|
+
if (
|
|
495
|
+
params &&
|
|
496
|
+
typeof params === "object" &&
|
|
497
|
+
"defaultValue" in params &&
|
|
498
|
+
typeof params.defaultValue === "string"
|
|
499
|
+
) {
|
|
500
|
+
return this.interpolate(params.defaultValue, params);
|
|
501
|
+
}
|
|
502
|
+
|
|
422
503
|
if (this.config.debug) {
|
|
423
|
-
const missing =
|
|
504
|
+
const missing =
|
|
505
|
+
this.config.missingKeyHandler?.(key, targetLang, namespace) || key;
|
|
424
506
|
return params ? this.interpolate(missing, params) : missing;
|
|
425
507
|
}
|
|
426
508
|
|
|
427
509
|
// 프로덕션에서는 빈 문자열 반환 (미싱 키 노출 방지)
|
|
428
|
-
return
|
|
510
|
+
return "";
|
|
429
511
|
}
|
|
430
512
|
|
|
431
513
|
/**
|
|
432
514
|
* 네임스페이스에서 키 찾기
|
|
433
515
|
*/
|
|
434
|
-
private findInNamespace(
|
|
516
|
+
private findInNamespace(
|
|
517
|
+
namespace: string,
|
|
518
|
+
key: string,
|
|
519
|
+
language: string,
|
|
520
|
+
): string {
|
|
435
521
|
const translations = this.allTranslations[language]?.[namespace];
|
|
436
522
|
|
|
437
523
|
if (!translations) {
|
|
438
524
|
// 네임스페이스가 없으면 자동으로 로드 시도 (비동기, 백그라운드)
|
|
439
525
|
const cacheKey = `${language}:${namespace}`;
|
|
440
|
-
if (
|
|
526
|
+
if (
|
|
527
|
+
!this.loadedNamespaces.has(cacheKey) &&
|
|
528
|
+
!this.loadingPromises.has(cacheKey)
|
|
529
|
+
) {
|
|
441
530
|
// 로딩 시작 (비동기, 즉시 반환하지 않음)
|
|
442
|
-
this.loadTranslationData(language, namespace).catch(error => {
|
|
531
|
+
this.loadTranslationData(language, namespace).catch((error) => {
|
|
443
532
|
if (this.config.debug) {
|
|
444
|
-
console.warn(
|
|
533
|
+
console.warn(
|
|
534
|
+
`⚠️ [TRANSLATOR] Auto-load failed for ${language}/${namespace}:`,
|
|
535
|
+
error,
|
|
536
|
+
);
|
|
445
537
|
}
|
|
446
538
|
});
|
|
447
|
-
|
|
539
|
+
|
|
448
540
|
// 디버그 모드에서만 첫 시도 시에만 경고 출력 (중복 방지)
|
|
449
541
|
if (this.config.debug) {
|
|
450
|
-
console.warn(
|
|
542
|
+
console.warn(
|
|
543
|
+
`❌ [TRANSLATOR] No translations found for ${language}/${namespace}, attempting auto-load...`,
|
|
544
|
+
);
|
|
451
545
|
}
|
|
452
546
|
}
|
|
453
|
-
return
|
|
547
|
+
return "";
|
|
454
548
|
}
|
|
455
549
|
|
|
456
550
|
// 직접 키 매칭
|
|
@@ -472,9 +566,11 @@ export class Translator implements TranslatorInterface {
|
|
|
472
566
|
}
|
|
473
567
|
|
|
474
568
|
if (this.config.debug) {
|
|
475
|
-
console.warn(
|
|
569
|
+
console.warn(
|
|
570
|
+
`❌ [TRANSLATOR] No match found for key: ${key} in ${language}/${namespace}`,
|
|
571
|
+
);
|
|
476
572
|
}
|
|
477
|
-
return
|
|
573
|
+
return "";
|
|
478
574
|
}
|
|
479
575
|
|
|
480
576
|
/**
|
|
@@ -482,28 +578,31 @@ export class Translator implements TranslatorInterface {
|
|
|
482
578
|
* 배열도 지원: 최종 값이 string[]이면 그대로 반환
|
|
483
579
|
*/
|
|
484
580
|
private getNestedValue(obj: unknown, path: string): unknown {
|
|
485
|
-
if (typeof obj !==
|
|
581
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
486
582
|
return undefined;
|
|
487
583
|
}
|
|
488
584
|
|
|
489
|
-
return path.split(
|
|
585
|
+
return path.split(".").reduce((current: unknown, key: string) => {
|
|
490
586
|
if (current == null) return undefined;
|
|
491
587
|
if (Array.isArray(current)) {
|
|
492
588
|
const idx = Number(key);
|
|
493
589
|
return Number.isInteger(idx) ? current[idx] : undefined;
|
|
494
590
|
}
|
|
495
|
-
if (
|
|
591
|
+
if (
|
|
592
|
+
typeof current === "object" &&
|
|
593
|
+
key in (current as Record<string, unknown>)
|
|
594
|
+
) {
|
|
496
595
|
return (current as Record<string, unknown>)[key];
|
|
497
596
|
}
|
|
498
597
|
return undefined;
|
|
499
598
|
}, obj);
|
|
500
599
|
}
|
|
501
|
-
|
|
600
|
+
|
|
502
601
|
/**
|
|
503
602
|
* 문자열 값인지 확인하는 타입 가드
|
|
504
603
|
*/
|
|
505
604
|
private isStringValue(value: unknown): value is string {
|
|
506
|
-
return typeof value ===
|
|
605
|
+
return typeof value === "string" && value.length > 0;
|
|
507
606
|
}
|
|
508
607
|
|
|
509
608
|
/**
|
|
@@ -511,18 +610,22 @@ export class Translator implements TranslatorInterface {
|
|
|
511
610
|
* 배열 값이 t()에 전달되면 랜덤으로 하나를 선택하여 반환
|
|
512
611
|
*/
|
|
513
612
|
private isStringArray(value: unknown): value is string[] {
|
|
514
|
-
return
|
|
613
|
+
return (
|
|
614
|
+
Array.isArray(value) &&
|
|
615
|
+
value.length > 0 &&
|
|
616
|
+
value.every((v) => typeof v === "string")
|
|
617
|
+
);
|
|
515
618
|
}
|
|
516
619
|
|
|
517
620
|
/**
|
|
518
621
|
* 원시 값 가져오기 (배열, 객체 포함)
|
|
519
622
|
*/
|
|
520
|
-
getRawValue(key: string, language?: string):
|
|
623
|
+
getRawValue<T = unknown>(key: string, language?: string): T | undefined {
|
|
521
624
|
const targetLang = language || this.currentLang;
|
|
522
625
|
|
|
523
626
|
if (!this.isInitialized) {
|
|
524
627
|
if (this.config.debug) {
|
|
525
|
-
console.warn(
|
|
628
|
+
console.warn("Translator not initialized. Call initialize() first.");
|
|
526
629
|
}
|
|
527
630
|
return undefined;
|
|
528
631
|
}
|
|
@@ -536,25 +639,29 @@ export class Translator implements TranslatorInterface {
|
|
|
536
639
|
|
|
537
640
|
// 직접 키 매칭
|
|
538
641
|
if (actualKey in translations) {
|
|
539
|
-
return translations[actualKey];
|
|
642
|
+
return translations[actualKey] as T;
|
|
540
643
|
}
|
|
541
644
|
|
|
542
645
|
// 중첩 키 매칭
|
|
543
646
|
const nestedValue = this.getNestedValue(translations, actualKey);
|
|
544
647
|
if (nestedValue !== undefined) {
|
|
545
|
-
return nestedValue;
|
|
648
|
+
return nestedValue as T;
|
|
546
649
|
}
|
|
547
650
|
|
|
548
651
|
// 폴백 언어에서 찾기
|
|
549
652
|
if (targetLang !== this.config.fallbackLanguage) {
|
|
550
|
-
const fallbackTranslations =
|
|
653
|
+
const fallbackTranslations =
|
|
654
|
+
this.allTranslations[this.config.fallbackLanguage || "en"]?.[namespace];
|
|
551
655
|
if (fallbackTranslations) {
|
|
552
656
|
if (actualKey in fallbackTranslations) {
|
|
553
|
-
return fallbackTranslations[actualKey];
|
|
657
|
+
return fallbackTranslations[actualKey] as T;
|
|
554
658
|
}
|
|
555
|
-
const fallbackNestedValue = this.getNestedValue(
|
|
659
|
+
const fallbackNestedValue = this.getNestedValue(
|
|
660
|
+
fallbackTranslations,
|
|
661
|
+
actualKey,
|
|
662
|
+
);
|
|
556
663
|
if (fallbackNestedValue !== undefined) {
|
|
557
|
-
return fallbackNestedValue;
|
|
664
|
+
return fallbackNestedValue as T;
|
|
558
665
|
}
|
|
559
666
|
}
|
|
560
667
|
}
|
|
@@ -567,10 +674,13 @@ export class Translator implements TranslatorInterface {
|
|
|
567
674
|
*/
|
|
568
675
|
tArray(key: string, language?: string): string[] {
|
|
569
676
|
const raw = this.getRawValue(key, language);
|
|
570
|
-
if (
|
|
677
|
+
if (
|
|
678
|
+
Array.isArray(raw) &&
|
|
679
|
+
raw.every((v: unknown) => typeof v === "string")
|
|
680
|
+
) {
|
|
571
681
|
return raw as string[];
|
|
572
682
|
}
|
|
573
|
-
if (process.env.NODE_ENV ===
|
|
683
|
+
if (process.env.NODE_ENV === "development") {
|
|
574
684
|
console.warn(`tArray: "${key}" is not a string array`);
|
|
575
685
|
}
|
|
576
686
|
return [];
|
|
@@ -597,20 +707,27 @@ export class Translator implements TranslatorInterface {
|
|
|
597
707
|
* tPlural('common:total_count', 1) → en: "1 item" / ko: "총 1개"
|
|
598
708
|
* tPlural('common:total_count', 5) → en: "5 items" / ko: "총 5개"
|
|
599
709
|
*/
|
|
600
|
-
tPlural(
|
|
710
|
+
tPlural(
|
|
711
|
+
key: string,
|
|
712
|
+
count: number,
|
|
713
|
+
params?: Record<string, unknown>,
|
|
714
|
+
language?: string,
|
|
715
|
+
): string {
|
|
601
716
|
const targetLang = language || this.currentLang;
|
|
602
717
|
const raw = this.getRawValue(key, targetLang);
|
|
603
718
|
const mergedParams: Record<string, unknown> = { count, ...params };
|
|
604
719
|
|
|
605
720
|
// PluralValue 객체인 경우: Intl.PluralRules로 카테고리 결정
|
|
606
721
|
if (isPluralValue(raw)) {
|
|
607
|
-
const category = this.getPluralRules(targetLang).select(
|
|
722
|
+
const category = this.getPluralRules(targetLang).select(
|
|
723
|
+
count,
|
|
724
|
+
) as PluralCategory;
|
|
608
725
|
const text = raw[category] ?? raw.other;
|
|
609
726
|
return this.interpolate(text, mergedParams);
|
|
610
727
|
}
|
|
611
728
|
|
|
612
729
|
// fallback: plain string이면 interpolate만
|
|
613
|
-
if (typeof raw ===
|
|
730
|
+
if (typeof raw === "string") {
|
|
614
731
|
return this.interpolate(raw, mergedParams);
|
|
615
732
|
}
|
|
616
733
|
|
|
@@ -618,7 +735,7 @@ export class Translator implements TranslatorInterface {
|
|
|
618
735
|
if (this.config.debug) {
|
|
619
736
|
return this.interpolate(key, mergedParams);
|
|
620
737
|
}
|
|
621
|
-
return
|
|
738
|
+
return "";
|
|
622
739
|
}
|
|
623
740
|
|
|
624
741
|
/**
|
|
@@ -652,15 +769,17 @@ export class Translator implements TranslatorInterface {
|
|
|
652
769
|
|
|
653
770
|
// 새로운 언어의 데이터가 로드되지 않았다면 로드
|
|
654
771
|
if (!this.allTranslations[language]) {
|
|
655
|
-
this.loadLanguageData(language).catch(error => {
|
|
772
|
+
this.loadLanguageData(language).catch((error) => {
|
|
656
773
|
if (this.config.debug) {
|
|
657
|
-
console.warn(
|
|
774
|
+
console.warn("Failed to load language data:", error);
|
|
658
775
|
}
|
|
659
776
|
});
|
|
660
777
|
}
|
|
661
778
|
|
|
662
779
|
if (this.config.debug) {
|
|
663
|
-
console.log(
|
|
780
|
+
console.log(
|
|
781
|
+
`🌐 [TRANSLATOR] Language changed: ${previousLanguage} -> ${language}`,
|
|
782
|
+
);
|
|
664
783
|
}
|
|
665
784
|
}
|
|
666
785
|
|
|
@@ -677,15 +796,15 @@ export class Translator implements TranslatorInterface {
|
|
|
677
796
|
const data = await this.safeLoadTranslations(language, namespace);
|
|
678
797
|
this.allTranslations[language][namespace] = data;
|
|
679
798
|
this.loadedNamespaces.add(`${language}:${namespace}`);
|
|
680
|
-
|
|
799
|
+
|
|
681
800
|
// 언어 변경 시 번역 로드 완료 알림
|
|
682
801
|
this.notifyTranslationLoaded(language, namespace);
|
|
683
802
|
} catch (error) {
|
|
684
803
|
const translationError = this.createTranslationError(
|
|
685
|
-
|
|
804
|
+
"LOAD_FAILED",
|
|
686
805
|
error as Error,
|
|
687
806
|
language,
|
|
688
|
-
namespace
|
|
807
|
+
namespace,
|
|
689
808
|
);
|
|
690
809
|
|
|
691
810
|
this.logError(translationError);
|
|
@@ -713,7 +832,7 @@ export class Translator implements TranslatorInterface {
|
|
|
713
832
|
* 지원되는 언어 목록 가져오기
|
|
714
833
|
*/
|
|
715
834
|
getSupportedLanguages(): string[] {
|
|
716
|
-
return this.config.supportedLanguages?.map(lang => lang.code) || [];
|
|
835
|
+
return this.config.supportedLanguages?.map((lang) => lang.code) || [];
|
|
717
836
|
}
|
|
718
837
|
|
|
719
838
|
/**
|
|
@@ -738,7 +857,7 @@ export class Translator implements TranslatorInterface {
|
|
|
738
857
|
this.cacheStats = { hits: 0, misses: 0 };
|
|
739
858
|
|
|
740
859
|
if (this.config.debug) {
|
|
741
|
-
console.log(
|
|
860
|
+
console.log("Cache cleared");
|
|
742
861
|
}
|
|
743
862
|
}
|
|
744
863
|
|
|
@@ -749,7 +868,7 @@ export class Translator implements TranslatorInterface {
|
|
|
749
868
|
this.cache.set(key, {
|
|
750
869
|
data,
|
|
751
870
|
timestamp: Date.now(),
|
|
752
|
-
ttl: 5 * 60 * 1000 // 5분
|
|
871
|
+
ttl: 5 * 60 * 1000, // 5분
|
|
753
872
|
});
|
|
754
873
|
}
|
|
755
874
|
|
|
@@ -776,14 +895,14 @@ export class Translator implements TranslatorInterface {
|
|
|
776
895
|
* 번역 오류 생성
|
|
777
896
|
*/
|
|
778
897
|
private createTranslationError(
|
|
779
|
-
code: TranslationError[
|
|
898
|
+
code: TranslationError["code"],
|
|
780
899
|
originalError: Error,
|
|
781
900
|
language?: string,
|
|
782
901
|
namespace?: string,
|
|
783
|
-
key?: string
|
|
902
|
+
key?: string,
|
|
784
903
|
): TranslationError {
|
|
785
904
|
return {
|
|
786
|
-
name:
|
|
905
|
+
name: "TranslationError",
|
|
787
906
|
code,
|
|
788
907
|
message: originalError.message,
|
|
789
908
|
originalError,
|
|
@@ -791,7 +910,7 @@ export class Translator implements TranslatorInterface {
|
|
|
791
910
|
namespace,
|
|
792
911
|
key,
|
|
793
912
|
timestamp: Date.now(),
|
|
794
|
-
stack: originalError.stack
|
|
913
|
+
stack: originalError.stack,
|
|
795
914
|
};
|
|
796
915
|
}
|
|
797
916
|
|
|
@@ -800,7 +919,11 @@ export class Translator implements TranslatorInterface {
|
|
|
800
919
|
*/
|
|
801
920
|
private logError(error: TranslationError): void {
|
|
802
921
|
if (this.config.errorHandler) {
|
|
803
|
-
this.config.errorHandler(
|
|
922
|
+
this.config.errorHandler(
|
|
923
|
+
error,
|
|
924
|
+
error.language || "",
|
|
925
|
+
error.namespace || "",
|
|
926
|
+
);
|
|
804
927
|
}
|
|
805
928
|
}
|
|
806
929
|
|
|
@@ -810,7 +933,7 @@ export class Translator implements TranslatorInterface {
|
|
|
810
933
|
private async retryOperation<T>(
|
|
811
934
|
operation: () => Promise<T>,
|
|
812
935
|
error: TranslationError,
|
|
813
|
-
context: { language?: string; namespace?: string; key?: string }
|
|
936
|
+
context: { language?: string; namespace?: string; key?: string },
|
|
814
937
|
): Promise<T> {
|
|
815
938
|
const maxRetries = 3;
|
|
816
939
|
let lastError = error;
|
|
@@ -820,11 +943,11 @@ export class Translator implements TranslatorInterface {
|
|
|
820
943
|
return await operation();
|
|
821
944
|
} catch (retryError) {
|
|
822
945
|
lastError = this.createTranslationError(
|
|
823
|
-
|
|
946
|
+
"RETRY_FAILED",
|
|
824
947
|
retryError as Error,
|
|
825
948
|
context.language,
|
|
826
949
|
context.namespace,
|
|
827
|
-
context.key
|
|
950
|
+
context.key,
|
|
828
951
|
);
|
|
829
952
|
|
|
830
953
|
if (attempt === maxRetries) {
|
|
@@ -832,7 +955,9 @@ export class Translator implements TranslatorInterface {
|
|
|
832
955
|
}
|
|
833
956
|
|
|
834
957
|
// 지수 백오프
|
|
835
|
-
await new Promise(
|
|
958
|
+
await new Promise((resolve) =>
|
|
959
|
+
setTimeout(resolve, Math.pow(2, attempt) * 1000),
|
|
960
|
+
);
|
|
836
961
|
}
|
|
837
962
|
}
|
|
838
963
|
|
|
@@ -842,18 +967,27 @@ export class Translator implements TranslatorInterface {
|
|
|
842
967
|
/**
|
|
843
968
|
* 안전한 번역 로드
|
|
844
969
|
*/
|
|
845
|
-
private async safeLoadTranslations(
|
|
970
|
+
private async safeLoadTranslations(
|
|
971
|
+
language: string,
|
|
972
|
+
namespace: string,
|
|
973
|
+
): Promise<TranslationNamespace> {
|
|
846
974
|
if (this.config.debug) {
|
|
847
|
-
console.log(`📥 [TRANSLATOR] safeLoadTranslations called:`, {
|
|
975
|
+
console.log(`📥 [TRANSLATOR] safeLoadTranslations called:`, {
|
|
976
|
+
language,
|
|
977
|
+
namespace,
|
|
978
|
+
});
|
|
848
979
|
}
|
|
849
980
|
|
|
850
981
|
const loadOperation = async (): Promise<TranslationNamespace> => {
|
|
851
982
|
if (!this.config.loadTranslations) {
|
|
852
|
-
throw new Error(
|
|
983
|
+
throw new Error("No translation loader configured");
|
|
853
984
|
}
|
|
854
985
|
|
|
855
986
|
if (this.config.debug) {
|
|
856
|
-
console.log(`🔄 [TRANSLATOR] Calling loadTranslations for:`, {
|
|
987
|
+
console.log(`🔄 [TRANSLATOR] Calling loadTranslations for:`, {
|
|
988
|
+
language,
|
|
989
|
+
namespace,
|
|
990
|
+
});
|
|
857
991
|
}
|
|
858
992
|
|
|
859
993
|
const data = await this.config.loadTranslations(language, namespace);
|
|
@@ -863,7 +997,9 @@ export class Translator implements TranslatorInterface {
|
|
|
863
997
|
}
|
|
864
998
|
|
|
865
999
|
if (!isTranslationNamespace(data)) {
|
|
866
|
-
throw new Error(
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
`Invalid translation data for ${language}:${namespace}`,
|
|
1002
|
+
);
|
|
867
1003
|
}
|
|
868
1004
|
|
|
869
1005
|
return data;
|
|
@@ -873,13 +1009,16 @@ export class Translator implements TranslatorInterface {
|
|
|
873
1009
|
return await loadOperation();
|
|
874
1010
|
} catch (error) {
|
|
875
1011
|
const translationError = this.createTranslationError(
|
|
876
|
-
|
|
1012
|
+
"LOAD_FAILED",
|
|
877
1013
|
error as Error,
|
|
878
1014
|
language,
|
|
879
|
-
namespace
|
|
1015
|
+
namespace,
|
|
880
1016
|
);
|
|
881
1017
|
|
|
882
|
-
return this.retryOperation(loadOperation, translationError, {
|
|
1018
|
+
return this.retryOperation(loadOperation, translationError, {
|
|
1019
|
+
language,
|
|
1020
|
+
namespace,
|
|
1021
|
+
});
|
|
883
1022
|
}
|
|
884
1023
|
}
|
|
885
1024
|
|
|
@@ -895,14 +1034,16 @@ export class Translator implements TranslatorInterface {
|
|
|
895
1034
|
cacheSize: this.cache.size,
|
|
896
1035
|
allTranslations: this.allTranslations,
|
|
897
1036
|
initializationError: this.initializationError,
|
|
898
|
-
config: this.config
|
|
1037
|
+
config: this.config,
|
|
899
1038
|
};
|
|
900
1039
|
}
|
|
901
1040
|
|
|
902
1041
|
/**
|
|
903
1042
|
* SSR에서 하이드레이션
|
|
904
1043
|
*/
|
|
905
|
-
hydrateFromSSR(
|
|
1044
|
+
hydrateFromSSR(
|
|
1045
|
+
translations: Record<string, Record<string, TranslationNamespace>>,
|
|
1046
|
+
): void {
|
|
906
1047
|
this.allTranslations = translations;
|
|
907
1048
|
this.isInitialized = true;
|
|
908
1049
|
|
|
@@ -917,18 +1058,15 @@ export class Translator implements TranslatorInterface {
|
|
|
917
1058
|
/**
|
|
918
1059
|
* 비동기 번역 (고급 기능)
|
|
919
1060
|
*/
|
|
920
|
-
async translateAsync(
|
|
1061
|
+
async translateAsync(
|
|
1062
|
+
key: string,
|
|
1063
|
+
params?: Record<string, unknown>,
|
|
1064
|
+
): Promise<string> {
|
|
921
1065
|
if (!this.isInitialized) {
|
|
922
1066
|
await this.initialize();
|
|
923
1067
|
}
|
|
924
1068
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
if (!params) {
|
|
928
|
-
return translated;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
return this.interpolate(translated, params);
|
|
1069
|
+
return this.translate(key, params);
|
|
932
1070
|
}
|
|
933
1071
|
|
|
934
1072
|
/**
|
|
@@ -936,28 +1074,33 @@ export class Translator implements TranslatorInterface {
|
|
|
936
1074
|
*/
|
|
937
1075
|
translateSync(key: string, params?: Record<string, unknown>): string {
|
|
938
1076
|
if (!this.isInitialized) {
|
|
1077
|
+
// defaultValue support even before initialization
|
|
1078
|
+
if (
|
|
1079
|
+
params &&
|
|
1080
|
+
typeof params === "object" &&
|
|
1081
|
+
"defaultValue" in params &&
|
|
1082
|
+
typeof params.defaultValue === "string"
|
|
1083
|
+
) {
|
|
1084
|
+
return this.interpolate(params.defaultValue, params);
|
|
1085
|
+
}
|
|
939
1086
|
if (this.config.debug) {
|
|
940
|
-
console.warn(
|
|
1087
|
+
console.warn("Translator not initialized for sync translation");
|
|
941
1088
|
}
|
|
942
1089
|
const { namespace } = this.parseKey(key);
|
|
943
|
-
return
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const translated = this.translate(key);
|
|
947
|
-
|
|
948
|
-
if (!params) {
|
|
949
|
-
return translated;
|
|
1090
|
+
return (
|
|
1091
|
+
this.config.missingKeyHandler?.(key, this.currentLang, namespace) || key
|
|
1092
|
+
);
|
|
950
1093
|
}
|
|
951
1094
|
|
|
952
|
-
return this.
|
|
1095
|
+
return this.translate(key, params);
|
|
953
1096
|
}
|
|
954
1097
|
|
|
955
1098
|
/**
|
|
956
1099
|
* 키 파싱 (네임스페이스:키 형식)
|
|
957
|
-
*
|
|
1100
|
+
*
|
|
958
1101
|
* - 콜론(:)만 네임스페이스 구분자로 사용
|
|
959
1102
|
* - 점(.)은 키 이름의 일부로 취급 (중첩 객체 접근용)
|
|
960
|
-
*
|
|
1103
|
+
*
|
|
961
1104
|
* @example
|
|
962
1105
|
* parseKey("home:hero.badge") → { namespace: "home", key: "hero.badge" }
|
|
963
1106
|
* parseKey("hero.badge") → { namespace: "common", key: "hero.badge" }
|
|
@@ -965,20 +1108,26 @@ export class Translator implements TranslatorInterface {
|
|
|
965
1108
|
*/
|
|
966
1109
|
private parseKey(key: string): { namespace: string; key: string } {
|
|
967
1110
|
// 콜론(:)만 네임스페이스 구분자로 사용
|
|
968
|
-
const colonIndex = key.indexOf(
|
|
1111
|
+
const colonIndex = key.indexOf(":");
|
|
969
1112
|
if (colonIndex !== -1) {
|
|
970
|
-
return {
|
|
1113
|
+
return {
|
|
1114
|
+
namespace: key.substring(0, colonIndex),
|
|
1115
|
+
key: key.substring(colonIndex + 1),
|
|
1116
|
+
};
|
|
971
1117
|
}
|
|
972
1118
|
|
|
973
1119
|
// 콜론이 없으면 common 네임스페이스로 간주
|
|
974
1120
|
// 점(.)은 키 이름의 일부 (중첩 객체 접근은 getNestedValue에서 처리)
|
|
975
|
-
return { namespace:
|
|
1121
|
+
return { namespace: "common", key };
|
|
976
1122
|
}
|
|
977
1123
|
|
|
978
1124
|
/**
|
|
979
1125
|
* 번역 데이터 로드 (고급 기능)
|
|
980
1126
|
*/
|
|
981
|
-
private async loadTranslationData(
|
|
1127
|
+
private async loadTranslationData(
|
|
1128
|
+
language: string,
|
|
1129
|
+
namespace: string,
|
|
1130
|
+
): Promise<TranslationNamespace> {
|
|
982
1131
|
const cacheKey = `${language}:${namespace}`;
|
|
983
1132
|
|
|
984
1133
|
// 이미 로드된 네임스페이스인지 확인
|
|
@@ -1013,24 +1162,26 @@ export class Translator implements TranslatorInterface {
|
|
|
1013
1162
|
|
|
1014
1163
|
try {
|
|
1015
1164
|
const data = await loadPromise;
|
|
1016
|
-
|
|
1165
|
+
|
|
1017
1166
|
// allTranslations에 저장 (중요: 이렇게 해야 findInNamespace에서 찾을 수 있음)
|
|
1018
1167
|
if (!this.allTranslations[language]) {
|
|
1019
1168
|
this.allTranslations[language] = {};
|
|
1020
1169
|
}
|
|
1021
1170
|
this.allTranslations[language][namespace] = data;
|
|
1022
1171
|
this.loadedNamespaces.add(cacheKey);
|
|
1023
|
-
|
|
1172
|
+
|
|
1024
1173
|
// 캐시에도 저장
|
|
1025
1174
|
this.setCacheEntry(cacheKey, data);
|
|
1026
|
-
|
|
1175
|
+
|
|
1027
1176
|
if (this.config.debug) {
|
|
1028
|
-
console.log(
|
|
1177
|
+
console.log(
|
|
1178
|
+
`✅ [TRANSLATOR] Auto-loaded and saved ${language}/${namespace}`,
|
|
1179
|
+
);
|
|
1029
1180
|
}
|
|
1030
|
-
|
|
1181
|
+
|
|
1031
1182
|
// React 리렌더링 트리거 (디바운싱 적용)
|
|
1032
1183
|
this.notifyTranslationLoaded(language, namespace);
|
|
1033
|
-
|
|
1184
|
+
|
|
1034
1185
|
return data;
|
|
1035
1186
|
} finally {
|
|
1036
1187
|
this.loadingPromises.delete(cacheKey);
|
|
@@ -1040,25 +1191,30 @@ export class Translator implements TranslatorInterface {
|
|
|
1040
1191
|
/**
|
|
1041
1192
|
* 실제 번역 데이터 로드
|
|
1042
1193
|
*/
|
|
1043
|
-
private async _loadTranslationData(
|
|
1194
|
+
private async _loadTranslationData(
|
|
1195
|
+
language: string,
|
|
1196
|
+
namespace: string,
|
|
1197
|
+
): Promise<TranslationNamespace> {
|
|
1044
1198
|
if (!this.config.loadTranslations) {
|
|
1045
|
-
throw new Error(
|
|
1199
|
+
throw new Error("No translation loader configured");
|
|
1046
1200
|
}
|
|
1047
1201
|
|
|
1048
1202
|
try {
|
|
1049
1203
|
const data = await this.config.loadTranslations(language, namespace);
|
|
1050
1204
|
|
|
1051
1205
|
if (!isTranslationNamespace(data)) {
|
|
1052
|
-
throw new Error(
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`Invalid translation data for ${language}:${namespace}`,
|
|
1208
|
+
);
|
|
1053
1209
|
}
|
|
1054
1210
|
|
|
1055
1211
|
return data;
|
|
1056
1212
|
} catch (error) {
|
|
1057
1213
|
const translationError = this.createTranslationError(
|
|
1058
|
-
|
|
1214
|
+
"LOAD_FAILED",
|
|
1059
1215
|
error as Error,
|
|
1060
1216
|
language,
|
|
1061
|
-
namespace
|
|
1217
|
+
namespace,
|
|
1062
1218
|
);
|
|
1063
1219
|
|
|
1064
1220
|
this.logError(translationError);
|
|
@@ -1073,9 +1229,9 @@ export class Translator implements TranslatorInterface {
|
|
|
1073
1229
|
export function ssrTranslate({
|
|
1074
1230
|
translations,
|
|
1075
1231
|
key,
|
|
1076
|
-
language =
|
|
1077
|
-
fallbackLanguage =
|
|
1078
|
-
missingKeyHandler = (key: string) => key
|
|
1232
|
+
language = "ko",
|
|
1233
|
+
fallbackLanguage = "en",
|
|
1234
|
+
missingKeyHandler = (key: string) => key,
|
|
1079
1235
|
}: {
|
|
1080
1236
|
translations: Record<string, Record<string, TranslationNamespace>>;
|
|
1081
1237
|
key: string;
|
|
@@ -1086,7 +1242,14 @@ export function ssrTranslate({
|
|
|
1086
1242
|
const { namespace, key: actualKey } = parseKey(key);
|
|
1087
1243
|
|
|
1088
1244
|
// 현재 언어에서 찾기
|
|
1089
|
-
let result = ssrFindInNamespace(
|
|
1245
|
+
let result = ssrFindInNamespace(
|
|
1246
|
+
translations,
|
|
1247
|
+
namespace,
|
|
1248
|
+
actualKey,
|
|
1249
|
+
language,
|
|
1250
|
+
fallbackLanguage,
|
|
1251
|
+
missingKeyHandler,
|
|
1252
|
+
);
|
|
1090
1253
|
|
|
1091
1254
|
if (result) {
|
|
1092
1255
|
return result;
|
|
@@ -1094,7 +1257,14 @@ export function ssrTranslate({
|
|
|
1094
1257
|
|
|
1095
1258
|
// 폴백 언어에서 찾기
|
|
1096
1259
|
if (language !== fallbackLanguage) {
|
|
1097
|
-
result = ssrFindInNamespace(
|
|
1260
|
+
result = ssrFindInNamespace(
|
|
1261
|
+
translations,
|
|
1262
|
+
namespace,
|
|
1263
|
+
actualKey,
|
|
1264
|
+
fallbackLanguage,
|
|
1265
|
+
fallbackLanguage,
|
|
1266
|
+
missingKeyHandler,
|
|
1267
|
+
);
|
|
1098
1268
|
if (result) {
|
|
1099
1269
|
return result;
|
|
1100
1270
|
}
|
|
@@ -1109,12 +1279,12 @@ function ssrFindInNamespace(
|
|
|
1109
1279
|
key: string,
|
|
1110
1280
|
language: string,
|
|
1111
1281
|
fallbackLanguage: string,
|
|
1112
|
-
missingKeyHandler: (key: string) => string
|
|
1282
|
+
missingKeyHandler: (key: string) => string,
|
|
1113
1283
|
): string {
|
|
1114
1284
|
const namespaceData = translations[language]?.[namespace];
|
|
1115
1285
|
|
|
1116
1286
|
if (!namespaceData) {
|
|
1117
|
-
return
|
|
1287
|
+
return "";
|
|
1118
1288
|
}
|
|
1119
1289
|
|
|
1120
1290
|
// 직접 키 매칭
|
|
@@ -1129,16 +1299,21 @@ function ssrFindInNamespace(
|
|
|
1129
1299
|
return nestedValue;
|
|
1130
1300
|
}
|
|
1131
1301
|
|
|
1132
|
-
return
|
|
1302
|
+
return "";
|
|
1133
1303
|
}
|
|
1134
1304
|
|
|
1135
1305
|
function getNestedValue(obj: unknown, path: string): unknown {
|
|
1136
|
-
if (typeof obj !==
|
|
1306
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
1137
1307
|
return undefined;
|
|
1138
1308
|
}
|
|
1139
1309
|
|
|
1140
|
-
return path.split(
|
|
1141
|
-
if (
|
|
1310
|
+
return path.split(".").reduce((current: unknown, key: string) => {
|
|
1311
|
+
if (
|
|
1312
|
+
current &&
|
|
1313
|
+
typeof current === "object" &&
|
|
1314
|
+
!Array.isArray(current) &&
|
|
1315
|
+
key in current
|
|
1316
|
+
) {
|
|
1142
1317
|
return (current as Record<string, unknown>)[key];
|
|
1143
1318
|
}
|
|
1144
1319
|
return undefined;
|
|
@@ -1149,34 +1324,37 @@ function getNestedValue(obj: unknown, path: string): unknown {
|
|
|
1149
1324
|
* 문자열 값인지 확인하는 타입 가드
|
|
1150
1325
|
*/
|
|
1151
1326
|
function isStringValue(value: unknown): value is string {
|
|
1152
|
-
return typeof value ===
|
|
1327
|
+
return typeof value === "string" && value.length > 0;
|
|
1153
1328
|
}
|
|
1154
1329
|
|
|
1155
1330
|
/**
|
|
1156
1331
|
* 키 파싱 (네임스페이스:키 형식) - SSR용 standalone 함수
|
|
1157
|
-
*
|
|
1332
|
+
*
|
|
1158
1333
|
* - 콜론(:)만 네임스페이스 구분자로 사용
|
|
1159
1334
|
* - 점(.)은 키 이름의 일부로 취급 (중첩 객체 접근용)
|
|
1160
1335
|
*/
|
|
1161
1336
|
function parseKey(key: string): { namespace: string; key: string } {
|
|
1162
1337
|
// 콜론(:)만 네임스페이스 구분자로 사용
|
|
1163
|
-
const colonIndex = key.indexOf(
|
|
1338
|
+
const colonIndex = key.indexOf(":");
|
|
1164
1339
|
if (colonIndex !== -1) {
|
|
1165
|
-
return {
|
|
1340
|
+
return {
|
|
1341
|
+
namespace: key.substring(0, colonIndex),
|
|
1342
|
+
key: key.substring(colonIndex + 1),
|
|
1343
|
+
};
|
|
1166
1344
|
}
|
|
1167
1345
|
|
|
1168
1346
|
// 콜론이 없으면 common 네임스페이스로 간주
|
|
1169
|
-
return { namespace:
|
|
1347
|
+
return { namespace: "common", key };
|
|
1170
1348
|
}
|
|
1171
1349
|
|
|
1172
1350
|
// 서버 번역 함수 (고급 기능 포함)
|
|
1173
1351
|
export function serverTranslate({
|
|
1174
1352
|
translations,
|
|
1175
1353
|
key,
|
|
1176
|
-
language =
|
|
1177
|
-
fallbackLanguage =
|
|
1354
|
+
language = "ko",
|
|
1355
|
+
fallbackLanguage = "en",
|
|
1178
1356
|
missingKeyHandler = (key: string) => key,
|
|
1179
|
-
options = {}
|
|
1357
|
+
options = {},
|
|
1180
1358
|
}: {
|
|
1181
1359
|
translations: Record<string, unknown>; // 번역 데이터
|
|
1182
1360
|
key: string; // 번역 키
|
|
@@ -1203,7 +1381,13 @@ export function serverTranslate({
|
|
|
1203
1381
|
}
|
|
1204
1382
|
|
|
1205
1383
|
// 번역 찾기
|
|
1206
|
-
const result = findInTranslations(
|
|
1384
|
+
const result = findInTranslations(
|
|
1385
|
+
translations,
|
|
1386
|
+
key,
|
|
1387
|
+
language,
|
|
1388
|
+
fallbackLanguage,
|
|
1389
|
+
missingKeyHandler,
|
|
1390
|
+
);
|
|
1207
1391
|
|
|
1208
1392
|
// 캐시에 저장
|
|
1209
1393
|
if (cache && result) {
|
|
@@ -1222,7 +1406,7 @@ function findInTranslations(
|
|
|
1222
1406
|
key: string,
|
|
1223
1407
|
language: string,
|
|
1224
1408
|
fallbackLanguage: string,
|
|
1225
|
-
missingKeyHandler: (key: string) => string
|
|
1409
|
+
missingKeyHandler: (key: string) => string,
|
|
1226
1410
|
): string {
|
|
1227
1411
|
const { namespace, key: actualKey } = parseKey(key);
|
|
1228
1412
|
|
|
@@ -1235,49 +1419,62 @@ function findInTranslations(
|
|
|
1235
1419
|
|
|
1236
1420
|
// 폴백 언어에서 찾기
|
|
1237
1421
|
if (language !== fallbackLanguage) {
|
|
1238
|
-
result = findInNamespace(
|
|
1422
|
+
result = findInNamespace(
|
|
1423
|
+
translations,
|
|
1424
|
+
namespace,
|
|
1425
|
+
actualKey,
|
|
1426
|
+
fallbackLanguage,
|
|
1427
|
+
);
|
|
1239
1428
|
if (result) {
|
|
1240
1429
|
return result;
|
|
1241
1430
|
}
|
|
1242
1431
|
}
|
|
1243
1432
|
|
|
1244
|
-
return
|
|
1433
|
+
return "";
|
|
1245
1434
|
}
|
|
1246
1435
|
|
|
1247
1436
|
function findInNamespace(
|
|
1248
1437
|
translations: Record<string, unknown>,
|
|
1249
1438
|
namespace: string,
|
|
1250
1439
|
key: string,
|
|
1251
|
-
language: string
|
|
1440
|
+
language: string,
|
|
1252
1441
|
): string {
|
|
1253
1442
|
// 언어 데이터 가져오기
|
|
1254
1443
|
const languageData = translations[language];
|
|
1255
1444
|
|
|
1256
1445
|
// 언어 데이터가 객체인지 확인
|
|
1257
|
-
if (
|
|
1258
|
-
|
|
1446
|
+
if (
|
|
1447
|
+
!languageData ||
|
|
1448
|
+
typeof languageData !== "object" ||
|
|
1449
|
+
Array.isArray(languageData)
|
|
1450
|
+
) {
|
|
1451
|
+
return "";
|
|
1259
1452
|
}
|
|
1260
1453
|
|
|
1261
1454
|
// 네임스페이스 데이터 가져오기
|
|
1262
1455
|
const namespaceData = (languageData as Record<string, unknown>)[namespace];
|
|
1263
1456
|
|
|
1264
|
-
if (
|
|
1265
|
-
|
|
1457
|
+
if (
|
|
1458
|
+
!namespaceData ||
|
|
1459
|
+
typeof namespaceData !== "object" ||
|
|
1460
|
+
Array.isArray(namespaceData)
|
|
1461
|
+
) {
|
|
1462
|
+
return "";
|
|
1266
1463
|
}
|
|
1267
1464
|
|
|
1268
1465
|
// 타입 단언: namespaceData는 객체임을 확인했으므로 Record로 단언
|
|
1269
1466
|
const data = namespaceData as Record<string, unknown>;
|
|
1270
1467
|
|
|
1271
1468
|
// 직접 키 매칭
|
|
1272
|
-
if (data[key] && typeof data[key] ===
|
|
1469
|
+
if (data[key] && typeof data[key] === "string") {
|
|
1273
1470
|
return data[key] as string;
|
|
1274
1471
|
}
|
|
1275
1472
|
|
|
1276
1473
|
// 중첩 키 매칭
|
|
1277
1474
|
const nestedValue = getNestedValue(namespaceData, key);
|
|
1278
|
-
if (typeof nestedValue ===
|
|
1475
|
+
if (typeof nestedValue === "string") {
|
|
1279
1476
|
return nestedValue;
|
|
1280
1477
|
}
|
|
1281
1478
|
|
|
1282
|
-
return
|
|
1283
|
-
}
|
|
1479
|
+
return "";
|
|
1480
|
+
}
|