@hua-labs/i18n-loaders 1.1.1 → 2.0.2
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 +35 -50
- package/package.json +13 -8
- package/src/__tests__/api-loader.test.ts +610 -0
- package/src/__tests__/defaults.test.ts +327 -0
- package/src/__tests__/preload.test.ts +265 -0
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -1,93 +1,78 @@
|
|
|
1
1
|
# @hua-labs/i18n-loaders
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
캐싱 및 프리로딩 기능을 갖춘 번역 로더.
|
|
3
|
+
Production-ready translation loaders with built-in TTL caching, duplicate request prevention, and namespace preloading. Designed to work seamlessly with @hua-labs/i18n-core. Supports both server and client environments.
|
|
5
4
|
|
|
6
5
|
[](https://www.npmjs.com/package/@hua-labs/i18n-loaders)
|
|
7
|
-
[](https://www.npmjs.com/package/@hua-labs/i18n-loaders)
|
|
8
7
|
[](https://github.com/HUA-Labs/HUA-Labs-public/blob/main/LICENSE)
|
|
9
8
|
[](https://www.typescriptlang.org/)
|
|
10
9
|
[](https://reactjs.org/)
|
|
11
10
|
|
|
12
|
-
> **Alpha**: APIs may change before stable release. | **알파**: 안정 릴리스 전 API가 변경될 수 있습니다.
|
|
13
|
-
|
|
14
|
-
## Overview | 개요
|
|
15
|
-
|
|
16
|
-
Production-ready translation loaders with built-in TTL caching, duplicate request prevention, and namespace preloading. Designed to work seamlessly with @hua-labs/i18n-core. Supports both server and client environments.
|
|
17
|
-
|
|
18
|
-
TTL 캐싱, 중복 요청 방지, 네임스페이스 프리로딩이 내장된 프로덕션 레디 번역 로더입니다. @hua-labs/i18n-core와 원활하게 작동하도록 설계되었습니다. 서버/클라이언트 환경 모두 지원합니다.
|
|
19
|
-
|
|
20
11
|
## Features
|
|
21
12
|
|
|
22
|
-
- **API loader
|
|
23
|
-
- **TTL caching
|
|
24
|
-
- **Duplicate prevention
|
|
25
|
-
- **Preloading
|
|
26
|
-
- **Default merging
|
|
13
|
+
- **API loader — createApiTranslationLoader with configurable endpoints**
|
|
14
|
+
- **TTL caching — Time-based cache with global cache support**
|
|
15
|
+
- **Duplicate prevention — Deduplicates concurrent requests for the same resource**
|
|
16
|
+
- **Preloading — Warm up namespaces and fallback languages at startup**
|
|
17
|
+
- **Default merging — Merge API translations with bundled defaults**
|
|
27
18
|
|
|
28
|
-
## Installation
|
|
19
|
+
## Installation
|
|
29
20
|
|
|
30
21
|
```bash
|
|
31
22
|
pnpm add @hua-labs/i18n-loaders
|
|
32
23
|
```
|
|
33
24
|
|
|
34
|
-
Peer
|
|
25
|
+
> Peer dependencies: react >=19.0.0
|
|
35
26
|
|
|
36
|
-
## Quick Start
|
|
27
|
+
## Quick Start
|
|
37
28
|
|
|
38
29
|
```tsx
|
|
39
30
|
import { createCoreI18n } from '@hua-labs/i18n-core';
|
|
40
31
|
import { createApiTranslationLoader, preloadNamespaces } from '@hua-labs/i18n-loaders';
|
|
41
32
|
|
|
42
|
-
const
|
|
33
|
+
const loader = createApiTranslationLoader({
|
|
43
34
|
translationApiPath: '/api/translations',
|
|
44
35
|
cacheTtlMs: 60_000,
|
|
45
|
-
|
|
36
|
+
retryCount: 2,
|
|
46
37
|
});
|
|
47
38
|
|
|
48
39
|
// Preload at startup
|
|
49
|
-
preloadNamespaces('ko', ['common', 'dashboard'],
|
|
40
|
+
preloadNamespaces('ko', ['common', 'dashboard'], loader);
|
|
50
41
|
|
|
51
42
|
// Use with i18n-core
|
|
52
43
|
const I18nProvider = createCoreI18n({
|
|
53
44
|
defaultLanguage: 'ko',
|
|
54
|
-
fallbackLanguage: 'en',
|
|
55
|
-
namespaces: ['common', 'dashboard'],
|
|
56
45
|
translationLoader: 'custom',
|
|
57
|
-
loadTranslations,
|
|
46
|
+
loadTranslations: loader,
|
|
58
47
|
});
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## API Overview | API 개요
|
|
62
|
-
|
|
63
|
-
| Function | Description |
|
|
64
|
-
|----------|-------------|
|
|
65
|
-
| `createApiTranslationLoader(config)` | Create an API-based translation loader |
|
|
66
|
-
| `preloadNamespaces(lang, namespaces, loader)` | Preload translation namespaces |
|
|
67
|
-
| `withDefaultTranslations(loader, defaults)` | Merge API results with bundled defaults |
|
|
68
48
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
| Option | Type | Default | Description |
|
|
72
|
-
|--------|------|---------|-------------|
|
|
73
|
-
| `translationApiPath` | `string` | — | API endpoint path |
|
|
74
|
-
| `cacheTtlMs` | `number` | `300000` | Cache TTL in ms |
|
|
75
|
-
| `enableGlobalCache` | `boolean` | `true` | Enable global cache |
|
|
49
|
+
```
|
|
76
50
|
|
|
77
|
-
##
|
|
51
|
+
## API
|
|
78
52
|
|
|
79
|
-
|
|
53
|
+
| Export | Type | Description |
|
|
54
|
+
|--------|------|-------------|
|
|
55
|
+
| `createApiTranslationLoader` | function | Create API-based loader with caching and retry |
|
|
56
|
+
| `preloadNamespaces` | function | Preload translation namespaces |
|
|
57
|
+
| `warmFallbackLanguages` | function | Warm up fallback language caches |
|
|
58
|
+
| `withDefaultTranslations` | function | Merge API results with bundled defaults |
|
|
59
|
+
| `ApiLoaderOptions` | type | |
|
|
60
|
+
| `CacheInvalidation` | type | |
|
|
61
|
+
| `DefaultTranslations` | type | |
|
|
62
|
+
| `PreloadOptions` | type | |
|
|
63
|
+
| `TranslationLoader` | type | |
|
|
64
|
+
| `TranslationRecord` | type | |
|
|
80
65
|
|
|
81
|
-
##
|
|
66
|
+
## Documentation
|
|
82
67
|
|
|
83
|
-
|
|
84
|
-
- [`@hua-labs/i18n-core-zustand`](https://www.npmjs.com/package/@hua-labs/i18n-core-zustand) — Zustand state adapter
|
|
85
|
-
- [`@hua-labs/i18n-formatters`](https://www.npmjs.com/package/@hua-labs/i18n-formatters) — Date, number, currency formatters
|
|
68
|
+
[Full Documentation](https://docs.hua-labs.com)
|
|
86
69
|
|
|
87
|
-
##
|
|
70
|
+
## Related Packages
|
|
88
71
|
|
|
89
|
-
|
|
72
|
+
- [`@hua-labs/i18n-core`](https://www.npmjs.com/package/@hua-labs/i18n-core)
|
|
73
|
+
- [`@hua-labs/i18n-core-zustand`](https://www.npmjs.com/package/@hua-labs/i18n-core-zustand)
|
|
74
|
+
- [`@hua-labs/i18n-formatters`](https://www.npmjs.com/package/@hua-labs/i18n-formatters)
|
|
90
75
|
|
|
91
76
|
## License
|
|
92
77
|
|
|
93
|
-
MIT — [HUA Labs](https://
|
|
78
|
+
MIT — [HUA Labs](https://hua-labs.com)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hua-labs/i18n-loaders",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Production-ready loaders, caching, and preloading helpers for @hua-labs/i18n-core.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -17,19 +17,23 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"sideEffects": false,
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20.0.0"
|
|
22
|
+
},
|
|
20
23
|
"dependencies": {
|
|
21
|
-
"@hua-labs/i18n-core": "
|
|
24
|
+
"@hua-labs/i18n-core": "latest"
|
|
22
25
|
},
|
|
23
26
|
"peerDependencies": {
|
|
24
27
|
"react": ">=19.0.0"
|
|
25
28
|
},
|
|
26
29
|
"devDependencies": {
|
|
27
|
-
"@types/node": "^25.0
|
|
28
|
-
"@types/react": "^19.2.
|
|
29
|
-
"eslint": "^
|
|
30
|
-
"react": "^19.2.
|
|
30
|
+
"@types/node": "^25.2.0",
|
|
31
|
+
"@types/react": "^19.2.10",
|
|
32
|
+
"eslint": "^10.0.0",
|
|
33
|
+
"react": "^19.2.4",
|
|
31
34
|
"tsup": "^8.5.1",
|
|
32
|
-
"typescript": "^5.9.3"
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^3.2.1"
|
|
33
37
|
},
|
|
34
38
|
"keywords": [
|
|
35
39
|
"hua-labs",
|
|
@@ -60,7 +64,8 @@
|
|
|
60
64
|
"build": "tsup",
|
|
61
65
|
"dev": "tsup --watch",
|
|
62
66
|
"clean": "rm -rf dist",
|
|
63
|
-
"test": "
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:watch": "vitest",
|
|
64
69
|
"lint": "echo 'Skipping lint for hua-i18n-loaders (TypeScript only, linted by tsc)'",
|
|
65
70
|
"type-check": "tsc --noEmit"
|
|
66
71
|
}
|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createApiTranslationLoader } from '../api-loader';
|
|
3
|
+
import type { TranslationRecord } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('createApiTranslationLoader', () => {
|
|
6
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockFetch = vi.fn();
|
|
10
|
+
global.fetch = mockFetch;
|
|
11
|
+
vi.useFakeTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('Basic functionality', () => {
|
|
20
|
+
it('should fetch and return translations', async () => {
|
|
21
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
22
|
+
mockFetch.mockResolvedValueOnce({
|
|
23
|
+
ok: true,
|
|
24
|
+
json: async () => translations,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const loader = createApiTranslationLoader({
|
|
28
|
+
baseUrl: 'https://example.com',
|
|
29
|
+
translationApiPath: '/api/translations',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const result = await loader('en', 'common');
|
|
33
|
+
|
|
34
|
+
expect(result).toEqual(translations);
|
|
35
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
36
|
+
'https://example.com/api/translations/en/common',
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
cache: 'no-store',
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should throw error on failed fetch', async () => {
|
|
44
|
+
mockFetch.mockResolvedValueOnce({
|
|
45
|
+
ok: false,
|
|
46
|
+
status: 404,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const loader = createApiTranslationLoader();
|
|
50
|
+
|
|
51
|
+
await expect(loader('en', 'common')).rejects.toThrow(
|
|
52
|
+
'[i18n-loaders] Failed to load en/common (404)'
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Caching', () => {
|
|
58
|
+
it('should return cached data on subsequent requests', async () => {
|
|
59
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
60
|
+
mockFetch.mockResolvedValueOnce({
|
|
61
|
+
ok: true,
|
|
62
|
+
json: async () => translations,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const loader = createApiTranslationLoader({
|
|
66
|
+
cacheTtlMs: 5 * 60 * 1000,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result1 = await loader('en', 'common');
|
|
70
|
+
const result2 = await loader('en', 'common');
|
|
71
|
+
|
|
72
|
+
expect(result1).toEqual(translations);
|
|
73
|
+
expect(result2).toEqual(translations);
|
|
74
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should refetch after cache TTL expires', async () => {
|
|
78
|
+
const translations1: TranslationRecord = { hello: 'world' };
|
|
79
|
+
const translations2: TranslationRecord = { hello: 'updated' };
|
|
80
|
+
|
|
81
|
+
mockFetch
|
|
82
|
+
.mockResolvedValueOnce({
|
|
83
|
+
ok: true,
|
|
84
|
+
json: async () => translations1,
|
|
85
|
+
})
|
|
86
|
+
.mockResolvedValueOnce({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: async () => translations2,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const loader = createApiTranslationLoader({
|
|
92
|
+
cacheTtlMs: 1000,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result1 = await loader('en', 'common');
|
|
96
|
+
expect(result1).toEqual(translations1);
|
|
97
|
+
|
|
98
|
+
// Advance time past TTL
|
|
99
|
+
vi.advanceTimersByTime(1001);
|
|
100
|
+
|
|
101
|
+
const result2 = await loader('en', 'common');
|
|
102
|
+
expect(result2).toEqual(translations2);
|
|
103
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should skip cache when disableCache is true', async () => {
|
|
107
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
108
|
+
mockFetch.mockResolvedValue({
|
|
109
|
+
ok: true,
|
|
110
|
+
json: async () => translations,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const loader = createApiTranslationLoader({
|
|
114
|
+
disableCache: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await loader('en', 'common');
|
|
118
|
+
await loader('en', 'common');
|
|
119
|
+
|
|
120
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('In-flight request deduplication', () => {
|
|
125
|
+
it('should deduplicate concurrent requests', async () => {
|
|
126
|
+
vi.useRealTimers(); // Use real timers for this test
|
|
127
|
+
|
|
128
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
129
|
+
mockFetch.mockImplementation(
|
|
130
|
+
() =>
|
|
131
|
+
new Promise((resolve) => {
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
resolve({
|
|
134
|
+
ok: true,
|
|
135
|
+
json: async () => translations,
|
|
136
|
+
});
|
|
137
|
+
}, 10);
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const loader = createApiTranslationLoader({
|
|
142
|
+
baseUrl: 'https://example.com',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const [result1, result2, result3] = await Promise.all([
|
|
146
|
+
loader('en', 'common'),
|
|
147
|
+
loader('en', 'common'),
|
|
148
|
+
loader('en', 'common'),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
expect(result1).toEqual(translations);
|
|
152
|
+
expect(result2).toEqual(translations);
|
|
153
|
+
expect(result3).toEqual(translations);
|
|
154
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
155
|
+
|
|
156
|
+
vi.useFakeTimers(); // Restore fake timers
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should not deduplicate different language/namespace combinations', async () => {
|
|
160
|
+
const translations1: TranslationRecord = { hello: 'world' };
|
|
161
|
+
const translations2: TranslationRecord = { hello: 'mundo' };
|
|
162
|
+
|
|
163
|
+
mockFetch.mockImplementation((url) => {
|
|
164
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
165
|
+
if (urlStr.includes('/en/')) {
|
|
166
|
+
return Promise.resolve({
|
|
167
|
+
ok: true,
|
|
168
|
+
json: async () => translations1,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return Promise.resolve({
|
|
172
|
+
ok: true,
|
|
173
|
+
json: async () => translations2,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const loader = createApiTranslationLoader({
|
|
178
|
+
baseUrl: 'https://example.com',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const [result1, result2] = await Promise.all([
|
|
182
|
+
loader('en', 'common'),
|
|
183
|
+
loader('es', 'common'),
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
expect(result1).toEqual(translations1);
|
|
187
|
+
expect(result2).toEqual(translations2);
|
|
188
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('Retry logic', () => {
|
|
193
|
+
it('should retry on 5xx errors with exponential backoff', async () => {
|
|
194
|
+
vi.useRealTimers(); // Use real timers for retry test
|
|
195
|
+
|
|
196
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
197
|
+
let callCount = 0;
|
|
198
|
+
|
|
199
|
+
mockFetch.mockImplementation(async () => {
|
|
200
|
+
callCount++;
|
|
201
|
+
if (callCount < 3) {
|
|
202
|
+
const error = new Error('Internal Server Error') as Error & { status: number };
|
|
203
|
+
error.status = 500;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
json: async () => translations,
|
|
209
|
+
} as Response;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const loader = createApiTranslationLoader({
|
|
213
|
+
baseUrl: 'https://example.com',
|
|
214
|
+
retryCount: 2,
|
|
215
|
+
retryDelay: 10, // Short delay for faster test
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const result = await loader('en', 'common');
|
|
219
|
+
|
|
220
|
+
expect(result).toEqual(translations);
|
|
221
|
+
expect(callCount).toBe(3);
|
|
222
|
+
|
|
223
|
+
vi.useFakeTimers(); // Restore fake timers
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should retry on network errors', async () => {
|
|
227
|
+
vi.useRealTimers(); // Use real timers for retry test
|
|
228
|
+
|
|
229
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
230
|
+
let callCount = 0;
|
|
231
|
+
|
|
232
|
+
mockFetch.mockImplementation(() => {
|
|
233
|
+
callCount++;
|
|
234
|
+
if (callCount === 1) {
|
|
235
|
+
return Promise.reject(new TypeError('Failed to fetch'));
|
|
236
|
+
}
|
|
237
|
+
return Promise.resolve({
|
|
238
|
+
ok: true,
|
|
239
|
+
json: async () => translations,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const loader = createApiTranslationLoader({
|
|
244
|
+
baseUrl: 'https://example.com',
|
|
245
|
+
retryCount: 1,
|
|
246
|
+
retryDelay: 10, // Short delay for faster test
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const result = await loader('en', 'common');
|
|
250
|
+
|
|
251
|
+
expect(result).toEqual(translations);
|
|
252
|
+
expect(callCount).toBe(2);
|
|
253
|
+
|
|
254
|
+
vi.useFakeTimers(); // Restore fake timers
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should not retry on 4xx errors', async () => {
|
|
258
|
+
mockFetch.mockResolvedValue({
|
|
259
|
+
ok: false,
|
|
260
|
+
status: 404,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const loader = createApiTranslationLoader({
|
|
264
|
+
retryCount: 2,
|
|
265
|
+
retryDelay: 100,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await expect(loader('en', 'common')).rejects.toThrow();
|
|
269
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should throw error after max retries exceeded', async () => {
|
|
273
|
+
vi.useRealTimers(); // Use real timers for retry test
|
|
274
|
+
|
|
275
|
+
let callCount = 0;
|
|
276
|
+
mockFetch.mockImplementation(async () => {
|
|
277
|
+
callCount++;
|
|
278
|
+
const error = new Error('Internal Server Error') as Error & { status: number };
|
|
279
|
+
error.status = 500;
|
|
280
|
+
throw error;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const logger = {
|
|
284
|
+
log: vi.fn(),
|
|
285
|
+
warn: vi.fn(),
|
|
286
|
+
error: vi.fn(),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const loader = createApiTranslationLoader({
|
|
290
|
+
baseUrl: 'https://example.com',
|
|
291
|
+
retryCount: 2,
|
|
292
|
+
retryDelay: 10, // Short delay for faster test
|
|
293
|
+
logger,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await expect(loader('en', 'common')).rejects.toThrow();
|
|
297
|
+
expect(callCount).toBe(3); // Initial + 2 retries
|
|
298
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
299
|
+
|
|
300
|
+
vi.useFakeTimers(); // Restore fake timers
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('URL building', () => {
|
|
305
|
+
it('should use baseUrl in server environment', async () => {
|
|
306
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
307
|
+
mockFetch.mockResolvedValueOnce({
|
|
308
|
+
ok: true,
|
|
309
|
+
json: async () => translations,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const loader = createApiTranslationLoader({
|
|
313
|
+
baseUrl: 'https://example.com',
|
|
314
|
+
translationApiPath: '/api/translations',
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await loader('en', 'common');
|
|
318
|
+
|
|
319
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
320
|
+
'https://example.com/api/translations/en/common',
|
|
321
|
+
expect.any(Object)
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should sanitize namespace in URL', async () => {
|
|
326
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
327
|
+
mockFetch.mockResolvedValueOnce({
|
|
328
|
+
ok: true,
|
|
329
|
+
json: async () => translations,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const loader = createApiTranslationLoader({
|
|
333
|
+
baseUrl: 'https://example.com',
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await loader('en', 'my@namespace#test');
|
|
337
|
+
|
|
338
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
339
|
+
'https://example.com/api/translations/en/mynamespacetest',
|
|
340
|
+
expect.any(Object)
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should fall back to NEXT_PUBLIC_SITE_URL', async () => {
|
|
345
|
+
const originalEnv = process.env.NEXT_PUBLIC_SITE_URL;
|
|
346
|
+
process.env.NEXT_PUBLIC_SITE_URL = 'https://site.com';
|
|
347
|
+
|
|
348
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
349
|
+
mockFetch.mockResolvedValueOnce({
|
|
350
|
+
ok: true,
|
|
351
|
+
json: async () => translations,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const loader = createApiTranslationLoader();
|
|
355
|
+
|
|
356
|
+
await loader('en', 'common');
|
|
357
|
+
|
|
358
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
359
|
+
'https://site.com/api/translations/en/common',
|
|
360
|
+
expect.any(Object)
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
process.env.NEXT_PUBLIC_SITE_URL = originalEnv;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should fall back to VERCEL_URL', async () => {
|
|
367
|
+
const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
|
368
|
+
const originalVercelUrl = process.env.VERCEL_URL;
|
|
369
|
+
|
|
370
|
+
delete process.env.NEXT_PUBLIC_SITE_URL;
|
|
371
|
+
process.env.VERCEL_URL = 'vercel-app.vercel.app';
|
|
372
|
+
|
|
373
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
374
|
+
mockFetch.mockResolvedValueOnce({
|
|
375
|
+
ok: true,
|
|
376
|
+
json: async () => translations,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const loader = createApiTranslationLoader();
|
|
380
|
+
|
|
381
|
+
await loader('en', 'common');
|
|
382
|
+
|
|
383
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
384
|
+
'https://vercel-app.vercel.app/api/translations/en/common',
|
|
385
|
+
expect.any(Object)
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl;
|
|
389
|
+
process.env.VERCEL_URL = originalVercelUrl;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should use localFallbackBaseUrl as final fallback', async () => {
|
|
393
|
+
const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL;
|
|
394
|
+
const originalVercelUrl = process.env.VERCEL_URL;
|
|
395
|
+
|
|
396
|
+
delete process.env.NEXT_PUBLIC_SITE_URL;
|
|
397
|
+
delete process.env.VERCEL_URL;
|
|
398
|
+
|
|
399
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
400
|
+
mockFetch.mockResolvedValueOnce({
|
|
401
|
+
ok: true,
|
|
402
|
+
json: async () => translations,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const loader = createApiTranslationLoader({
|
|
406
|
+
localFallbackBaseUrl: 'http://localhost:4000',
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await loader('en', 'common');
|
|
410
|
+
|
|
411
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
412
|
+
'http://localhost:4000/api/translations/en/common',
|
|
413
|
+
expect.any(Object)
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl;
|
|
417
|
+
process.env.VERCEL_URL = originalVercelUrl;
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('Cache invalidation', () => {
|
|
422
|
+
it('should invalidate specific language/namespace', async () => {
|
|
423
|
+
const translations1: TranslationRecord = { hello: 'world' };
|
|
424
|
+
const translations2: TranslationRecord = { hello: 'updated' };
|
|
425
|
+
|
|
426
|
+
mockFetch
|
|
427
|
+
.mockResolvedValueOnce({
|
|
428
|
+
ok: true,
|
|
429
|
+
json: async () => translations1,
|
|
430
|
+
})
|
|
431
|
+
.mockResolvedValueOnce({
|
|
432
|
+
ok: true,
|
|
433
|
+
json: async () => translations2,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const loader = createApiTranslationLoader();
|
|
437
|
+
|
|
438
|
+
const result1 = await loader('en', 'common');
|
|
439
|
+
expect(result1).toEqual(translations1);
|
|
440
|
+
|
|
441
|
+
loader.invalidate('en', 'common');
|
|
442
|
+
|
|
443
|
+
const result2 = await loader('en', 'common');
|
|
444
|
+
expect(result2).toEqual(translations2);
|
|
445
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should invalidate all namespaces for a language', async () => {
|
|
449
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
450
|
+
mockFetch.mockResolvedValue({
|
|
451
|
+
ok: true,
|
|
452
|
+
json: async () => translations,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const loader = createApiTranslationLoader();
|
|
456
|
+
|
|
457
|
+
await loader('en', 'common');
|
|
458
|
+
await loader('en', 'auth');
|
|
459
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
460
|
+
|
|
461
|
+
loader.invalidate('en');
|
|
462
|
+
|
|
463
|
+
await loader('en', 'common');
|
|
464
|
+
await loader('en', 'auth');
|
|
465
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('should invalidate specific namespace across all languages', async () => {
|
|
469
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
470
|
+
mockFetch.mockResolvedValue({
|
|
471
|
+
ok: true,
|
|
472
|
+
json: async () => translations,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const loader = createApiTranslationLoader();
|
|
476
|
+
|
|
477
|
+
await loader('en', 'common');
|
|
478
|
+
await loader('es', 'common');
|
|
479
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
480
|
+
|
|
481
|
+
loader.invalidate(undefined, 'common');
|
|
482
|
+
|
|
483
|
+
await loader('en', 'common');
|
|
484
|
+
await loader('es', 'common');
|
|
485
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should clear entire cache', async () => {
|
|
489
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
490
|
+
mockFetch.mockResolvedValue({
|
|
491
|
+
ok: true,
|
|
492
|
+
json: async () => translations,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const loader = createApiTranslationLoader();
|
|
496
|
+
|
|
497
|
+
await loader('en', 'common');
|
|
498
|
+
await loader('en', 'auth');
|
|
499
|
+
await loader('es', 'common');
|
|
500
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
501
|
+
|
|
502
|
+
loader.clear();
|
|
503
|
+
|
|
504
|
+
await loader('en', 'common');
|
|
505
|
+
await loader('en', 'auth');
|
|
506
|
+
await loader('es', 'common');
|
|
507
|
+
expect(mockFetch).toHaveBeenCalledTimes(6);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe('Custom fetcher', () => {
|
|
512
|
+
it('should use custom fetcher when provided', async () => {
|
|
513
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
514
|
+
const customFetcher = vi.fn().mockResolvedValue({
|
|
515
|
+
ok: true,
|
|
516
|
+
json: async () => translations,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const loader = createApiTranslationLoader({
|
|
520
|
+
fetcher: customFetcher,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await loader('en', 'common');
|
|
524
|
+
|
|
525
|
+
expect(customFetcher).toHaveBeenCalledTimes(1);
|
|
526
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe('Custom requestInit', () => {
|
|
531
|
+
it('should use static requestInit', async () => {
|
|
532
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
533
|
+
mockFetch.mockResolvedValueOnce({
|
|
534
|
+
ok: true,
|
|
535
|
+
json: async () => translations,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const loader = createApiTranslationLoader({
|
|
539
|
+
requestInit: {
|
|
540
|
+
headers: {
|
|
541
|
+
'X-Custom-Header': 'test',
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await loader('en', 'common');
|
|
547
|
+
|
|
548
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
549
|
+
expect.any(String),
|
|
550
|
+
expect.objectContaining({
|
|
551
|
+
headers: {
|
|
552
|
+
'X-Custom-Header': 'test',
|
|
553
|
+
},
|
|
554
|
+
})
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('should use function-based requestInit', async () => {
|
|
559
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
560
|
+
mockFetch.mockResolvedValueOnce({
|
|
561
|
+
ok: true,
|
|
562
|
+
json: async () => translations,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const loader = createApiTranslationLoader({
|
|
566
|
+
requestInit: (language, namespace) => ({
|
|
567
|
+
headers: {
|
|
568
|
+
'X-Language': language,
|
|
569
|
+
'X-Namespace': namespace,
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
await loader('en', 'common');
|
|
575
|
+
|
|
576
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
577
|
+
expect.any(String),
|
|
578
|
+
expect.objectContaining({
|
|
579
|
+
headers: {
|
|
580
|
+
'X-Language': 'en',
|
|
581
|
+
'X-Namespace': 'common',
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('Logger', () => {
|
|
589
|
+
it('should use custom logger', async () => {
|
|
590
|
+
const logger = {
|
|
591
|
+
log: vi.fn(),
|
|
592
|
+
warn: vi.fn(),
|
|
593
|
+
error: vi.fn(),
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
mockFetch.mockResolvedValue({
|
|
597
|
+
ok: false,
|
|
598
|
+
status: 404,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const loader = createApiTranslationLoader({
|
|
602
|
+
logger,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
await expect(loader('en', 'common')).rejects.toThrow();
|
|
606
|
+
|
|
607
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { withDefaultTranslations } from '../defaults';
|
|
3
|
+
import type { TranslationLoader, TranslationRecord, DefaultTranslations } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('withDefaultTranslations', () => {
|
|
6
|
+
let mockLoader: TranslationLoader;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockLoader = vi.fn();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should return merged translations when loader succeeds', async () => {
|
|
13
|
+
const remoteTranslations: TranslationRecord = {
|
|
14
|
+
hello: 'world',
|
|
15
|
+
goodbye: 'farewell',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
|
|
19
|
+
|
|
20
|
+
const defaults: DefaultTranslations = {
|
|
21
|
+
en: {
|
|
22
|
+
common: {
|
|
23
|
+
hello: 'default hello',
|
|
24
|
+
other: 'default other',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
30
|
+
const result = await loader('en', 'common');
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
hello: 'world', // Remote overrides default
|
|
34
|
+
goodbye: 'farewell', // From remote
|
|
35
|
+
other: 'default other', // From defaults
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should merge remote translations with defaults', async () => {
|
|
40
|
+
const remoteTranslations: TranslationRecord = {
|
|
41
|
+
hello: 'remote hello',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
|
|
45
|
+
|
|
46
|
+
const defaults: DefaultTranslations = {
|
|
47
|
+
en: {
|
|
48
|
+
common: {
|
|
49
|
+
hello: 'default hello',
|
|
50
|
+
goodbye: 'default goodbye',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
56
|
+
const result = await loader('en', 'common');
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
hello: 'remote hello', // Remote overrides default
|
|
60
|
+
goodbye: 'default goodbye', // Default is preserved
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return defaults when loader fails', async () => {
|
|
65
|
+
const error = new Error('Failed to load');
|
|
66
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
67
|
+
|
|
68
|
+
const defaults: DefaultTranslations = {
|
|
69
|
+
en: {
|
|
70
|
+
common: {
|
|
71
|
+
hello: 'default hello',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
77
|
+
const result = await loader('en', 'common');
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual({
|
|
80
|
+
hello: 'default hello',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should throw error when loader fails and no defaults available', async () => {
|
|
85
|
+
const error = new Error('Failed to load');
|
|
86
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
87
|
+
|
|
88
|
+
const defaults: DefaultTranslations = {
|
|
89
|
+
es: {
|
|
90
|
+
common: {
|
|
91
|
+
hola: 'mundo',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
97
|
+
|
|
98
|
+
await expect(loader('en', 'common')).rejects.toThrow('Failed to load');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return defaults when remote returns empty object', async () => {
|
|
102
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
|
103
|
+
|
|
104
|
+
const defaults: DefaultTranslations = {
|
|
105
|
+
en: {
|
|
106
|
+
common: {
|
|
107
|
+
hello: 'default hello',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
113
|
+
const result = await loader('en', 'common');
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual({
|
|
116
|
+
hello: 'default hello',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should return defaults when remote returns null', async () => {
|
|
121
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
122
|
+
|
|
123
|
+
const defaults: DefaultTranslations = {
|
|
124
|
+
en: {
|
|
125
|
+
common: {
|
|
126
|
+
hello: 'default hello',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
132
|
+
const result = await loader('en', 'common');
|
|
133
|
+
|
|
134
|
+
expect(result).toEqual({
|
|
135
|
+
hello: 'default hello',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should return empty object when both remote and defaults are empty', async () => {
|
|
140
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
|
141
|
+
|
|
142
|
+
const defaults: DefaultTranslations = {};
|
|
143
|
+
|
|
144
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
145
|
+
const result = await loader('en', 'common');
|
|
146
|
+
|
|
147
|
+
expect(result).toEqual({});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should deep merge nested translations', async () => {
|
|
151
|
+
const remoteTranslations: TranslationRecord = {
|
|
152
|
+
user: {
|
|
153
|
+
profile: {
|
|
154
|
+
name: 'remote name',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
|
|
160
|
+
|
|
161
|
+
const defaults: DefaultTranslations = {
|
|
162
|
+
en: {
|
|
163
|
+
common: {
|
|
164
|
+
user: {
|
|
165
|
+
profile: {
|
|
166
|
+
name: 'default name',
|
|
167
|
+
email: 'default email',
|
|
168
|
+
},
|
|
169
|
+
settings: {
|
|
170
|
+
theme: 'default theme',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
178
|
+
const result = await loader('en', 'common');
|
|
179
|
+
|
|
180
|
+
expect(result).toEqual({
|
|
181
|
+
user: {
|
|
182
|
+
profile: {
|
|
183
|
+
name: 'remote name', // Overridden by remote
|
|
184
|
+
email: 'default email', // From defaults
|
|
185
|
+
},
|
|
186
|
+
settings: {
|
|
187
|
+
theme: 'default theme', // From defaults
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should handle arrays as leaf values', async () => {
|
|
194
|
+
const remoteTranslations: TranslationRecord = {
|
|
195
|
+
items: ['remote1', 'remote2'],
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
|
|
199
|
+
|
|
200
|
+
const defaults: DefaultTranslations = {
|
|
201
|
+
en: {
|
|
202
|
+
common: {
|
|
203
|
+
items: ['default1', 'default2', 'default3'],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
209
|
+
const result = await loader('en', 'common');
|
|
210
|
+
|
|
211
|
+
// Arrays are replaced, not merged
|
|
212
|
+
expect(result).toEqual({
|
|
213
|
+
items: ['remote1', 'remote2'],
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle mixed types between defaults and remote', async () => {
|
|
218
|
+
const remoteTranslations: TranslationRecord = {
|
|
219
|
+
value: {
|
|
220
|
+
nested: 'remote nested',
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
|
|
225
|
+
|
|
226
|
+
const defaults: DefaultTranslations = {
|
|
227
|
+
en: {
|
|
228
|
+
common: {
|
|
229
|
+
value: 'default string',
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
235
|
+
const result = await loader('en', 'common');
|
|
236
|
+
|
|
237
|
+
// Remote value replaces default entirely
|
|
238
|
+
expect(result).toEqual({
|
|
239
|
+
value: {
|
|
240
|
+
nested: 'remote nested',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should not mutate original defaults', async () => {
|
|
246
|
+
const remoteTranslations: TranslationRecord = {
|
|
247
|
+
hello: 'remote hello',
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(remoteTranslations);
|
|
251
|
+
|
|
252
|
+
const defaults: DefaultTranslations = {
|
|
253
|
+
en: {
|
|
254
|
+
common: {
|
|
255
|
+
hello: 'default hello',
|
|
256
|
+
goodbye: 'default goodbye',
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const defaultsCopy = JSON.parse(JSON.stringify(defaults));
|
|
262
|
+
|
|
263
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
264
|
+
await loader('en', 'common');
|
|
265
|
+
|
|
266
|
+
// Defaults should remain unchanged
|
|
267
|
+
expect(defaults).toEqual(defaultsCopy);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should handle multiple namespaces', async () => {
|
|
271
|
+
const translations1: TranslationRecord = { hello: 'world' };
|
|
272
|
+
const translations2: TranslationRecord = { login: 'sign in' };
|
|
273
|
+
|
|
274
|
+
(mockLoader as ReturnType<typeof vi.fn>)
|
|
275
|
+
.mockResolvedValueOnce(translations1)
|
|
276
|
+
.mockResolvedValueOnce(translations2);
|
|
277
|
+
|
|
278
|
+
const defaults: DefaultTranslations = {
|
|
279
|
+
en: {
|
|
280
|
+
common: {
|
|
281
|
+
hello: 'default hello',
|
|
282
|
+
},
|
|
283
|
+
auth: {
|
|
284
|
+
login: 'default login',
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
290
|
+
|
|
291
|
+
const result1 = await loader('en', 'common');
|
|
292
|
+
const result2 = await loader('en', 'auth');
|
|
293
|
+
|
|
294
|
+
expect(result1).toEqual({ hello: 'world' });
|
|
295
|
+
expect(result2).toEqual({ login: 'sign in' });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should handle multiple languages', async () => {
|
|
299
|
+
const enTranslations: TranslationRecord = { hello: 'world' };
|
|
300
|
+
const esTranslations: TranslationRecord = { hello: 'mundo' };
|
|
301
|
+
|
|
302
|
+
(mockLoader as ReturnType<typeof vi.fn>)
|
|
303
|
+
.mockResolvedValueOnce(enTranslations)
|
|
304
|
+
.mockResolvedValueOnce(esTranslations);
|
|
305
|
+
|
|
306
|
+
const defaults: DefaultTranslations = {
|
|
307
|
+
en: {
|
|
308
|
+
common: {
|
|
309
|
+
hello: 'default hello',
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
es: {
|
|
313
|
+
common: {
|
|
314
|
+
hello: 'default hola',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const loader = withDefaultTranslations(mockLoader, defaults);
|
|
320
|
+
|
|
321
|
+
const enResult = await loader('en', 'common');
|
|
322
|
+
const esResult = await loader('es', 'common');
|
|
323
|
+
|
|
324
|
+
expect(enResult).toEqual({ hello: 'world' });
|
|
325
|
+
expect(esResult).toEqual({ hello: 'mundo' });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { preloadNamespaces, warmFallbackLanguages } from '../preload';
|
|
3
|
+
import type { TranslationLoader, TranslationRecord } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('preloadNamespaces', () => {
|
|
6
|
+
let mockLoader: TranslationLoader;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockLoader = vi.fn();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should preload all namespaces successfully', async () => {
|
|
13
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
14
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(translations);
|
|
15
|
+
|
|
16
|
+
const namespaces = ['common', 'auth', 'dashboard'];
|
|
17
|
+
const result = await preloadNamespaces('en', namespaces, mockLoader);
|
|
18
|
+
|
|
19
|
+
expect(result.fulfilled).toEqual(namespaces);
|
|
20
|
+
expect(result.rejected).toEqual([]);
|
|
21
|
+
expect(mockLoader).toHaveBeenCalledTimes(3);
|
|
22
|
+
expect(mockLoader).toHaveBeenCalledWith('en', 'common');
|
|
23
|
+
expect(mockLoader).toHaveBeenCalledWith('en', 'auth');
|
|
24
|
+
expect(mockLoader).toHaveBeenCalledWith('en', 'dashboard');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should handle partial failures', async () => {
|
|
28
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
29
|
+
const error = new Error('Failed to load');
|
|
30
|
+
|
|
31
|
+
(mockLoader as ReturnType<typeof vi.fn>)
|
|
32
|
+
.mockResolvedValueOnce(translations) // common succeeds
|
|
33
|
+
.mockRejectedValueOnce(error) // auth fails
|
|
34
|
+
.mockResolvedValueOnce(translations); // dashboard succeeds
|
|
35
|
+
|
|
36
|
+
const namespaces = ['common', 'auth', 'dashboard'];
|
|
37
|
+
const result = await preloadNamespaces('en', namespaces, mockLoader);
|
|
38
|
+
|
|
39
|
+
expect(result.fulfilled).toEqual(['common', 'dashboard']);
|
|
40
|
+
expect(result.rejected).toHaveLength(1);
|
|
41
|
+
expect(result.rejected[0]).toBe(error);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should log success messages', async () => {
|
|
45
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
46
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(translations);
|
|
47
|
+
|
|
48
|
+
const logger = {
|
|
49
|
+
log: vi.fn(),
|
|
50
|
+
warn: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const namespaces = ['common', 'auth'];
|
|
54
|
+
await preloadNamespaces('en', namespaces, mockLoader, { logger });
|
|
55
|
+
|
|
56
|
+
expect(logger.log).toHaveBeenCalledWith(
|
|
57
|
+
'[i18n-loaders] Preloaded 2/2 namespaces for en'
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should log warning messages for failures', async () => {
|
|
62
|
+
const error = new Error('Failed to load');
|
|
63
|
+
(mockLoader as ReturnType<typeof vi.fn>)
|
|
64
|
+
.mockResolvedValueOnce({ hello: 'world' })
|
|
65
|
+
.mockRejectedValueOnce(error);
|
|
66
|
+
|
|
67
|
+
const logger = {
|
|
68
|
+
log: vi.fn(),
|
|
69
|
+
warn: vi.fn(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const namespaces = ['common', 'auth'];
|
|
73
|
+
await preloadNamespaces('en', namespaces, mockLoader, { logger });
|
|
74
|
+
|
|
75
|
+
expect(logger.log).toHaveBeenCalled();
|
|
76
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
77
|
+
'[i18n-loaders] Failed to preload 1 namespaces for en'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should suppress error warnings when option is set', async () => {
|
|
82
|
+
const error = new Error('Failed to load');
|
|
83
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
|
84
|
+
|
|
85
|
+
const logger = {
|
|
86
|
+
log: vi.fn(),
|
|
87
|
+
warn: vi.fn(),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const namespaces = ['common', 'auth'];
|
|
91
|
+
await preloadNamespaces('en', namespaces, mockLoader, {
|
|
92
|
+
logger,
|
|
93
|
+
suppressErrors: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should handle empty namespace list', async () => {
|
|
100
|
+
const result = await preloadNamespaces('en', [], mockLoader);
|
|
101
|
+
|
|
102
|
+
expect(result.fulfilled).toEqual([]);
|
|
103
|
+
expect(result.rejected).toEqual([]);
|
|
104
|
+
expect(mockLoader).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should call loader concurrently', async () => {
|
|
108
|
+
const callOrder: string[] = [];
|
|
109
|
+
|
|
110
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockImplementation(
|
|
111
|
+
async (language: string, namespace: string) => {
|
|
112
|
+
callOrder.push(`start-${namespace}`);
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
114
|
+
callOrder.push(`end-${namespace}`);
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const namespaces = ['common', 'auth', 'dashboard'];
|
|
120
|
+
await preloadNamespaces('en', namespaces, mockLoader);
|
|
121
|
+
|
|
122
|
+
// All starts should happen before any ends (concurrent execution)
|
|
123
|
+
const firstEndIndex = callOrder.findIndex((item) => item.startsWith('end-'));
|
|
124
|
+
const lastStartIndex = callOrder.reduce(
|
|
125
|
+
(acc, item, idx) => (item.startsWith('start-') ? idx : acc),
|
|
126
|
+
-1
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(firstEndIndex).toBeGreaterThan(lastStartIndex);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('warmFallbackLanguages', () => {
|
|
134
|
+
let mockLoader: TranslationLoader;
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
mockLoader = vi.fn();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should preload all fallback languages', async () => {
|
|
141
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
142
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockResolvedValue(translations);
|
|
143
|
+
|
|
144
|
+
const languages = ['en', 'es', 'fr'];
|
|
145
|
+
const namespaces = ['common', 'auth'];
|
|
146
|
+
|
|
147
|
+
await warmFallbackLanguages('en', languages, namespaces, mockLoader);
|
|
148
|
+
|
|
149
|
+
// Should preload es and fr (not en, as it's the current language)
|
|
150
|
+
expect(mockLoader).toHaveBeenCalledTimes(4); // 2 languages * 2 namespaces
|
|
151
|
+
expect(mockLoader).toHaveBeenCalledWith('es', 'common');
|
|
152
|
+
expect(mockLoader).toHaveBeenCalledWith('es', 'auth');
|
|
153
|
+
expect(mockLoader).toHaveBeenCalledWith('fr', 'common');
|
|
154
|
+
expect(mockLoader).toHaveBeenCalledWith('fr', 'auth');
|
|
155
|
+
expect(mockLoader).not.toHaveBeenCalledWith('en', expect.any(String));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should return empty array when no fallback languages', async () => {
|
|
159
|
+
const languages = ['en'];
|
|
160
|
+
const namespaces = ['common'];
|
|
161
|
+
|
|
162
|
+
const result = await warmFallbackLanguages(
|
|
163
|
+
'en',
|
|
164
|
+
languages,
|
|
165
|
+
namespaces,
|
|
166
|
+
mockLoader
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(result).toEqual([]);
|
|
170
|
+
expect(mockLoader).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle all languages being current language', async () => {
|
|
174
|
+
const languages = ['en'];
|
|
175
|
+
const namespaces = ['common', 'auth'];
|
|
176
|
+
|
|
177
|
+
const result = await warmFallbackLanguages(
|
|
178
|
+
'en',
|
|
179
|
+
languages,
|
|
180
|
+
namespaces,
|
|
181
|
+
mockLoader
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(result).toEqual([]);
|
|
185
|
+
expect(mockLoader).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should pass options to preloadNamespaces', async () => {
|
|
189
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
190
|
+
const error = new Error('Failed to load');
|
|
191
|
+
|
|
192
|
+
(mockLoader as ReturnType<typeof vi.fn>)
|
|
193
|
+
.mockResolvedValueOnce(translations)
|
|
194
|
+
.mockRejectedValueOnce(error);
|
|
195
|
+
|
|
196
|
+
const logger = {
|
|
197
|
+
log: vi.fn(),
|
|
198
|
+
warn: vi.fn(),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const languages = ['en', 'es'];
|
|
202
|
+
const namespaces = ['common'];
|
|
203
|
+
|
|
204
|
+
await warmFallbackLanguages('en', languages, namespaces, mockLoader, {
|
|
205
|
+
logger,
|
|
206
|
+
suppressErrors: true,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(logger.log).toHaveBeenCalled();
|
|
210
|
+
expect(logger.warn).not.toHaveBeenCalled(); // suppressErrors is true
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should return results for each fallback language', async () => {
|
|
214
|
+
const translations: TranslationRecord = { hello: 'world' };
|
|
215
|
+
const error = new Error('Failed to load');
|
|
216
|
+
|
|
217
|
+
(mockLoader as ReturnType<typeof vi.fn>)
|
|
218
|
+
.mockResolvedValueOnce(translations) // es/common succeeds
|
|
219
|
+
.mockRejectedValueOnce(error) // fr/common fails
|
|
220
|
+
.mockResolvedValueOnce(translations); // ko/common succeeds
|
|
221
|
+
|
|
222
|
+
const languages = ['en', 'es', 'fr', 'ko'];
|
|
223
|
+
const namespaces = ['common'];
|
|
224
|
+
|
|
225
|
+
const results = await warmFallbackLanguages(
|
|
226
|
+
'en',
|
|
227
|
+
languages,
|
|
228
|
+
namespaces,
|
|
229
|
+
mockLoader,
|
|
230
|
+
{ suppressErrors: true }
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(results).toHaveLength(3); // es, fr, ko (excluding current language 'en')
|
|
234
|
+
expect(results[0].fulfilled).toEqual(['common']);
|
|
235
|
+
expect(results[1].rejected).toHaveLength(1);
|
|
236
|
+
expect(results[2].fulfilled).toEqual(['common']);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should execute preloads concurrently for different languages', async () => {
|
|
240
|
+
const callOrder: string[] = [];
|
|
241
|
+
|
|
242
|
+
(mockLoader as ReturnType<typeof vi.fn>).mockImplementation(
|
|
243
|
+
async (language: string) => {
|
|
244
|
+
callOrder.push(`start-${language}`);
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
246
|
+
callOrder.push(`end-${language}`);
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const languages = ['en', 'es', 'fr'];
|
|
252
|
+
const namespaces = ['common'];
|
|
253
|
+
|
|
254
|
+
await warmFallbackLanguages('en', languages, namespaces, mockLoader);
|
|
255
|
+
|
|
256
|
+
// All starts should happen before any ends (concurrent execution)
|
|
257
|
+
const firstEndIndex = callOrder.findIndex((item) => item.startsWith('end-'));
|
|
258
|
+
const lastStartIndex = callOrder.reduce(
|
|
259
|
+
(acc, item, idx) => (item.startsWith('start-') ? idx : acc),
|
|
260
|
+
-1
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(firstEndIndex).toBeGreaterThan(lastStartIndex);
|
|
264
|
+
});
|
|
265
|
+
});
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2024 HUA Labs Team
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|