@hua-labs/i18n-loaders 1.0.0 → 1.1.0-alpha.0.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 +20 -20
- package/README.md +415 -415
- package/dist/api-loader.d.ts +24 -1
- package/dist/api-loader.d.ts.map +1 -1
- package/dist/api-loader.js +78 -1
- package/dist/api-loader.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/api-loader.ts +281 -199
- package/src/defaults.ts +63 -63
- package/src/index.ts +13 -12
- package/src/preload.ts +64 -64
- package/src/types.ts +56 -34
package/src/api-loader.ts
CHANGED
|
@@ -1,199 +1,281 @@
|
|
|
1
|
-
import { ApiLoaderOptions, TranslationLoader, TranslationRecord } from './types';
|
|
2
|
-
|
|
3
|
-
interface CacheEntry {
|
|
4
|
-
data: TranslationRecord;
|
|
5
|
-
expiresAt: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
9
|
-
|
|
10
|
-
const defaultFetcher = (input: RequestInfo | URL, init?: RequestInit) =>
|
|
11
|
-
fetch(input, init);
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 재시도 가능한 에러인지 확인
|
|
15
|
-
*/
|
|
16
|
-
function isRetryableError(error: unknown): boolean {
|
|
17
|
-
// 네트워크 에러 (TypeError)
|
|
18
|
-
if (error instanceof TypeError) {
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Fetch API 에러 메시지 확인
|
|
23
|
-
if (error instanceof Error) {
|
|
24
|
-
const message = error.message.toLowerCase();
|
|
25
|
-
if (
|
|
26
|
-
message.includes('failed to fetch') ||
|
|
27
|
-
message.includes('networkerror') ||
|
|
28
|
-
message.includes('network request failed')
|
|
29
|
-
) {
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Response 객체가 있는 경우 HTTP 상태 코드 확인
|
|
35
|
-
if (error && typeof error === 'object' && 'status' in error) {
|
|
36
|
-
const status = (error as { status: number }).status;
|
|
37
|
-
// 5xx 서버 에러는 재시도 가능
|
|
38
|
-
if (status >= 500 && status < 600) {
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
// 408 Request Timeout도 재시도 가능
|
|
42
|
-
if (status === 408) {
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
1
|
+
import { ApiLoaderOptions, TranslationLoader, TranslationRecord } from './types';
|
|
2
|
+
|
|
3
|
+
interface CacheEntry {
|
|
4
|
+
data: TranslationRecord;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
const defaultFetcher = (input: RequestInfo | URL, init?: RequestInit) =>
|
|
11
|
+
fetch(input, init);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 재시도 가능한 에러인지 확인
|
|
15
|
+
*/
|
|
16
|
+
function isRetryableError(error: unknown): boolean {
|
|
17
|
+
// 네트워크 에러 (TypeError)
|
|
18
|
+
if (error instanceof TypeError) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fetch API 에러 메시지 확인
|
|
23
|
+
if (error instanceof Error) {
|
|
24
|
+
const message = error.message.toLowerCase();
|
|
25
|
+
if (
|
|
26
|
+
message.includes('failed to fetch') ||
|
|
27
|
+
message.includes('networkerror') ||
|
|
28
|
+
message.includes('network request failed')
|
|
29
|
+
) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Response 객체가 있는 경우 HTTP 상태 코드 확인
|
|
35
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
36
|
+
const status = (error as { status: number }).status;
|
|
37
|
+
// 5xx 서버 에러는 재시도 가능
|
|
38
|
+
if (status >= 500 && status < 600) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
// 408 Request Timeout도 재시도 가능
|
|
42
|
+
if (status === 408) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* API 기반 번역 로더 생성
|
|
52
|
+
*
|
|
53
|
+
* @param options - 로더 옵션
|
|
54
|
+
* @returns 번역 로더 및 캐시 무효화 함수
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* const { loader, invalidate, clear } = createApiTranslationLoader({
|
|
59
|
+
* translationApiPath: '/api/translations',
|
|
60
|
+
* autoInvalidateInDev: true
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // 특정 언어/네임스페이스 무효화
|
|
64
|
+
* invalidate('ko', 'common');
|
|
65
|
+
*
|
|
66
|
+
* // 전체 캐시 클리어
|
|
67
|
+
* clear();
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function createApiTranslationLoader(
|
|
71
|
+
options: ApiLoaderOptions = {}
|
|
72
|
+
): TranslationLoader & { invalidate: (language?: string, namespace?: string) => void; clear: () => void } {
|
|
73
|
+
const translationApiPath = options.translationApiPath ?? '/api/translations';
|
|
74
|
+
const cacheTtlMs = options.cacheTtlMs ?? FIVE_MINUTES;
|
|
75
|
+
const fetcher = options.fetcher ?? defaultFetcher;
|
|
76
|
+
const logger = options.logger ?? console;
|
|
77
|
+
const retryCount = options.retryCount ?? 0;
|
|
78
|
+
const retryDelay = options.retryDelay ?? 1000;
|
|
79
|
+
const autoInvalidateInDev = options.autoInvalidateInDev ?? (typeof process !== 'undefined' && process.env.NODE_ENV === 'development');
|
|
80
|
+
const localCache = new Map<string, CacheEntry>();
|
|
81
|
+
const inFlightRequests = new Map<string, Promise<TranslationRecord>>();
|
|
82
|
+
|
|
83
|
+
const buildUrl = (language: string, namespace: string) => {
|
|
84
|
+
const safeNamespace = namespace.replace(/[^a-zA-Z0-9-_]/g, '');
|
|
85
|
+
const path = `${translationApiPath}/${language}/${safeNamespace}`;
|
|
86
|
+
|
|
87
|
+
if (typeof window !== 'undefined') {
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.baseUrl) {
|
|
92
|
+
return `${options.baseUrl}${path}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (process.env.NEXT_PUBLIC_SITE_URL) {
|
|
96
|
+
return `${process.env.NEXT_PUBLIC_SITE_URL}${path}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (process.env.VERCEL_URL) {
|
|
100
|
+
const vercelUrl = process.env.VERCEL_URL.startsWith('http')
|
|
101
|
+
? process.env.VERCEL_URL
|
|
102
|
+
: `https://${process.env.VERCEL_URL}`;
|
|
103
|
+
return `${vercelUrl}${path}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fallbackBase = options.localFallbackBaseUrl ?? 'http://localhost:3000';
|
|
107
|
+
return `${fallbackBase}${path}`;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const getRequestInit = (language: string, namespace: string): RequestInit => {
|
|
111
|
+
if (typeof options.requestInit === 'function') {
|
|
112
|
+
return options.requestInit(language, namespace) ?? {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return options.requestInit ?? {};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const getCached = (cacheKey: string) => {
|
|
119
|
+
if (options.disableCache) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const entry = localCache.get(cacheKey);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (entry.expiresAt < Date.now()) {
|
|
129
|
+
localCache.delete(cacheKey);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return entry.data;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const setCached = (cacheKey: string, data: TranslationRecord) => {
|
|
137
|
+
if (options.disableCache) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
localCache.set(cacheKey, {
|
|
142
|
+
data,
|
|
143
|
+
expiresAt: Date.now() + cacheTtlMs
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const loadTranslations: TranslationLoader = async (language, namespace) => {
|
|
148
|
+
const cacheKey = `${language}:${namespace}`;
|
|
149
|
+
const cached = getCached(cacheKey);
|
|
150
|
+
|
|
151
|
+
if (cached) {
|
|
152
|
+
return cached;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const inFlight = inFlightRequests.get(cacheKey);
|
|
156
|
+
if (inFlight) {
|
|
157
|
+
return inFlight;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const url = buildUrl(language, namespace);
|
|
161
|
+
const requestInit = getRequestInit(language, namespace);
|
|
162
|
+
|
|
163
|
+
const performRequest = async (attempt: number): Promise<TranslationRecord> => {
|
|
164
|
+
try {
|
|
165
|
+
const response = await fetcher(url, {
|
|
166
|
+
cache: 'no-store',
|
|
167
|
+
...requestInit
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`[i18n-loaders] Failed to load ${language}/${namespace} (${response.status})`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const data = (await response.json()) as TranslationRecord;
|
|
177
|
+
setCached(cacheKey, data);
|
|
178
|
+
return data;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// 재시도 가능한 에러인지 확인
|
|
181
|
+
const isRetryable = isRetryableError(error);
|
|
182
|
+
|
|
183
|
+
if (isRetryable && attempt < retryCount) {
|
|
184
|
+
logger.warn?.(
|
|
185
|
+
`[i18n-loaders] Translation fetch failed (attempt ${attempt + 1}/${retryCount + 1}), retrying...`,
|
|
186
|
+
language,
|
|
187
|
+
namespace,
|
|
188
|
+
error
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// 지수 백오프: 각 재시도마다 지연 시간 증가
|
|
192
|
+
const delay = retryDelay * Math.pow(2, attempt);
|
|
193
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
194
|
+
|
|
195
|
+
return performRequest(attempt + 1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 재시도 불가능하거나 재시도 횟수 초과
|
|
199
|
+
logger.warn?.(
|
|
200
|
+
'[i18n-loaders] translation fetch failed',
|
|
201
|
+
language,
|
|
202
|
+
namespace,
|
|
203
|
+
error
|
|
204
|
+
);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const requestPromise = performRequest(0)
|
|
210
|
+
.finally(() => {
|
|
211
|
+
inFlightRequests.delete(cacheKey);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
inFlightRequests.set(cacheKey, requestPromise);
|
|
215
|
+
return requestPromise;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 특정 언어/네임스페이스의 캐시 무효화
|
|
220
|
+
*/
|
|
221
|
+
const invalidate = (language?: string, namespace?: string) => {
|
|
222
|
+
if (language && namespace) {
|
|
223
|
+
// 특정 언어/네임스페이스만 무효화
|
|
224
|
+
const cacheKey = `${language}:${namespace}`;
|
|
225
|
+
localCache.delete(cacheKey);
|
|
226
|
+
logger.log?.('[i18n-loaders] Cache invalidated:', cacheKey);
|
|
227
|
+
} else if (language) {
|
|
228
|
+
// 특정 언어의 모든 네임스페이스 무효화
|
|
229
|
+
const prefix = `${language}:`;
|
|
230
|
+
for (const key of localCache.keys()) {
|
|
231
|
+
if (key.startsWith(prefix)) {
|
|
232
|
+
localCache.delete(key);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
logger.log?.('[i18n-loaders] Cache invalidated for language:', language);
|
|
236
|
+
} else if (namespace) {
|
|
237
|
+
// 특정 네임스페이스의 모든 언어 무효화
|
|
238
|
+
const suffix = `:${namespace}`;
|
|
239
|
+
for (const key of localCache.keys()) {
|
|
240
|
+
if (key.endsWith(suffix)) {
|
|
241
|
+
localCache.delete(key);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
logger.log?.('[i18n-loaders] Cache invalidated for namespace:', namespace);
|
|
245
|
+
} else {
|
|
246
|
+
// 전체 캐시 무효화
|
|
247
|
+
clear();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 전체 캐시 클리어
|
|
253
|
+
*/
|
|
254
|
+
const clear = () => {
|
|
255
|
+
localCache.clear();
|
|
256
|
+
logger.log?.('[i18n-loaders] Cache cleared');
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// 개발 모드에서 자동 무효화 설정
|
|
260
|
+
if (autoInvalidateInDev && typeof window !== 'undefined') {
|
|
261
|
+
// 개발 모드에서 페이지 포커스 시 캐시 무효화 (번역 파일 변경 감지)
|
|
262
|
+
window.addEventListener('focus', () => {
|
|
263
|
+
if (process.env.NODE_ENV === 'development') {
|
|
264
|
+
logger.log?.('[i18n-loaders] Development mode: Auto-invalidating cache on focus');
|
|
265
|
+
clear();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// loadTranslations 함수에 invalidate와 clear 메서드 추가
|
|
271
|
+
const loader = loadTranslations as TranslationLoader & {
|
|
272
|
+
invalidate: (language?: string, namespace?: string) => void;
|
|
273
|
+
clear: () => void;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
loader.invalidate = invalidate;
|
|
277
|
+
loader.clear = clear;
|
|
278
|
+
|
|
279
|
+
return loader;
|
|
280
|
+
}
|
|
281
|
+
|
package/src/defaults.ts
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DefaultTranslations,
|
|
3
|
-
TranslationLoader,
|
|
4
|
-
TranslationRecord
|
|
5
|
-
} from './types';
|
|
6
|
-
|
|
7
|
-
export function withDefaultTranslations(
|
|
8
|
-
loader: TranslationLoader,
|
|
9
|
-
defaults: DefaultTranslations
|
|
10
|
-
): TranslationLoader {
|
|
11
|
-
return async (language, namespace) => {
|
|
12
|
-
const fallback = defaults[language]?.[namespace];
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const remote = await loader(language, namespace);
|
|
16
|
-
|
|
17
|
-
// API 응답이 빈 객체이거나 null/undefined인 경우 fallback 반환
|
|
18
|
-
if (!remote || (typeof remote === 'object' && Object.keys(remote).length === 0)) {
|
|
19
|
-
if (fallback) {
|
|
20
|
-
return fallback;
|
|
21
|
-
}
|
|
22
|
-
return remote || {};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (!fallback) {
|
|
26
|
-
return remote;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return mergeTranslations(fallback, remote);
|
|
30
|
-
} catch (error) {
|
|
31
|
-
if (fallback) {
|
|
32
|
-
return fallback;
|
|
33
|
-
}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function mergeTranslations(
|
|
40
|
-
base: TranslationRecord,
|
|
41
|
-
override: TranslationRecord
|
|
42
|
-
): TranslationRecord {
|
|
43
|
-
const result: TranslationRecord = { ...base };
|
|
44
|
-
|
|
45
|
-
for (const [key, value] of Object.entries(override)) {
|
|
46
|
-
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
47
|
-
result[key] = mergeTranslations(
|
|
48
|
-
result[key] as TranslationRecord,
|
|
49
|
-
value as TranslationRecord
|
|
50
|
-
);
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
result[key] = value;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return result;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isPlainObject(value: unknown): value is TranslationRecord {
|
|
61
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
62
|
-
}
|
|
63
|
-
|
|
1
|
+
import {
|
|
2
|
+
DefaultTranslations,
|
|
3
|
+
TranslationLoader,
|
|
4
|
+
TranslationRecord
|
|
5
|
+
} from './types';
|
|
6
|
+
|
|
7
|
+
export function withDefaultTranslations(
|
|
8
|
+
loader: TranslationLoader,
|
|
9
|
+
defaults: DefaultTranslations
|
|
10
|
+
): TranslationLoader {
|
|
11
|
+
return async (language, namespace) => {
|
|
12
|
+
const fallback = defaults[language]?.[namespace];
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const remote = await loader(language, namespace);
|
|
16
|
+
|
|
17
|
+
// API 응답이 빈 객체이거나 null/undefined인 경우 fallback 반환
|
|
18
|
+
if (!remote || (typeof remote === 'object' && Object.keys(remote).length === 0)) {
|
|
19
|
+
if (fallback) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
return remote || {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!fallback) {
|
|
26
|
+
return remote;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return mergeTranslations(fallback, remote);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (fallback) {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mergeTranslations(
|
|
40
|
+
base: TranslationRecord,
|
|
41
|
+
override: TranslationRecord
|
|
42
|
+
): TranslationRecord {
|
|
43
|
+
const result: TranslationRecord = { ...base };
|
|
44
|
+
|
|
45
|
+
for (const [key, value] of Object.entries(override)) {
|
|
46
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
47
|
+
result[key] = mergeTranslations(
|
|
48
|
+
result[key] as TranslationRecord,
|
|
49
|
+
value as TranslationRecord
|
|
50
|
+
);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
result[key] = value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPlainObject(value: unknown): value is TranslationRecord {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
export { createApiTranslationLoader } from './api-loader';
|
|
2
|
-
export { preloadNamespaces, warmFallbackLanguages } from './preload';
|
|
3
|
-
export { withDefaultTranslations } from './defaults';
|
|
4
|
-
|
|
5
|
-
export type {
|
|
6
|
-
ApiLoaderOptions,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
export { createApiTranslationLoader } from './api-loader';
|
|
2
|
+
export { preloadNamespaces, warmFallbackLanguages } from './preload';
|
|
3
|
+
export { withDefaultTranslations } from './defaults';
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
ApiLoaderOptions,
|
|
7
|
+
CacheInvalidation,
|
|
8
|
+
DefaultTranslations,
|
|
9
|
+
PreloadOptions,
|
|
10
|
+
TranslationLoader,
|
|
11
|
+
TranslationRecord
|
|
12
|
+
} from './types';
|
|
13
|
+
|