@hua-labs/i18n-core 1.0.0
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 +636 -0
- package/dist/components/MissingKeyOverlay.d.ts +33 -0
- package/dist/components/MissingKeyOverlay.d.ts.map +1 -0
- package/dist/components/MissingKeyOverlay.js +138 -0
- package/dist/components/MissingKeyOverlay.js.map +1 -0
- package/dist/core/debug-tools.d.ts +37 -0
- package/dist/core/debug-tools.d.ts.map +1 -0
- package/dist/core/debug-tools.js +241 -0
- package/dist/core/debug-tools.js.map +1 -0
- package/dist/core/i18n-resource.d.ts +59 -0
- package/dist/core/i18n-resource.d.ts.map +1 -0
- package/dist/core/i18n-resource.js +153 -0
- package/dist/core/i18n-resource.js.map +1 -0
- package/dist/core/lazy-loader.d.ts +82 -0
- package/dist/core/lazy-loader.d.ts.map +1 -0
- package/dist/core/lazy-loader.js +193 -0
- package/dist/core/lazy-loader.js.map +1 -0
- package/dist/core/translator-factory.d.ts +50 -0
- package/dist/core/translator-factory.d.ts.map +1 -0
- package/dist/core/translator-factory.js +117 -0
- package/dist/core/translator-factory.js.map +1 -0
- package/dist/core/translator.d.ts +202 -0
- package/dist/core/translator.d.ts.map +1 -0
- package/dist/core/translator.js +912 -0
- package/dist/core/translator.js.map +1 -0
- package/dist/hooks/useI18n.d.ts +39 -0
- package/dist/hooks/useI18n.d.ts.map +1 -0
- package/dist/hooks/useI18n.js +531 -0
- package/dist/hooks/useI18n.js.map +1 -0
- package/dist/hooks/useTranslation.d.ts +55 -0
- package/dist/hooks/useTranslation.d.ts.map +1 -0
- package/dist/hooks/useTranslation.js +58 -0
- package/dist/hooks/useTranslation.js.map +1 -0
- package/dist/index.d.ts +162 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +162 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +191 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/default-translations.d.ts +20 -0
- package/dist/utils/default-translations.d.ts.map +1 -0
- package/dist/utils/default-translations.js +123 -0
- package/dist/utils/default-translations.js.map +1 -0
- package/package.json +60 -0
- package/src/components/MissingKeyOverlay.tsx +223 -0
- package/src/core/debug-tools.ts +298 -0
- package/src/core/i18n-resource.ts +180 -0
- package/src/core/lazy-loader.ts +255 -0
- package/src/core/translator-factory.ts +137 -0
- package/src/core/translator.tsx +1194 -0
- package/src/hooks/useI18n.tsx +595 -0
- package/src/hooks/useTranslation.tsx +62 -0
- package/src/index.ts +298 -0
- package/src/types/index.ts +443 -0
- package/src/utils/default-translations.ts +129 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface MissingKeyOverlayProps {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
|
6
|
+
style?: React.CSSProperties;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface MissingKey {
|
|
10
|
+
key: string;
|
|
11
|
+
namespace?: string;
|
|
12
|
+
language: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
component?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 개발 모드에서 누락된 번역 키를 화면에 표시하는 오버레이
|
|
19
|
+
* Lingui 스타일의 디버그 모드
|
|
20
|
+
*/
|
|
21
|
+
export const MissingKeyOverlay: React.FC<MissingKeyOverlayProps> = ({
|
|
22
|
+
enabled = process.env.NODE_ENV === 'development',
|
|
23
|
+
position = 'top-right',
|
|
24
|
+
style
|
|
25
|
+
}) => {
|
|
26
|
+
const [missingKeys, setMissingKeys] = useState<MissingKey[]>([]);
|
|
27
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled) return;
|
|
31
|
+
|
|
32
|
+
// 누락된 키 이벤트 리스너
|
|
33
|
+
const handleMissingKey = (event: CustomEvent<MissingKey>) => {
|
|
34
|
+
setMissingKeys(prev => [...prev, event.detail]);
|
|
35
|
+
setIsVisible(true);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// 전역 이벤트 리스너 등록
|
|
39
|
+
window.addEventListener('i18n:missing-key', handleMissingKey as EventListener);
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
window.removeEventListener('i18n:missing-key', handleMissingKey as EventListener);
|
|
43
|
+
};
|
|
44
|
+
}, [enabled]);
|
|
45
|
+
|
|
46
|
+
if (!enabled || !isVisible) return null;
|
|
47
|
+
|
|
48
|
+
const positionStyles = {
|
|
49
|
+
'top-right': { top: 20, right: 20 },
|
|
50
|
+
'top-left': { top: 20, left: 20 },
|
|
51
|
+
'bottom-right': { bottom: 20, right: 20 },
|
|
52
|
+
'bottom-left': { bottom: 20, left: 20 }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const overlayStyle: React.CSSProperties = {
|
|
56
|
+
position: 'fixed',
|
|
57
|
+
zIndex: 9999,
|
|
58
|
+
backgroundColor: 'rgba(255, 0, 0, 0.9)',
|
|
59
|
+
color: 'white',
|
|
60
|
+
padding: '12px 16px',
|
|
61
|
+
borderRadius: '8px',
|
|
62
|
+
fontFamily: 'monospace',
|
|
63
|
+
fontSize: '12px',
|
|
64
|
+
maxWidth: '400px',
|
|
65
|
+
maxHeight: '300px',
|
|
66
|
+
overflow: 'auto',
|
|
67
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
|
68
|
+
border: '2px solid #ff4444',
|
|
69
|
+
...positionStyles[position],
|
|
70
|
+
...style
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const keyStyle: React.CSSProperties = {
|
|
74
|
+
marginBottom: '8px',
|
|
75
|
+
padding: '4px 8px',
|
|
76
|
+
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
77
|
+
borderRadius: '4px',
|
|
78
|
+
borderLeft: '3px solid #ff8888'
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const headerStyle: React.CSSProperties = {
|
|
82
|
+
display: 'flex',
|
|
83
|
+
justifyContent: 'space-between',
|
|
84
|
+
alignItems: 'center',
|
|
85
|
+
marginBottom: '12px',
|
|
86
|
+
paddingBottom: '8px',
|
|
87
|
+
borderBottom: '1px solid rgba(255, 255, 255, 0.3)'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const closeButtonStyle: React.CSSProperties = {
|
|
91
|
+
background: 'none',
|
|
92
|
+
border: 'none',
|
|
93
|
+
color: 'white',
|
|
94
|
+
cursor: 'pointer',
|
|
95
|
+
fontSize: '16px',
|
|
96
|
+
padding: '0',
|
|
97
|
+
width: '20px',
|
|
98
|
+
height: '20px',
|
|
99
|
+
display: 'flex',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center'
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const clearButtonStyle: React.CSSProperties = {
|
|
105
|
+
background: 'rgba(255, 255, 255, 0.2)',
|
|
106
|
+
border: 'none',
|
|
107
|
+
color: 'white',
|
|
108
|
+
cursor: 'pointer',
|
|
109
|
+
padding: '4px 8px',
|
|
110
|
+
borderRadius: '4px',
|
|
111
|
+
fontSize: '10px',
|
|
112
|
+
marginLeft: '8px'
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleClose = () => {
|
|
116
|
+
setIsVisible(false);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleClear = () => {
|
|
120
|
+
setMissingKeys([]);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const formatTime = (timestamp: number) => {
|
|
124
|
+
return new Date(timestamp).toLocaleTimeString();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div style={overlayStyle}>
|
|
129
|
+
<div style={headerStyle}>
|
|
130
|
+
<div>
|
|
131
|
+
<strong>🚨 Missing Translation Keys</strong>
|
|
132
|
+
<span style={{ fontSize: '10px', marginLeft: '8px' }}>
|
|
133
|
+
({missingKeys.length})
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div>
|
|
137
|
+
<button style={clearButtonStyle} onClick={handleClear}>
|
|
138
|
+
Clear
|
|
139
|
+
</button>
|
|
140
|
+
<button style={closeButtonStyle} onClick={handleClose}>
|
|
141
|
+
×
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div>
|
|
147
|
+
{missingKeys.slice(-10).reverse().map((key, index) => (
|
|
148
|
+
<div key={`${key.key}-${key.timestamp}`} style={keyStyle}>
|
|
149
|
+
<div style={{ fontWeight: 'bold', color: '#ffcccc' }}>
|
|
150
|
+
{key.key}
|
|
151
|
+
</div>
|
|
152
|
+
<div style={{ fontSize: '10px', marginTop: '2px' }}>
|
|
153
|
+
<span>Lang: {key.language}</span>
|
|
154
|
+
{key.namespace && <span style={{ marginLeft: '8px' }}>NS: {key.namespace}</span>}
|
|
155
|
+
<span style={{ marginLeft: '8px' }}>Time: {formatTime(key.timestamp)}</span>
|
|
156
|
+
</div>
|
|
157
|
+
{key.component && (
|
|
158
|
+
<div style={{ fontSize: '10px', color: '#ffaaaa', marginTop: '2px' }}>
|
|
159
|
+
Component: {key.component}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
|
|
165
|
+
{missingKeys.length > 10 && (
|
|
166
|
+
<div style={{ fontSize: '10px', textAlign: 'center', marginTop: '8px' }}>
|
|
167
|
+
... and {missingKeys.length - 10} more
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 누락된 키를 오버레이에 표시하는 유틸리티 함수
|
|
177
|
+
*/
|
|
178
|
+
export const reportMissingKey = (key: string, options: {
|
|
179
|
+
namespace?: string;
|
|
180
|
+
language: string;
|
|
181
|
+
component?: string;
|
|
182
|
+
}) => {
|
|
183
|
+
if (process.env.NODE_ENV === 'development') {
|
|
184
|
+
const missingKey: MissingKey = {
|
|
185
|
+
key,
|
|
186
|
+
namespace: options.namespace,
|
|
187
|
+
language: options.language,
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
component: options.component
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// 커스텀 이벤트 발생
|
|
193
|
+
window.dispatchEvent(new CustomEvent('i18n:missing-key', {
|
|
194
|
+
detail: missingKey
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
// 콘솔에도 로그
|
|
198
|
+
console.warn(`Missing translation key: ${key}`, {
|
|
199
|
+
namespace: options.namespace,
|
|
200
|
+
language: options.language,
|
|
201
|
+
component: options.component
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 누락된 키 오버레이를 자동으로 활성화하는 훅
|
|
208
|
+
*/
|
|
209
|
+
export const useMissingKeyOverlay = (enabled = true) => {
|
|
210
|
+
const [showOverlay, setShowOverlay] = useState(enabled);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (enabled && process.env.NODE_ENV === 'development') {
|
|
214
|
+
setShowOverlay(true);
|
|
215
|
+
}
|
|
216
|
+
}, [enabled]);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
showOverlay,
|
|
220
|
+
setShowOverlay,
|
|
221
|
+
reportMissingKey
|
|
222
|
+
};
|
|
223
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 조건부 디버깅 도구 - 개발 환경에서만 로드
|
|
3
|
+
* 번들 크기 최적화를 위해 조건부 로딩
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface DebugTools {
|
|
7
|
+
// 번역 키 시각화
|
|
8
|
+
highlightMissingKeys: (container: HTMLElement) => void;
|
|
9
|
+
showTranslationKeys: (container: HTMLElement) => void;
|
|
10
|
+
|
|
11
|
+
// 성능 모니터링
|
|
12
|
+
performanceMetrics: {
|
|
13
|
+
translationCount: number;
|
|
14
|
+
cacheHits: number;
|
|
15
|
+
cacheMisses: number;
|
|
16
|
+
loadTimes: number[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// 개발자 도구
|
|
20
|
+
devTools: {
|
|
21
|
+
open: () => void;
|
|
22
|
+
close: () => void;
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// 번역 데이터 검증
|
|
27
|
+
validateTranslations: (translations: Record<string, unknown>) => {
|
|
28
|
+
missingKeys: string[];
|
|
29
|
+
duplicateKeys: string[];
|
|
30
|
+
invalidKeys: string[];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 디버깅 도구 팩토리 - 조건부 로딩
|
|
36
|
+
*/
|
|
37
|
+
export function createDebugTools(): DebugTools | null {
|
|
38
|
+
// 프로덕션에서는 디버깅 도구 비활성화
|
|
39
|
+
if (process.env.NODE_ENV === 'production') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 개발 환경에서만 로드
|
|
44
|
+
return {
|
|
45
|
+
highlightMissingKeys: (container: HTMLElement) => {
|
|
46
|
+
const elements = container.querySelectorAll('[data-i18n-key]');
|
|
47
|
+
elements.forEach((element) => {
|
|
48
|
+
const key = element.getAttribute('data-i18n-key');
|
|
49
|
+
const text = element.textContent;
|
|
50
|
+
|
|
51
|
+
// 번역 키가 텍스트와 다른 경우 하이라이트
|
|
52
|
+
if (key && text === key) {
|
|
53
|
+
(element as HTMLElement).style.backgroundColor = '#ffeb3b';
|
|
54
|
+
(element as HTMLElement).style.border = '2px solid #f57c00';
|
|
55
|
+
(element as HTMLElement).title = `Missing translation: ${key}`;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
showTranslationKeys: (container: HTMLElement) => {
|
|
61
|
+
const elements = container.querySelectorAll('[data-i18n-key]');
|
|
62
|
+
elements.forEach((element) => {
|
|
63
|
+
const key = element.getAttribute('data-i18n-key');
|
|
64
|
+
if (key) {
|
|
65
|
+
const tooltip = document.createElement('div');
|
|
66
|
+
tooltip.style.cssText = `
|
|
67
|
+
position: absolute;
|
|
68
|
+
background: #333;
|
|
69
|
+
color: white;
|
|
70
|
+
padding: 4px 8px;
|
|
71
|
+
border-radius: 4px;
|
|
72
|
+
font-size: 12px;
|
|
73
|
+
z-index: 10000;
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
opacity: 0;
|
|
76
|
+
transition: opacity 0.2s;
|
|
77
|
+
`;
|
|
78
|
+
tooltip.textContent = `Key: ${key}`;
|
|
79
|
+
|
|
80
|
+
element.addEventListener('mouseenter', () => {
|
|
81
|
+
const rect = element.getBoundingClientRect();
|
|
82
|
+
tooltip.style.left = `${rect.left}px`;
|
|
83
|
+
tooltip.style.top = `${rect.bottom + 5}px`;
|
|
84
|
+
tooltip.style.opacity = '1';
|
|
85
|
+
document.body.appendChild(tooltip);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
element.addEventListener('mouseleave', () => {
|
|
89
|
+
tooltip.style.opacity = '0';
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
if (tooltip.parentNode) {
|
|
92
|
+
tooltip.parentNode.removeChild(tooltip);
|
|
93
|
+
}
|
|
94
|
+
}, 200);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
performanceMetrics: {
|
|
101
|
+
translationCount: 0,
|
|
102
|
+
cacheHits: 0,
|
|
103
|
+
cacheMisses: 0,
|
|
104
|
+
loadTimes: [],
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
devTools: {
|
|
108
|
+
isOpen: false,
|
|
109
|
+
open: () => {
|
|
110
|
+
const devTools = createDevToolsPanel();
|
|
111
|
+
document.body.appendChild(devTools);
|
|
112
|
+
(devTools as HTMLElement & { isOpen?: boolean }).isOpen = true;
|
|
113
|
+
},
|
|
114
|
+
close: () => {
|
|
115
|
+
const existingPanel = document.getElementById('hua-i18n-devtools');
|
|
116
|
+
if (existingPanel) {
|
|
117
|
+
existingPanel.remove();
|
|
118
|
+
}
|
|
119
|
+
// devTools 변수는 이 스코프에서 접근할 수 없으므로 전역에서 관리
|
|
120
|
+
const globalDebugTools = (window as Window & { __HUA_I18N_DEBUG__?: DebugTools }).__HUA_I18N_DEBUG__;
|
|
121
|
+
if (globalDebugTools) {
|
|
122
|
+
globalDebugTools.devTools.isOpen = false;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
validateTranslations: (translations: Record<string, unknown>) => {
|
|
128
|
+
const missingKeys: string[] = [];
|
|
129
|
+
const duplicateKeys: string[] = [];
|
|
130
|
+
const invalidKeys: string[] = [];
|
|
131
|
+
const seenKeys = new Set<string>();
|
|
132
|
+
|
|
133
|
+
const traverse = (obj: unknown, path: string = '') => {
|
|
134
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
138
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
139
|
+
|
|
140
|
+
// 키 유효성 검사
|
|
141
|
+
if (typeof key !== 'string' || key.trim() === '') {
|
|
142
|
+
invalidKeys.push(currentPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 중복 키 검사
|
|
146
|
+
if (seenKeys.has(currentPath)) {
|
|
147
|
+
duplicateKeys.push(currentPath);
|
|
148
|
+
} else {
|
|
149
|
+
seenKeys.add(currentPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 값 검사
|
|
153
|
+
if (value === null || value === undefined) {
|
|
154
|
+
missingKeys.push(currentPath);
|
|
155
|
+
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
156
|
+
traverse(value, currentPath);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
traverse(translations);
|
|
162
|
+
|
|
163
|
+
return { missingKeys, duplicateKeys, invalidKeys };
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 개발자 도구 패널 생성
|
|
170
|
+
*/
|
|
171
|
+
function createDevToolsPanel(): HTMLElement {
|
|
172
|
+
const panel = document.createElement('div');
|
|
173
|
+
panel.id = 'hua-i18n-devtools';
|
|
174
|
+
panel.style.cssText = `
|
|
175
|
+
position: fixed;
|
|
176
|
+
top: 20px;
|
|
177
|
+
right: 20px;
|
|
178
|
+
width: 300px;
|
|
179
|
+
max-height: 500px;
|
|
180
|
+
background: #1e1e1e;
|
|
181
|
+
color: #fff;
|
|
182
|
+
border-radius: 8px;
|
|
183
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
184
|
+
z-index: 10000;
|
|
185
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
186
|
+
font-size: 12px;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
const header = document.createElement('div');
|
|
191
|
+
header.style.cssText = `
|
|
192
|
+
background: #2d2d2d;
|
|
193
|
+
padding: 12px;
|
|
194
|
+
border-bottom: 1px solid #444;
|
|
195
|
+
display: flex;
|
|
196
|
+
justify-content: space-between;
|
|
197
|
+
align-items: center;
|
|
198
|
+
`;
|
|
199
|
+
header.innerHTML = `
|
|
200
|
+
<span style="font-weight: bold;">HUA i18n Debug</span>
|
|
201
|
+
<button id="close-devtools" style="background: none; border: none; color: #fff; cursor: pointer; font-size: 16px;">×</button>
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
const content = document.createElement('div');
|
|
205
|
+
content.style.cssText = `
|
|
206
|
+
padding: 12px;
|
|
207
|
+
max-height: 400px;
|
|
208
|
+
overflow-y: auto;
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
// 성능 메트릭
|
|
212
|
+
const metricsSection = document.createElement('div');
|
|
213
|
+
metricsSection.innerHTML = `
|
|
214
|
+
<h4 style="margin: 0 0 8px 0; color: #4fc3f7;">Performance</h4>
|
|
215
|
+
<div>Translations: <span id="translation-count">0</span></div>
|
|
216
|
+
<div>Cache Hits: <span id="cache-hits">0</span></div>
|
|
217
|
+
<div>Cache Misses: <span id="cache-misses">0</span></div>
|
|
218
|
+
<div>Hit Rate: <span id="hit-rate">0%</span></div>
|
|
219
|
+
`;
|
|
220
|
+
|
|
221
|
+
// 현재 언어 정보
|
|
222
|
+
const languageSection = document.createElement('div');
|
|
223
|
+
languageSection.style.marginTop = '16px';
|
|
224
|
+
languageSection.innerHTML = `
|
|
225
|
+
<h4 style="margin: 0 0 8px 0; color: #4fc3f7;">Current Language</h4>
|
|
226
|
+
<div>Language: <span id="current-language">ko</span></div>
|
|
227
|
+
<div>Fallback: <span id="fallback-language">en</span></div>
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
// 액션 버튼들
|
|
231
|
+
const actionsSection = document.createElement('div');
|
|
232
|
+
actionsSection.style.marginTop = '16px';
|
|
233
|
+
actionsSection.innerHTML = `
|
|
234
|
+
<h4 style="margin: 0 0 8px 0; color: #4fc3f7;">Actions</h4>
|
|
235
|
+
<button id="highlight-missing" style="background: #4fc3f7; border: none; color: #000; padding: 4px 8px; border-radius: 4px; cursor: pointer; margin-right: 8px;">Highlight Missing</button>
|
|
236
|
+
<button id="show-keys" style="background: #4fc3f7; border: none; color: #000; padding: 4px 8px; border-radius: 4px; cursor: pointer;">Show Keys</button>
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
content.appendChild(metricsSection);
|
|
240
|
+
content.appendChild(languageSection);
|
|
241
|
+
content.appendChild(actionsSection);
|
|
242
|
+
|
|
243
|
+
panel.appendChild(header);
|
|
244
|
+
panel.appendChild(content);
|
|
245
|
+
|
|
246
|
+
// 이벤트 리스너
|
|
247
|
+
panel.querySelector('#close-devtools')?.addEventListener('click', () => {
|
|
248
|
+
panel.remove();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
panel.querySelector('#highlight-missing')?.addEventListener('click', () => {
|
|
252
|
+
const debugTools = createDebugTools();
|
|
253
|
+
if (debugTools) {
|
|
254
|
+
debugTools.highlightMissingKeys(document.body);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
panel.querySelector('#show-keys')?.addEventListener('click', () => {
|
|
259
|
+
const debugTools = createDebugTools();
|
|
260
|
+
if (debugTools) {
|
|
261
|
+
debugTools.showTranslationKeys(document.body);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return panel;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 디버깅 도구 활성화 (전역 함수)
|
|
270
|
+
*/
|
|
271
|
+
export function enableDebugTools(): void {
|
|
272
|
+
if (process.env.NODE_ENV === 'production') {
|
|
273
|
+
console.warn('Debug tools are not available in production');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const debugTools = createDebugTools();
|
|
278
|
+
if (!debugTools) return;
|
|
279
|
+
|
|
280
|
+
// 전역 객체에 추가
|
|
281
|
+
(window as Window & { __HUA_I18N_DEBUG__?: DebugTools }).__HUA_I18N_DEBUG__ = debugTools;
|
|
282
|
+
|
|
283
|
+
// 개발자 도구 열기
|
|
284
|
+
debugTools.devTools.open();
|
|
285
|
+
|
|
286
|
+
console.log('HUA i18n debug tools enabled. Use window.__HUA_I18N_DEBUG__ to access.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 디버깅 도구 비활성화
|
|
291
|
+
*/
|
|
292
|
+
export function disableDebugTools(): void {
|
|
293
|
+
const debugTools = (window as Window & { __HUA_I18N_DEBUG__?: DebugTools }).__HUA_I18N_DEBUG__;
|
|
294
|
+
if (debugTools) {
|
|
295
|
+
debugTools.devTools.close();
|
|
296
|
+
delete (window as Window & { __HUA_I18N_DEBUG__?: DebugTools }).__HUA_I18N_DEBUG__;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { TranslationNamespace, TranslationData } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* i18n 리소스 관리자
|
|
5
|
+
* SSR 환경에서 동일 번역 요청 중복 방지 및 전역 캐시 관리
|
|
6
|
+
*/
|
|
7
|
+
export class I18nResourceManager {
|
|
8
|
+
private static instance: I18nResourceManager;
|
|
9
|
+
private globalCache = new Map<string, TranslationNamespace>();
|
|
10
|
+
private loadingPromises = new Map<string, Promise<TranslationNamespace>>();
|
|
11
|
+
private cacheStats = {
|
|
12
|
+
hits: 0,
|
|
13
|
+
misses: 0,
|
|
14
|
+
size: 0
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
private constructor() {}
|
|
18
|
+
|
|
19
|
+
static getInstance(): I18nResourceManager {
|
|
20
|
+
if (!I18nResourceManager.instance) {
|
|
21
|
+
I18nResourceManager.instance = new I18nResourceManager();
|
|
22
|
+
}
|
|
23
|
+
return I18nResourceManager.instance;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 전역 캐시에서 번역 데이터 가져오기
|
|
28
|
+
*/
|
|
29
|
+
async getCachedTranslations(
|
|
30
|
+
language: string,
|
|
31
|
+
namespace: string,
|
|
32
|
+
loader: (lang: string, ns: string) => Promise<TranslationNamespace>
|
|
33
|
+
): Promise<TranslationNamespace> {
|
|
34
|
+
const cacheKey = `${language}:${namespace}`;
|
|
35
|
+
|
|
36
|
+
// 캐시에 있으면 반환
|
|
37
|
+
if (this.globalCache.has(cacheKey)) {
|
|
38
|
+
this.cacheStats.hits++;
|
|
39
|
+
return this.globalCache.get(cacheKey)!;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 이미 로딩 중이면 기존 Promise 반환
|
|
43
|
+
if (this.loadingPromises.has(cacheKey)) {
|
|
44
|
+
return this.loadingPromises.get(cacheKey)!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 새로 로딩
|
|
48
|
+
this.cacheStats.misses++;
|
|
49
|
+
const loadPromise = loader(language, namespace).then(data => {
|
|
50
|
+
this.globalCache.set(cacheKey, data);
|
|
51
|
+
this.cacheStats.size = this.globalCache.size;
|
|
52
|
+
this.loadingPromises.delete(cacheKey);
|
|
53
|
+
return data;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.loadingPromises.set(cacheKey, loadPromise);
|
|
57
|
+
return loadPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 캐시된 번역 데이터 직접 접근
|
|
62
|
+
*/
|
|
63
|
+
getCachedTranslationsSync(language: string, namespace: string): TranslationNamespace | null {
|
|
64
|
+
const cacheKey = `${language}:${namespace}`;
|
|
65
|
+
return this.globalCache.get(cacheKey) || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 특정 언어의 모든 네임스페이스 가져오기
|
|
70
|
+
*/
|
|
71
|
+
getAllTranslationsForLanguage(language: string): Record<string, TranslationNamespace> {
|
|
72
|
+
const result: Record<string, TranslationNamespace> = {};
|
|
73
|
+
|
|
74
|
+
for (const [key, data] of this.globalCache.entries()) {
|
|
75
|
+
if (key.startsWith(`${language}:`)) {
|
|
76
|
+
const namespace = key.split(':')[1];
|
|
77
|
+
result[namespace] = data;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 모든 캐시된 번역 데이터 가져오기
|
|
86
|
+
*/
|
|
87
|
+
getAllCachedTranslations(): Record<string, Record<string, TranslationNamespace>> {
|
|
88
|
+
const result: Record<string, Record<string, TranslationNamespace>> = {};
|
|
89
|
+
|
|
90
|
+
for (const [key, data] of this.globalCache.entries()) {
|
|
91
|
+
const [language, namespace] = key.split(':');
|
|
92
|
+
if (!result[language]) {
|
|
93
|
+
result[language] = {};
|
|
94
|
+
}
|
|
95
|
+
result[language][namespace] = data;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 캐시 통계 가져오기
|
|
103
|
+
*/
|
|
104
|
+
getCacheStats() {
|
|
105
|
+
return {
|
|
106
|
+
...this.cacheStats,
|
|
107
|
+
hitRate: this.cacheStats.hits / (this.cacheStats.hits + this.cacheStats.misses)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 캐시 무효화
|
|
113
|
+
*/
|
|
114
|
+
invalidateCache(language?: string, namespace?: string): void {
|
|
115
|
+
if (language && namespace) {
|
|
116
|
+
// 특정 언어/네임스페이스만 무효화
|
|
117
|
+
const cacheKey = `${language}:${namespace}`;
|
|
118
|
+
this.globalCache.delete(cacheKey);
|
|
119
|
+
this.loadingPromises.delete(cacheKey);
|
|
120
|
+
} else if (language) {
|
|
121
|
+
// 특정 언어의 모든 네임스페이스 무효화
|
|
122
|
+
for (const key of this.globalCache.keys()) {
|
|
123
|
+
if (key.startsWith(`${language}:`)) {
|
|
124
|
+
this.globalCache.delete(key);
|
|
125
|
+
this.loadingPromises.delete(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
// 전체 캐시 무효화
|
|
130
|
+
this.globalCache.clear();
|
|
131
|
+
this.loadingPromises.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.cacheStats.size = this.globalCache.size;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 캐시 크기 제한 설정
|
|
139
|
+
*/
|
|
140
|
+
setCacheLimit(maxSize: number): void {
|
|
141
|
+
if (this.globalCache.size > maxSize) {
|
|
142
|
+
// LRU 방식으로 오래된 항목 제거
|
|
143
|
+
const entries = Array.from(this.globalCache.entries());
|
|
144
|
+
const toRemove = entries.slice(0, this.globalCache.size - maxSize);
|
|
145
|
+
|
|
146
|
+
for (const [key] of toRemove) {
|
|
147
|
+
this.globalCache.delete(key);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.cacheStats.size = this.globalCache.size;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* SSR 환경에서 하이드레이션
|
|
156
|
+
*/
|
|
157
|
+
hydrateFromSSR(translations: Record<string, Record<string, TranslationNamespace>>): void {
|
|
158
|
+
for (const [language, namespaces] of Object.entries(translations)) {
|
|
159
|
+
for (const [namespace, data] of Object.entries(namespaces)) {
|
|
160
|
+
const cacheKey = `${language}:${namespace}`;
|
|
161
|
+
this.globalCache.set(cacheKey, data);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.cacheStats.size = this.globalCache.size;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 메모리 사용량 최적화
|
|
170
|
+
*/
|
|
171
|
+
optimizeMemory(): void {
|
|
172
|
+
// 사용되지 않는 번역 데이터 정리
|
|
173
|
+
// 실제 구현에서는 사용 통계를 기반으로 정리
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 전역 리소스 매니저 인스턴스
|
|
179
|
+
*/
|
|
180
|
+
export const i18nResourceManager = I18nResourceManager.getInstance();
|