@gravito/cosmos 3.2.1 โ 3.2.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 +30 -7
- package/build.ts +12 -6
- package/doc/LOCALIZATION_STRATEGY.md +89 -0
- package/package.json +7 -5
- package/src/HMRWatcher.ts +2 -2
- package/src/index.ts +1 -0
- package/src/loader.ts +73 -10
- package/src/loaders/FileSystemLoader.ts +58 -2
- package/src/loaders/Json5Loader.ts +252 -0
- package/src/loaders/RemoteLoader.ts +6 -5
- package/tests/unit/loaders.test.ts +79 -0
- package/tsconfig.build.json +27 -0
- package/tsconfig.json +0 -5
package/README.md
CHANGED
|
@@ -6,13 +6,28 @@
|
|
|
6
6
|
|
|
7
7
|
## โจ Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
|
|
9
|
+
- ๐ช **Galaxy-Ready Globalization**: Native integration with PlanetCore for universal translation support across all Satellites.
|
|
10
|
+
- ๐ **Performance-First**: Highly optimized translation resolution with internal caching and lazy loading.
|
|
11
|
+
- ๐ก๏ธ **Type-Safe Keys**: End-to-end TypeScript support for translation keys using generics.
|
|
12
|
+
- ๐ **Request-Scoped State**: Clones i18n instances per request to maintain locale consistency without overhead.
|
|
13
|
+
- ๐ **Intl.PluralRules Support**: Native pluralization following international standards.
|
|
14
|
+
- ๐ก **Smart Auto-Detection**: Detects locale from Route Params, Query Strings, or `Accept-Language` headers.
|
|
15
|
+
|
|
16
|
+
## ๐ Role in Galaxy Architecture
|
|
17
|
+
|
|
18
|
+
In the **Gravito Galaxy Architecture**, Cosmos acts as the **Universal Translator (Linguistic Base)**.
|
|
19
|
+
|
|
20
|
+
- **Cross-Satellite Language**: Ensures that "Success" or "Error" messages are consistent and translated correctly, regardless of which Satellite generates the response.
|
|
21
|
+
- **Linguistic Context**: Propagates the user's preferred language from the `Photon` Sensing Layer deep into the business logic of every Satellite.
|
|
22
|
+
- **Dynamic Localization**: Works with `Atlas` or `Nebula` to load domain-specific translation files on demand, keeping the core Galaxy lean.
|
|
23
|
+
|
|
24
|
+
```mermaid
|
|
25
|
+
graph LR
|
|
26
|
+
User([User]) -- "Accept-Language: zh-TW" --> Photon[Photon Engine]
|
|
27
|
+
Photon --> Cosmos{Cosmos Orbit}
|
|
28
|
+
Cosmos -->|Translate| Sat[Satellite: Shop]
|
|
29
|
+
Sat -->|Response| User
|
|
30
|
+
```
|
|
16
31
|
|
|
17
32
|
## ๐ฆ Installation
|
|
18
33
|
|
|
@@ -88,6 +103,14 @@ app.get('/items', (c) => {
|
|
|
88
103
|
});
|
|
89
104
|
```
|
|
90
105
|
|
|
106
|
+
## ๐ Documentation
|
|
107
|
+
|
|
108
|
+
Detailed guides and references for the Galaxy Architecture:
|
|
109
|
+
|
|
110
|
+
- [๐๏ธ **Architecture Overview**](./README.md) โ Linguistic base and request-scoped state.
|
|
111
|
+
- [๐ **Localization Strategy**](./doc/LOCALIZATION_STRATEGY.md) โ **NEW**: Managing translations across isolated Satellites.
|
|
112
|
+
- [๐ **Integration with Orbits**](#-quick-start) โ Using i18n in Signal and Flare.
|
|
113
|
+
|
|
91
114
|
## ๐ Core Concepts
|
|
92
115
|
|
|
93
116
|
### I18nManager
|
package/build.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'bun'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const isDtsOnly = process.argv.includes('--dts-only')
|
|
4
|
+
|
|
5
|
+
console.log(isDtsOnly ? 'Building @gravito/cosmos DTS...' : 'Building @gravito/cosmos...')
|
|
4
6
|
|
|
5
7
|
// Clean dist
|
|
6
8
|
await Bun.$`rm -rf dist`
|
|
@@ -8,12 +10,14 @@ await Bun.$`rm -rf dist`
|
|
|
8
10
|
// Use tsup for multi-format build
|
|
9
11
|
const tsup = spawn(
|
|
10
12
|
[
|
|
11
|
-
'
|
|
13
|
+
'bunx',
|
|
12
14
|
'tsup',
|
|
13
15
|
'src/index.ts',
|
|
14
16
|
'--format',
|
|
15
|
-
'esm,cjs',
|
|
16
|
-
'--dts',
|
|
17
|
+
isDtsOnly ? 'esm' : 'esm,cjs',
|
|
18
|
+
...(isDtsOnly ? ['--dts', '--dts-only'] : ['--dts']),
|
|
19
|
+
'--tsconfig',
|
|
20
|
+
'tsconfig.build.json',
|
|
17
21
|
'--external',
|
|
18
22
|
'@gravito/core,@gravito/photon',
|
|
19
23
|
'--outDir',
|
|
@@ -27,9 +31,11 @@ const tsup = spawn(
|
|
|
27
31
|
|
|
28
32
|
const tsupCode = await tsup.exited
|
|
29
33
|
if (tsupCode !== 0) {
|
|
30
|
-
console.error('
|
|
34
|
+
console.error('\u274c tsup build failed')
|
|
31
35
|
process.exit(1)
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
// Type declaration generation is now handled by tsup --dts
|
|
39
|
+
|
|
40
|
+
console.log('\u2705 Build complete!')
|
|
35
41
|
process.exit(0)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Localization Strategy Guide
|
|
2
|
+
|
|
3
|
+
In a distributed **Galaxy Architecture**, maintaining consistent translations across isolated Satellites is a challenge. `@gravito/cosmos` provides the infrastructure to manage globalization at scale.
|
|
4
|
+
|
|
5
|
+
## 1. Request-Scoped Locales
|
|
6
|
+
|
|
7
|
+
Cosmos automatically detects and sets the current locale for every incoming request. This state is maintained throughout the request lifecycle.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// The 'i18n' instance in context is pre-configured for the current user
|
|
11
|
+
app.get('/welcome', (c) => {
|
|
12
|
+
const t = c.get('i18n').t;
|
|
13
|
+
return c.text(t('messages.welcome'));
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 2. Distributed Translation Files
|
|
18
|
+
|
|
19
|
+
Instead of one giant translation file, each **Satellite** should manage its own linguistic assets.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
satellites/catalog/
|
|
23
|
+
โโโ lang/
|
|
24
|
+
โโโ en.json
|
|
25
|
+
โโโ zh-TW.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Register satellite-specific translations during the `BOOT` phase:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// CatalogSatellite.ts
|
|
32
|
+
async boot(core: PlanetCore) {
|
|
33
|
+
const cosmos = core.container.resolve('cosmos');
|
|
34
|
+
cosmos.addTranslations('catalog', path.join(__dirname, '../lang'));
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 3. Translation Namespacing
|
|
39
|
+
|
|
40
|
+
To avoid collisions between Satellites, use namespaces when resolving keys.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// Explicit namespace
|
|
44
|
+
t('catalog::products.not_found');
|
|
45
|
+
|
|
46
|
+
// Fallback to global if namespace not found
|
|
47
|
+
t('common::errors.validation_failed');
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 4. Parameter Replacement & Pluralization
|
|
51
|
+
|
|
52
|
+
Cosmos follows the standard `:param` syntax for replacements and leverages `Intl.PluralRules` for complex pluralization logic.
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"items_count": "Found :count {zero: items|one: item|other: items}"
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
t('items_count', { count: 5 }); // Found 5 items
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 5. Dynamic Fallback Chains
|
|
65
|
+
|
|
66
|
+
Configure fallback languages based on regional requirements.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
new OrbitCosmos({
|
|
70
|
+
fallback: {
|
|
71
|
+
fallbackChain: {
|
|
72
|
+
'zh-HK': ['zh-TW', 'en'],
|
|
73
|
+
'fr-CA': ['fr', 'en']
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 6. Integration with Signal (Mail)
|
|
80
|
+
|
|
81
|
+
When sending emails via `@gravito/signal`, the `Mailable` class automatically uses the current locale context provided by Cosmos.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
export class WelcomeEmail extends Mailable {
|
|
85
|
+
build() {
|
|
86
|
+
return this.subject(this.t('emails.welcome_subject'));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/cosmos",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "Internationalization orbit for Gravito framework",
|
|
5
5
|
"main": "./dist/index.cjs",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "bun run build.ts",
|
|
29
|
+
"build:dts": "bun run build.ts --dts-only",
|
|
29
30
|
"test": "bun test --timeout=10000",
|
|
30
|
-
"test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
|
|
31
|
-
"test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
|
|
31
|
+
"test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && COVERAGE_THRESHOLD=65 bun run --bun scripts/check-coverage.ts",
|
|
32
|
+
"test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && COVERAGE_THRESHOLD=65 bun run --bun scripts/check-coverage.ts",
|
|
32
33
|
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
|
33
34
|
"test:unit": "bun test tests/ --timeout=10000",
|
|
34
35
|
"test:integration": "test $(find tests -name '*.integration.test.ts' 2>/dev/null | wc -l) -gt 0 && find tests -name '*.integration.test.ts' -print0 | xargs -0 bun test --timeout=10000 || echo 'No integration tests found'"
|
|
@@ -42,10 +43,11 @@
|
|
|
42
43
|
"author": "Carl Lee <carllee0520@gmail.com>",
|
|
43
44
|
"license": "MIT",
|
|
44
45
|
"peerDependencies": {
|
|
45
|
-
"@gravito/core": "^
|
|
46
|
-
"@gravito/photon": "^1.0
|
|
46
|
+
"@gravito/core": "^2.0.0",
|
|
47
|
+
"@gravito/photon": "^1.1.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
50
|
+
"@gravito/core": "workspace:*",
|
|
49
51
|
"bun-types": "latest",
|
|
50
52
|
"mitata": "^1.0.34",
|
|
51
53
|
"tsup": "^8.5.1",
|
package/src/HMRWatcher.ts
CHANGED
|
@@ -141,7 +141,7 @@ export class HMRWatcher {
|
|
|
141
141
|
enabled: false, // ๅผทๅถๅ็จ
|
|
142
142
|
watchDirs: [],
|
|
143
143
|
debounce: 300,
|
|
144
|
-
extensions: ['.json'],
|
|
144
|
+
extensions: ['.json', '.json5'],
|
|
145
145
|
verbose: false,
|
|
146
146
|
}
|
|
147
147
|
return
|
|
@@ -151,7 +151,7 @@ export class HMRWatcher {
|
|
|
151
151
|
enabled: config.enabled,
|
|
152
152
|
watchDirs: config.watchDirs,
|
|
153
153
|
debounce: config.debounce ?? 300,
|
|
154
|
-
extensions: config.extensions ?? ['.json'],
|
|
154
|
+
extensions: config.extensions ?? ['.json', '.json5'],
|
|
155
155
|
verbose: config.verbose ?? true,
|
|
156
156
|
}
|
|
157
157
|
}
|
package/src/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ export * from './loaders/ChainedLoader'
|
|
|
84
84
|
export * from './loaders/CloudflareKVLoader'
|
|
85
85
|
export * from './loaders/EdgeKVLoader'
|
|
86
86
|
export * from './loaders/FileSystemLoader'
|
|
87
|
+
export * from './loaders/Json5Loader'
|
|
87
88
|
export * from './loaders/MemoryLoader'
|
|
88
89
|
export * from './loaders/RemoteLoader'
|
|
89
90
|
// Loaders
|
package/src/loader.ts
CHANGED
|
@@ -22,13 +22,55 @@
|
|
|
22
22
|
import { readdir, readFile } from 'node:fs/promises'
|
|
23
23
|
import { join, parse } from 'node:path'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* ๅตๆธฌ Bun ้่กๆๆฏๅฆๅ็ๆฏๆด JSON5 ่งฃๆ
|
|
27
|
+
*
|
|
28
|
+
* @returns ๆฏๅฆๅ
ทๅ Bun ๅ็ JSON5 ่ฝๅ
|
|
29
|
+
*/
|
|
30
|
+
function hasBunJson5(): boolean {
|
|
31
|
+
return (
|
|
32
|
+
typeof globalThis.Bun !== 'undefined' &&
|
|
33
|
+
typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ่งฃๆ JSON5 ๆ ผๅผๅญไธฒ๏ผ้็ดๅฐ json5 npm ๅฅไปถ๏ผ
|
|
39
|
+
*
|
|
40
|
+
* @param content - ่ฆ่งฃๆ็ JSON5 ๅญไธฒ
|
|
41
|
+
* @returns ่งฃๆ็ตๆ
|
|
42
|
+
*/
|
|
43
|
+
async function parseJson5(content: string): Promise<unknown> {
|
|
44
|
+
if (hasBunJson5()) {
|
|
45
|
+
const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
|
|
46
|
+
parse: (text: string) => unknown
|
|
47
|
+
}
|
|
48
|
+
return bunJson5.parse(content)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ไฝฟ็จ่ฎๆธไฝ็บ่ทฏๅพไปฅ้ฟๅ
้ๆ
ๅๅฅ่งฃๆ๏ผjson5 ็บๅฏ้ธ peer dependency๏ผ
|
|
52
|
+
try {
|
|
53
|
+
const pkgName = 'json5'
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
const json5Module = await (import(pkgName) as Promise<any>)
|
|
56
|
+
const json5 = json5Module.default ?? json5Module
|
|
57
|
+
return (json5 as { parse: (text: string) => unknown }).parse(content)
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'[Cosmos] JSON5 parsing requires either Bun v1.2+ or the `json5` npm package. ' +
|
|
61
|
+
'Please install it: bun add json5'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
/**
|
|
26
67
|
* ๅพ็ฎ้่ผๅ
ฅๆๆ็ฟป่ญฏๆชๆก
|
|
27
68
|
*
|
|
28
|
-
* ๆๆๆๅฎ็ฎ้ไธญ็ๆๆ JSON ๆชๆกไธฆ่ผๅ
ฅ็บ็ฟป่ญฏ่ณๆบ
|
|
69
|
+
* ๆๆๆๅฎ็ฎ้ไธญ็ๆๆ JSON ่ JSON5 ๆชๆกไธฆ่ผๅ
ฅ็บ็ฟป่ญฏ่ณๆบ
|
|
70
|
+
* ่ฅๅไธ่ช่จๅๆๅญๅจ `.json` ่ `.json5`๏ผไปฅ `.json5` ๅชๅ
|
|
29
71
|
* ๆชๆกๅ็จฑ(ไธๅซๅฏๆชๅ)ๅฐไฝ็บ่ช่จไปฃ็ขผ
|
|
30
72
|
*
|
|
31
|
-
* @deprecated ่ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็จ FileSystemLoader
|
|
73
|
+
* @deprecated ่ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็จ FileSystemLoader ๆ Json5Loader
|
|
32
74
|
* @param directory - ็ฟป่ญฏ็ฎ้็็ตๅฐ่ทฏๅพ
|
|
33
75
|
* @returns ่ช่จไปฃ็ขผๅฐ็ฟป่ญฏ่ณๆบ็ๅฐๆ่กจ
|
|
34
76
|
* @public
|
|
@@ -37,7 +79,7 @@ import { join, parse } from 'node:path'
|
|
|
37
79
|
* ```
|
|
38
80
|
* /lang
|
|
39
81
|
* /en.json -> { "welcome": "Hello" }
|
|
40
|
-
* /zh-TW.
|
|
82
|
+
* /zh-TW.json5 -> { "welcome": "ไฝ ๅฅฝ" } // ๆฏๆด JSON5
|
|
41
83
|
* ```
|
|
42
84
|
*/
|
|
43
85
|
export async function loadTranslations(
|
|
@@ -48,13 +90,29 @@ export async function loadTranslations(
|
|
|
48
90
|
try {
|
|
49
91
|
const files = await readdir(directory)
|
|
50
92
|
|
|
93
|
+
// ๆถ้ๆๆๆฏๆดๆ ผๅผ็็ฟป่ญฏๆชๆก๏ผไปฅ locale ็บ้ต๏ผ้ฟๅ
้่ค่็
|
|
94
|
+
const localeFiles = new Map<string, string>()
|
|
95
|
+
|
|
51
96
|
for (const file of files) {
|
|
52
|
-
if (!file.endsWith('.json')) {
|
|
97
|
+
if (!file.endsWith('.json') && !file.endsWith('.json5')) {
|
|
53
98
|
continue
|
|
54
99
|
}
|
|
55
100
|
|
|
56
|
-
const locale = parse(file).name
|
|
57
|
-
const
|
|
101
|
+
const locale = parse(file).name
|
|
102
|
+
const existing = localeFiles.get(locale)
|
|
103
|
+
|
|
104
|
+
// json5 ๅชๅ
๏ผ่ฅๅไธ locale ๅทฒๆ .json๏ผjson5 ๅฏ่ฆ่๏ผๅไนไธ่ฆ่
|
|
105
|
+
if (!existing || file.endsWith('.json5')) {
|
|
106
|
+
localeFiles.set(locale, file)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [locale, file] of localeFiles) {
|
|
111
|
+
const translationsForLocale = await loadLocale(
|
|
112
|
+
directory,
|
|
113
|
+
locale,
|
|
114
|
+
parse(file).ext as '.json' | '.json5'
|
|
115
|
+
)
|
|
58
116
|
if (translationsForLocale) {
|
|
59
117
|
translations[locale] = translationsForLocale
|
|
60
118
|
}
|
|
@@ -71,21 +129,26 @@ export async function loadTranslations(
|
|
|
71
129
|
/**
|
|
72
130
|
* ่ผๅ
ฅๆๅฎ่ช่จ็็ฟป่ญฏ่ณๆบ
|
|
73
131
|
*
|
|
74
|
-
* ๆๆๅจๆๅฎ็ฎ้ไธญๆพๅฐ `{locale}.json` ๆ ผๅผ็ๆชๆก
|
|
132
|
+
* ๆๆๅจๆๅฎ็ฎ้ไธญๆพๅฐ `{locale}.json` ๆ `{locale}.json5` ๆ ผๅผ็ๆชๆก
|
|
75
133
|
*
|
|
76
|
-
* @deprecated ่ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็จ FileSystemLoader
|
|
134
|
+
* @deprecated ่ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็จ FileSystemLoader ๆ Json5Loader
|
|
77
135
|
* @param directory - ๅ
ๅซ็ฟป่ญฏๆชๆก็็ฎ้
|
|
78
136
|
* @param locale - ่ฆ่ผๅ
ฅ็่ช่จไปฃ็ขผ
|
|
137
|
+
* @param extension - ๆชๆกๅฏๆชๅ๏ผ้ ่จญ `.json`๏ผๅฏๅณๅ
ฅ `.json5`
|
|
79
138
|
* @returns ็ฟป่ญฏ่ณๆบ,่ผๅ
ฅๅคฑๆๅ่ฟๅ null
|
|
80
139
|
* @public
|
|
81
140
|
*/
|
|
82
141
|
export async function loadLocale(
|
|
83
142
|
directory: string,
|
|
84
|
-
locale: string
|
|
143
|
+
locale: string,
|
|
144
|
+
extension: '.json' | '.json5' = '.json'
|
|
85
145
|
): Promise<Record<string, string> | null> {
|
|
86
|
-
const filePath = join(directory, `${locale}
|
|
146
|
+
const filePath = join(directory, `${locale}${extension}`)
|
|
87
147
|
try {
|
|
88
148
|
const content = await readFile(filePath, 'utf-8')
|
|
149
|
+
if (extension === '.json5') {
|
|
150
|
+
return (await parseJson5(content)) as Record<string, string>
|
|
151
|
+
}
|
|
89
152
|
return JSON.parse(content)
|
|
90
153
|
} catch {
|
|
91
154
|
return null
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/cosmos/src/loaders/FileSystemLoader.ts
|
|
3
3
|
* @module @gravito/cosmos/loaders
|
|
4
|
-
* @description
|
|
4
|
+
* @description ๅพๆชๆก็ณป็ตฑ่ผๅ
ฅ็ฟป่ญฏ่ณๆบ็ๅฏฆ็พ๏ผๆฏๆด JSON ่ JSON5 ๆ ผๅผ
|
|
5
5
|
*
|
|
6
6
|
* โ ๏ธ **Node.js Only**
|
|
7
7
|
*
|
|
@@ -22,6 +22,20 @@ import type { TranslationMap } from '../I18nService'
|
|
|
22
22
|
import { detectRuntime } from '../runtime/detector'
|
|
23
23
|
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* ๅตๆธฌ Bun ้่กๆๆฏๅฆๅ็ๆฏๆด JSON5 ่งฃๆ
|
|
27
|
+
*
|
|
28
|
+
* Bun v1.2+ ๆไพ `Bun.JSON5` ๅ
จๅ็ฉไปถ
|
|
29
|
+
*
|
|
30
|
+
* @returns ๆฏๅฆๅ
ทๅ Bun ๅ็ JSON5 ่ฝๅ
|
|
31
|
+
*/
|
|
32
|
+
function hasBunJson5(): boolean {
|
|
33
|
+
return (
|
|
34
|
+
typeof globalThis.Bun !== 'undefined' &&
|
|
35
|
+
typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/**
|
|
26
40
|
* ๆชๆก็ณป็ตฑ่ผๅ
ฅๅจ้
็ฝฎ
|
|
27
41
|
*
|
|
@@ -39,6 +53,8 @@ export interface FileSystemLoaderConfig extends LoaderConfig {
|
|
|
39
53
|
/**
|
|
40
54
|
* ๆชๆกๅฏๆชๅ
|
|
41
55
|
*
|
|
56
|
+
* ๆฏๆด `.json` ่ `.json5` ๅ
ฉ็จฎๆ ผๅผ
|
|
57
|
+
*
|
|
42
58
|
* @default '.json'
|
|
43
59
|
*/
|
|
44
60
|
extension?: string
|
|
@@ -94,6 +110,8 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
94
110
|
/**
|
|
95
111
|
* ่ผๅ
ฅๆๅฎ่ช่จ็็ฟป่ญฏ่ณๆบ
|
|
96
112
|
*
|
|
113
|
+
* ๆ นๆ extension ่ชๅๅคๆทไฝฟ็จ JSON ๆ JSON5 ่งฃๆๅจ
|
|
114
|
+
*
|
|
97
115
|
* @param locale - ่ช่จไปฃ็ขผ
|
|
98
116
|
* @returns ็ฟป่ญฏ่ณๆบ,่ผๅ
ฅๅคฑๆๅ่ฟๅ null
|
|
99
117
|
*/
|
|
@@ -101,7 +119,10 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
101
119
|
try {
|
|
102
120
|
const filePath = join(this.baseDir, `${locale}${this.extension}`)
|
|
103
121
|
const content = await readFile(filePath, 'utf-8')
|
|
104
|
-
const translations =
|
|
122
|
+
const translations =
|
|
123
|
+
this.extension === '.json5'
|
|
124
|
+
? await this.parseJson5(content)
|
|
125
|
+
: (JSON.parse(content) as TranslationMap)
|
|
105
126
|
return translations
|
|
106
127
|
} catch (_error) {
|
|
107
128
|
// ๅฆๆๆๅ็จ่ผๅ
ฅๅจ,ๅ่ฉฆไฝฟ็จๅ็จ่ผๅ
ฅๅจ
|
|
@@ -112,6 +133,41 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
135
|
|
|
136
|
+
/**
|
|
137
|
+
* ่งฃๆ JSON5 ๅ
งๅฎน
|
|
138
|
+
*
|
|
139
|
+
* ๅชๅ
ไฝฟ็จ Bun ๅ็ JSON5 ่งฃๆๅจ๏ผBun v1.2+๏ผ๏ผ
|
|
140
|
+
* ่ฅไธๅฏ็จๅๅ่ฉฆๅๆ
่ผๅ
ฅ `json5` npm ๅฅไปถไฝ็บ้็ดๆนๆก
|
|
141
|
+
*
|
|
142
|
+
* @param content - ่ฆ่งฃๆ็ JSON5 ๅญไธฒ
|
|
143
|
+
* @returns ่งฃๆๅพ็็ฟป่ญฏ่ณๆบ
|
|
144
|
+
* @throws {Error} ่ฅ Bun ๅ็่ json5 ๅฅไปถๅไธๅฏ็จๆ
|
|
145
|
+
*/
|
|
146
|
+
private async parseJson5(content: string): Promise<TranslationMap> {
|
|
147
|
+
// ๅชๅ
ไฝฟ็จ Bun ๅ็ JSON5 ่งฃๆๅจ
|
|
148
|
+
if (hasBunJson5()) {
|
|
149
|
+
const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
|
|
150
|
+
parse: (text: string) => unknown
|
|
151
|
+
}
|
|
152
|
+
return bunJson5.parse(content) as TranslationMap
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ้็ดๆนๆก๏ผๅๆ
่ผๅ
ฅ json5 npm ๅฅไปถ
|
|
156
|
+
// ไฝฟ็จ่ฎๆธไฝ็บ่ทฏๅพไปฅ้ฟๅ
้ๆ
ๅๅฅ่งฃๆ๏ผjson5 ็บๅฏ้ธ peer dependency๏ผ
|
|
157
|
+
try {
|
|
158
|
+
const pkgName = 'json5'
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
160
|
+
const json5Module = await (import(pkgName) as Promise<any>)
|
|
161
|
+
const json5 = json5Module.default ?? json5Module
|
|
162
|
+
return (json5 as { parse: (text: string) => unknown }).parse(content) as TranslationMap
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(
|
|
165
|
+
'[FileSystemLoader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
|
|
166
|
+
'or the `json5` npm package. Please install it: bun add json5'
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
115
171
|
/**
|
|
116
172
|
* ่จญๅฎๅ็จ่ผๅ
ฅๅจ
|
|
117
173
|
*
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/loaders/Json5Loader.ts
|
|
3
|
+
* @module @gravito/cosmos/loaders
|
|
4
|
+
* @description ๆฏๆด JSON5 ๆ ผๅผ็ฟป่ญฏๆชๆก็ๆททๅ่ผๅ
ฅๅจ
|
|
5
|
+
*
|
|
6
|
+
* โ ๏ธ **Node.js Only**
|
|
7
|
+
*
|
|
8
|
+
* ๆญค่ผๅ
ฅๅจไพ่ณด Node.js ็ fs ๆจก็ต๏ผ็กๆณๅจ Edge Runtime ไฝฟ็จ
|
|
9
|
+
*
|
|
10
|
+
* ๆฏๆดๆททๅ่ผๅ
ฅ `.json5` ่ `.json` ๆ ผๅผ็็ฟป่ญฏๆชๆก๏ผ
|
|
11
|
+
* ๅฏ้
็ฝฎ่ผๅ
ฅๅชๅ
็ดไปฅๅฝๆงๆๅฐไธๅๅ ดๆฏ
|
|
12
|
+
*
|
|
13
|
+
* @since 3.2.0
|
|
14
|
+
* @platform node
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import type { TranslationMap } from '../I18nService'
|
|
20
|
+
import { detectRuntime } from '../runtime/detector'
|
|
21
|
+
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Json5Loader ๅชๅ
็ด็ญ็ฅ
|
|
25
|
+
*
|
|
26
|
+
* - `json5-first`: ๅชๅ
ๅ่ฉฆ `.json5`๏ผๅคฑๆๅพ้ๅฐ `.json`
|
|
27
|
+
* - `json-first`: ๅชๅ
ๅ่ฉฆ `.json`๏ผๅคฑๆๅพ้ๅฐ `.json5`
|
|
28
|
+
* - `json5-only`: ๅช่ผๅ
ฅ `.json5`๏ผไธ้็ด
|
|
29
|
+
* - `json-only`: ๅช่ผๅ
ฅ `.json`๏ผไธ้็ด
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
* @since 3.2.0
|
|
33
|
+
*/
|
|
34
|
+
export type Json5LoaderPriority = 'json5-first' | 'json-first' | 'json5-only' | 'json-only'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Json5Loader ้
็ฝฎ
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
* @since 3.2.0
|
|
41
|
+
*/
|
|
42
|
+
export interface Json5LoaderConfig extends LoaderConfig {
|
|
43
|
+
/**
|
|
44
|
+
* ็ฟป่ญฏๆชๆกๆๅจ็ๅบ็ค็ฎ้
|
|
45
|
+
*
|
|
46
|
+
* @example '/app/lang'
|
|
47
|
+
*/
|
|
48
|
+
baseDir: string
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ่ผๅ
ฅๅชๅ
็ด็ญ็ฅ
|
|
52
|
+
*
|
|
53
|
+
* @default 'json5-first'
|
|
54
|
+
*/
|
|
55
|
+
priority?: Json5LoaderPriority
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ๅตๆธฌ Bun ้่กๆๆฏๅฆๅ็ๆฏๆด JSON5 ่งฃๆ
|
|
60
|
+
*
|
|
61
|
+
* Bun v1.2+ ๆไพ `Bun.JSON5` ๅ
จๅ็ฉไปถ
|
|
62
|
+
*
|
|
63
|
+
* @returns ๆฏๅฆๅ
ทๅ Bun ๅ็ JSON5 ่ฝๅ
|
|
64
|
+
*/
|
|
65
|
+
function hasBunJson5(): boolean {
|
|
66
|
+
return (
|
|
67
|
+
typeof globalThis.Bun !== 'undefined' &&
|
|
68
|
+
typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ่งฃๆ JSON5 ๆ ผๅผๅญไธฒ
|
|
74
|
+
*
|
|
75
|
+
* ๅชๅ
ไฝฟ็จ Bun ๅ็ JSON5 ่งฃๆๅจ๏ผBun v1.2+๏ผ๏ผ
|
|
76
|
+
* ้็ด่ณ `json5` npm ๅฅไปถ
|
|
77
|
+
*
|
|
78
|
+
* @param content - ่ฆ่งฃๆ็ JSON5 ๅญไธฒ
|
|
79
|
+
* @returns ่งฃๆ็ตๆ
|
|
80
|
+
* @throws {Error} ่ฅๅ
ฉ็จฎๆนๅผๅไธๅฏ็จ
|
|
81
|
+
*/
|
|
82
|
+
async function parseJson5Content(content: string): Promise<unknown> {
|
|
83
|
+
if (hasBunJson5()) {
|
|
84
|
+
const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
|
|
85
|
+
parse: (text: string) => unknown
|
|
86
|
+
}
|
|
87
|
+
return bunJson5.parse(content)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ไฝฟ็จ่ฎๆธไฝ็บ่ทฏๅพไปฅ้ฟๅ
้ๆ
ๅๅฅ่งฃๆ๏ผjson5 ็บๅฏ้ธ peer dependency๏ผ
|
|
91
|
+
try {
|
|
92
|
+
const pkgName = 'json5'
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
+
const json5Module = await (import(pkgName) as Promise<any>)
|
|
95
|
+
const json5 = json5Module.default ?? json5Module
|
|
96
|
+
return (json5 as { parse: (text: string) => unknown }).parse(content)
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'[Json5Loader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
|
|
100
|
+
'or the `json5` npm package. Please install it: bun add json5'
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* JSON5 ๆททๅ็ฟป่ญฏ่ผๅ
ฅๅจ
|
|
107
|
+
*
|
|
108
|
+
* ๆฏๆดๆททๅ่ผๅ
ฅ `.json5` ่ `.json` ๆ ผๅผ็็ฟป่ญฏๆชๆกใ
|
|
109
|
+
* ้้ๅชๅ
็ด้
็ฝฎๅฝๆงๆงๅถ่ผๅ
ฅ็ญ็ฅ๏ผไธฆๆฏๆด fallback ้็ด้ใ
|
|
110
|
+
*
|
|
111
|
+
* @public
|
|
112
|
+
* @since 3.2.0
|
|
113
|
+
*
|
|
114
|
+
* @example json5-first ๅชๅ
๏ผ้ ่จญ๏ผ
|
|
115
|
+
* ```typescript
|
|
116
|
+
* const loader = new Json5Loader({
|
|
117
|
+
* baseDir: '/app/lang',
|
|
118
|
+
* priority: 'json5-first'
|
|
119
|
+
* })
|
|
120
|
+
* // ๅ่ฉฆ zh-TW.json5๏ผ่ฅไธๅญๅจๅ้ๅ zh-TW.json
|
|
121
|
+
* const translations = await loader.load('zh-TW')
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @example ๆญ้
fallback ้
|
|
125
|
+
* ```typescript
|
|
126
|
+
* const loader = new Json5Loader({ baseDir: '/app/lang' })
|
|
127
|
+
* .fallback(new MemoryLoader({ en: { welcome: 'Hello' } }))
|
|
128
|
+
*
|
|
129
|
+
* const translations = await loader.load('en')
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export class Json5Loader implements TranslationLoaderChain {
|
|
133
|
+
public readonly name: string
|
|
134
|
+
private readonly baseDir: string
|
|
135
|
+
private readonly priority: Json5LoaderPriority
|
|
136
|
+
private fallbackLoader?: TranslationLoader
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* ๅปบ็ซ Json5Loader ๅฏฆไพ
|
|
140
|
+
*
|
|
141
|
+
* @param config - ่ผๅ
ฅๅจ้
็ฝฎ
|
|
142
|
+
* @throws {Error} ๅฆๆๅจ Edge Runtime ็ฐๅขไธญไฝฟ็จ
|
|
143
|
+
*/
|
|
144
|
+
constructor(config: Json5LoaderConfig) {
|
|
145
|
+
// ้่กๆๆชขๆฅ
|
|
146
|
+
const runtime = detectRuntime()
|
|
147
|
+
if (runtime === 'edge') {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'[Json5Loader] This loader requires Node.js and cannot run in Edge Runtime. ' +
|
|
150
|
+
'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.'
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.name = config.name ?? 'Json5Loader'
|
|
155
|
+
this.baseDir = config.baseDir
|
|
156
|
+
this.priority = config.priority ?? 'json5-first'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ่ผๅ
ฅๆๅฎ่ช่จ็็ฟป่ญฏ่ณๆบ
|
|
161
|
+
*
|
|
162
|
+
* ๆ นๆๅชๅ
็ด็ญ็ฅๅ่ฉฆไธๅๅฏๆชๅ็ๆชๆก
|
|
163
|
+
*
|
|
164
|
+
* @param locale - ่ช่จไปฃ็ขผ
|
|
165
|
+
* @returns ็ฟป่ญฏ่ณๆบ๏ผๆๆๅ่ฉฆๅๅคฑๆๅ่ฟๅ null
|
|
166
|
+
*/
|
|
167
|
+
async load(locale: string): Promise<TranslationMap | null> {
|
|
168
|
+
const result = await this.loadByPriority(locale)
|
|
169
|
+
|
|
170
|
+
if (result !== null) {
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ๅ่ฉฆๅค้จ fallback ่ผๅ
ฅๅจ
|
|
175
|
+
if (this.fallbackLoader) {
|
|
176
|
+
return this.fallbackLoader.load(locale)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ่จญๅฎๅ็จ่ผๅ
ฅๅจ
|
|
184
|
+
*
|
|
185
|
+
* @param loader - ๅ็จ่ผๅ
ฅๅจ
|
|
186
|
+
* @returns ็ถๅๅฏฆไพ๏ผๆฏๆด้ๅผ่ชฟ็จ
|
|
187
|
+
*/
|
|
188
|
+
fallback(loader: TranslationLoader): TranslationLoaderChain {
|
|
189
|
+
this.fallbackLoader = loader
|
|
190
|
+
return this
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* ๅๅพ็ถๅๅชๅ
็ด็ญ็ฅ
|
|
195
|
+
*
|
|
196
|
+
* @returns ๅชๅ
็ด็ญ็ฅๅญไธฒ
|
|
197
|
+
*/
|
|
198
|
+
getPriority(): Json5LoaderPriority {
|
|
199
|
+
return this.priority
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* ๆ นๆๅชๅ
็ด็ญ็ฅไพๅบๅ่ฉฆ่ผๅ
ฅ็ฟป่ญฏๆชๆก
|
|
204
|
+
*
|
|
205
|
+
* @param locale - ่ช่จไปฃ็ขผ
|
|
206
|
+
* @returns ็ฟป่ญฏ่ณๆบ๏ผๆ null
|
|
207
|
+
*/
|
|
208
|
+
private async loadByPriority(locale: string): Promise<TranslationMap | null> {
|
|
209
|
+
switch (this.priority) {
|
|
210
|
+
case 'json5-first':
|
|
211
|
+
return (await this.tryLoadJson5(locale)) ?? (await this.tryLoadJson(locale))
|
|
212
|
+
case 'json-first':
|
|
213
|
+
return (await this.tryLoadJson(locale)) ?? (await this.tryLoadJson5(locale))
|
|
214
|
+
case 'json5-only':
|
|
215
|
+
return this.tryLoadJson5(locale)
|
|
216
|
+
case 'json-only':
|
|
217
|
+
return this.tryLoadJson(locale)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* ๅ่ฉฆ่ผๅ
ฅ `.json5` ๆ ผๅผ็็ฟป่ญฏๆชๆก
|
|
223
|
+
*
|
|
224
|
+
* @param locale - ่ช่จไปฃ็ขผ
|
|
225
|
+
* @returns ็ฟป่ญฏ่ณๆบ๏ผๆ null
|
|
226
|
+
*/
|
|
227
|
+
private async tryLoadJson5(locale: string): Promise<TranslationMap | null> {
|
|
228
|
+
try {
|
|
229
|
+
const filePath = join(this.baseDir, `${locale}.json5`)
|
|
230
|
+
const content = await readFile(filePath, 'utf-8')
|
|
231
|
+
return (await parseJson5Content(content)) as TranslationMap
|
|
232
|
+
} catch {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* ๅ่ฉฆ่ผๅ
ฅ `.json` ๆ ผๅผ็็ฟป่ญฏๆชๆก
|
|
239
|
+
*
|
|
240
|
+
* @param locale - ่ช่จไปฃ็ขผ
|
|
241
|
+
* @returns ็ฟป่ญฏ่ณๆบ๏ผๆ null
|
|
242
|
+
*/
|
|
243
|
+
private async tryLoadJson(locale: string): Promise<TranslationMap | null> {
|
|
244
|
+
try {
|
|
245
|
+
const filePath = join(this.baseDir, `${locale}.json`)
|
|
246
|
+
const content = await readFile(filePath, 'utf-8')
|
|
247
|
+
return JSON.parse(content) as TranslationMap
|
|
248
|
+
} catch {
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -124,16 +124,12 @@ export class RemoteLoader implements TranslationLoaderChain {
|
|
|
124
124
|
* @returns ็ฟป่ญฏ่ณๆบ,่ผๅ
ฅๅคฑๆๅ่ฟๅ null
|
|
125
125
|
*/
|
|
126
126
|
async load(locale: string): Promise<TranslationMap | null> {
|
|
127
|
-
let _lastError: Error | null = null
|
|
128
|
-
|
|
129
127
|
// ้่ฉฆ้่ผฏ
|
|
130
128
|
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
131
129
|
try {
|
|
132
130
|
const result = await this.fetchWithTimeout(locale)
|
|
133
131
|
return result
|
|
134
|
-
} catch
|
|
135
|
-
_lastError = error as Error
|
|
136
|
-
|
|
132
|
+
} catch {
|
|
137
133
|
// ๅฆๆไธๆฏๆๅพไธๆฌกๅ่ฉฆ,็ญๅพ
ๅพ้่ฉฆ
|
|
138
134
|
if (attempt < this.retries) {
|
|
139
135
|
const delay = this.retryDelay * 2 ** attempt
|
|
@@ -160,6 +156,7 @@ export class RemoteLoader implements TranslationLoaderChain {
|
|
|
160
156
|
const url = this.url.replace(':locale', locale)
|
|
161
157
|
const controller = new AbortController()
|
|
162
158
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
159
|
+
timeoutId.unref?.()
|
|
163
160
|
|
|
164
161
|
try {
|
|
165
162
|
const headers: Record<string, string> = { ...this.headers }
|
|
@@ -181,6 +178,10 @@ export class RemoteLoader implements TranslationLoaderChain {
|
|
|
181
178
|
if (cached) {
|
|
182
179
|
return cached
|
|
183
180
|
}
|
|
181
|
+
|
|
182
|
+
// ETag ่ๆฌๅฐ็ฟป่ญฏๅฟซๅ่ซ้คๆ๏ผไธๆฃ stale ETag ๅพ้ๆฐๆๅๅฎๆดๅ
งๅฎนใ
|
|
183
|
+
this.etagMap.delete(locale)
|
|
184
|
+
return this.fetchWithTimeout(locale)
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
// ่็้ฏ่ชค็ๆ
็ขผ
|
|
@@ -159,6 +159,52 @@ describe('TranslationLoaders', () => {
|
|
|
159
159
|
expect(callCount).toBe(2)
|
|
160
160
|
})
|
|
161
161
|
|
|
162
|
+
it('should refetch when server returns 304 but translation cache is missing', async () => {
|
|
163
|
+
let callCount = 0
|
|
164
|
+
globalThis.fetch = mock((_url: string, options?: any) => {
|
|
165
|
+
callCount++
|
|
166
|
+
const ifNoneMatch = options?.headers?.['If-None-Match']
|
|
167
|
+
|
|
168
|
+
if (!ifNoneMatch && callCount === 1) {
|
|
169
|
+
return Promise.resolve({
|
|
170
|
+
ok: true,
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: new Map([['ETag', '"v1"']]),
|
|
173
|
+
json: async () => ({ version: '1.0' }),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (callCount === 2) {
|
|
178
|
+
return Promise.resolve({
|
|
179
|
+
ok: true,
|
|
180
|
+
status: 304,
|
|
181
|
+
headers: new Map([['ETag', '"v1"']]),
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return Promise.resolve({
|
|
186
|
+
ok: true,
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: new Map([['ETag', '"v2"']]),
|
|
189
|
+
json: async () => ({ version: '2.0' }),
|
|
190
|
+
})
|
|
191
|
+
}) as any
|
|
192
|
+
|
|
193
|
+
const loader = new RemoteLoader({
|
|
194
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
195
|
+
etagCache: true,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const translations1 = await loader.load('en')
|
|
199
|
+
expect(translations1?.version).toBe('1.0')
|
|
200
|
+
|
|
201
|
+
;(loader as any).translationCache.clear()
|
|
202
|
+
|
|
203
|
+
const translations2 = await loader.load('en')
|
|
204
|
+
expect(translations2?.version).toBe('2.0')
|
|
205
|
+
expect(callCount).toBe(3)
|
|
206
|
+
})
|
|
207
|
+
|
|
162
208
|
it('should retry on failure', async () => {
|
|
163
209
|
let attemptCount = 0
|
|
164
210
|
globalThis.fetch = mock(() => {
|
|
@@ -213,6 +259,39 @@ describe('TranslationLoaders', () => {
|
|
|
213
259
|
expect(receivedHeaders['X-Custom']).toBe('value')
|
|
214
260
|
})
|
|
215
261
|
|
|
262
|
+
it('should unref request timeout timers', async () => {
|
|
263
|
+
const originalSetTimeout = global.setTimeout
|
|
264
|
+
const handle = {
|
|
265
|
+
unrefCalled: false,
|
|
266
|
+
unref() {
|
|
267
|
+
this.unrefCalled = true
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
globalThis.fetch = mock(() =>
|
|
272
|
+
Promise.resolve({
|
|
273
|
+
ok: true,
|
|
274
|
+
status: 200,
|
|
275
|
+
headers: new Map(),
|
|
276
|
+
json: async () => ({}),
|
|
277
|
+
})
|
|
278
|
+
) as any
|
|
279
|
+
|
|
280
|
+
global.setTimeout = ((_fn: () => void) => handle as any) as unknown as typeof setTimeout
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const loader = new RemoteLoader({
|
|
284
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
285
|
+
timeout: 100,
|
|
286
|
+
})
|
|
287
|
+
await loader.load('en')
|
|
288
|
+
|
|
289
|
+
expect(handle.unrefCalled).toBe(true)
|
|
290
|
+
} finally {
|
|
291
|
+
global.setTimeout = originalSetTimeout
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
216
295
|
it('should support fallback loader when all retries fail', async () => {
|
|
217
296
|
globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))) as any
|
|
218
297
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"noEmit": false,
|
|
7
|
+
"emitDeclarationOnly": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": false,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "src",
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"baseUrl": ".",
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"experimentalDecorators": true,
|
|
16
|
+
"emitDecoratorMetadata": true,
|
|
17
|
+
"types": ["bun-types"],
|
|
18
|
+
"paths": {
|
|
19
|
+
"@gravito/cosmos": ["./src/index.ts"],
|
|
20
|
+
"@gravito/core": ["../../packages/core/dist/index.d.ts"],
|
|
21
|
+
"@gravito/core/*": ["../../packages/core/dist/*"],
|
|
22
|
+
"@gravito/*": ["../../packages/*/dist/index.d.ts"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
|
26
|
+
"exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.test.tsx"]
|
|
27
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -2,11 +2,6 @@
|
|
|
2
2
|
"extends": "../../tsconfig.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"outDir": "./dist",
|
|
5
|
-
"baseUrl": ".",
|
|
6
|
-
"paths": {
|
|
7
|
-
"@gravito/core": ["../../packages/core/src/index.ts"],
|
|
8
|
-
"@gravito/*": ["../../packages/*/src/index.ts"]
|
|
9
|
-
},
|
|
10
5
|
"types": ["bun-types"]
|
|
11
6
|
},
|
|
12
7
|
"include": ["src/**/*"],
|