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