@gravito/cosmos 3.2.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/I18nService.ts +5 -1
- package/src/errors/CosmosError.ts +20 -0
- package/src/errors/codes.ts +30 -0
- package/src/errors/index.ts +2 -0
- package/src/index.ts +4 -0
- package/src/loader.ts +76 -10
- package/src/loaders/EdgeKVLoader.ts +8 -2
- package/src/loaders/FileSystemLoader.ts +66 -6
- package/src/loaders/Json5Loader.ts +256 -0
- package/src/loaders/RemoteLoader.ts +11 -6
- package/tests/contract/cosmos-errors.contract.test.ts +73 -0
- 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
|
+
"version": "4.0.0",
|
|
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": "^
|
|
46
|
+
"@gravito/core": "^3.0.0",
|
|
47
|
+
"@gravito/photon": "^2.0.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/I18nService.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import type { GravitoMiddleware } from '@gravito/core'
|
|
8
8
|
import { LRUCache } from 'lru-cache'
|
|
9
9
|
import { type HMRConfig, HMRWatcher } from './HMRWatcher'
|
|
10
|
+
import { CosmosError } from './errors/CosmosError'
|
|
11
|
+
import { CosmosErrorCodes } from './errors/codes'
|
|
10
12
|
import { loadLocale } from './loader'
|
|
11
13
|
import type { TranslationLoader } from './loaders/TranslationLoader'
|
|
12
14
|
|
|
@@ -816,7 +818,9 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
816
818
|
case 'empty':
|
|
817
819
|
return ''
|
|
818
820
|
case 'throw':
|
|
819
|
-
throw new
|
|
821
|
+
throw new CosmosError(500, CosmosErrorCodes.MISSING_TRANSLATION, {
|
|
822
|
+
message: `Missing translation: ${key}`,
|
|
823
|
+
})
|
|
820
824
|
default:
|
|
821
825
|
return key
|
|
822
826
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SystemException, type ExceptionOptions } from '@gravito/core'
|
|
2
|
+
import type { CosmosErrorCode } from './codes'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base error class for @gravito/cosmos i18n operations.
|
|
6
|
+
* Extends SystemException as cosmos is a general system utility (not a domain/auth module).
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export class CosmosError extends SystemException {
|
|
11
|
+
constructor(
|
|
12
|
+
status: number,
|
|
13
|
+
code: CosmosErrorCode,
|
|
14
|
+
options: ExceptionOptions = {}
|
|
15
|
+
) {
|
|
16
|
+
super(status, code, options)
|
|
17
|
+
this.name = 'CosmosError'
|
|
18
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error codes for @gravito/cosmos i18n operations.
|
|
3
|
+
* Follows the dot-separated namespace convention used across Gravito.
|
|
4
|
+
*
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export const CosmosErrorCodes = {
|
|
8
|
+
// Translation errors
|
|
9
|
+
MISSING_TRANSLATION: 'cosmos.missing_translation',
|
|
10
|
+
|
|
11
|
+
// Loader errors
|
|
12
|
+
LOADER_FAILED: 'cosmos.loader_failed',
|
|
13
|
+
UNSUPPORTED_FORMAT: 'cosmos.unsupported_format',
|
|
14
|
+
|
|
15
|
+
// Filesystem loader errors
|
|
16
|
+
FILE_NOT_FOUND: 'cosmos.file_not_found',
|
|
17
|
+
FILE_READ_FAILED: 'cosmos.file_read_failed',
|
|
18
|
+
|
|
19
|
+
// Remote loader errors
|
|
20
|
+
HTTP_ERROR: 'cosmos.http_error',
|
|
21
|
+
|
|
22
|
+
// Runtime errors
|
|
23
|
+
EDGE_RUNTIME_UNSUPPORTED: 'cosmos.edge_runtime_unsupported',
|
|
24
|
+
|
|
25
|
+
// KV storage errors
|
|
26
|
+
KV_PUT_UNSUPPORTED: 'cosmos.kv_put_unsupported',
|
|
27
|
+
KV_DELETE_UNSUPPORTED: 'cosmos.kv_delete_unsupported',
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
export type CosmosErrorCode = (typeof CosmosErrorCodes)[keyof typeof CosmosErrorCodes]
|
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
|
|
@@ -93,3 +94,6 @@ export * from './loaders/VercelKVLoader'
|
|
|
93
94
|
// Runtime utilities
|
|
94
95
|
export * from './runtime/detector'
|
|
95
96
|
export * from './runtime/path-utils'
|
|
97
|
+
|
|
98
|
+
// Errors
|
|
99
|
+
export * from './errors'
|
package/src/loader.ts
CHANGED
|
@@ -21,14 +21,59 @@
|
|
|
21
21
|
|
|
22
22
|
import { readdir, readFile } from 'node:fs/promises'
|
|
23
23
|
import { join, parse } from 'node:path'
|
|
24
|
+
import { CosmosError } from './errors/CosmosError'
|
|
25
|
+
import { CosmosErrorCodes } from './errors/codes'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 偵測 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
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析 JSON5 格式字串(降級到 json5 npm 套件)
|
|
41
|
+
*
|
|
42
|
+
* @param content - 要解析的 JSON5 字串
|
|
43
|
+
* @returns 解析結果
|
|
44
|
+
*/
|
|
45
|
+
async function parseJson5(content: string): Promise<unknown> {
|
|
46
|
+
if (hasBunJson5()) {
|
|
47
|
+
const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
|
|
48
|
+
parse: (text: string) => unknown
|
|
49
|
+
}
|
|
50
|
+
return bunJson5.parse(content)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 使用變數作為路徑以避免靜態型別解析(json5 為可選 peer dependency)
|
|
54
|
+
try {
|
|
55
|
+
const pkgName = 'json5'
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
const json5Module = await (import(pkgName) as Promise<any>)
|
|
58
|
+
const json5 = json5Module.default ?? json5Module
|
|
59
|
+
return (json5 as { parse: (text: string) => unknown }).parse(content)
|
|
60
|
+
} catch {
|
|
61
|
+
throw new CosmosError(500, CosmosErrorCodes.UNSUPPORTED_FORMAT, {
|
|
62
|
+
message:
|
|
63
|
+
'[Cosmos] JSON5 parsing requires either Bun v1.2+ or the `json5` npm package. ' +
|
|
64
|
+
'Please install it: bun add json5',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
24
68
|
|
|
25
69
|
/**
|
|
26
70
|
* 從目錄載入所有翻譯檔案
|
|
27
71
|
*
|
|
28
|
-
* 掃描指定目錄中的所有 JSON 檔案並載入為翻譯資源
|
|
72
|
+
* 掃描指定目錄中的所有 JSON 與 JSON5 檔案並載入為翻譯資源
|
|
73
|
+
* 若同一語言同時存在 `.json` 與 `.json5`,以 `.json5` 優先
|
|
29
74
|
* 檔案名稱(不含副檔名)將作為語言代碼
|
|
30
75
|
*
|
|
31
|
-
* @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader
|
|
76
|
+
* @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader 或 Json5Loader
|
|
32
77
|
* @param directory - 翻譯目錄的絕對路徑
|
|
33
78
|
* @returns 語言代碼到翻譯資源的對應表
|
|
34
79
|
* @public
|
|
@@ -37,7 +82,7 @@ import { join, parse } from 'node:path'
|
|
|
37
82
|
* ```
|
|
38
83
|
* /lang
|
|
39
84
|
* /en.json -> { "welcome": "Hello" }
|
|
40
|
-
* /zh-TW.
|
|
85
|
+
* /zh-TW.json5 -> { "welcome": "你好" } // 支援 JSON5
|
|
41
86
|
* ```
|
|
42
87
|
*/
|
|
43
88
|
export async function loadTranslations(
|
|
@@ -48,13 +93,29 @@ export async function loadTranslations(
|
|
|
48
93
|
try {
|
|
49
94
|
const files = await readdir(directory)
|
|
50
95
|
|
|
96
|
+
// 收集所有支援格式的翻譯檔案,以 locale 為鍵,避免重複處理
|
|
97
|
+
const localeFiles = new Map<string, string>()
|
|
98
|
+
|
|
51
99
|
for (const file of files) {
|
|
52
|
-
if (!file.endsWith('.json')) {
|
|
100
|
+
if (!file.endsWith('.json') && !file.endsWith('.json5')) {
|
|
53
101
|
continue
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
const locale = parse(file).name
|
|
57
|
-
const
|
|
104
|
+
const locale = parse(file).name
|
|
105
|
+
const existing = localeFiles.get(locale)
|
|
106
|
+
|
|
107
|
+
// json5 優先:若同一 locale 已有 .json,json5 可覆蓋;反之不覆蓋
|
|
108
|
+
if (!existing || file.endsWith('.json5')) {
|
|
109
|
+
localeFiles.set(locale, file)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [locale, file] of localeFiles) {
|
|
114
|
+
const translationsForLocale = await loadLocale(
|
|
115
|
+
directory,
|
|
116
|
+
locale,
|
|
117
|
+
parse(file).ext as '.json' | '.json5'
|
|
118
|
+
)
|
|
58
119
|
if (translationsForLocale) {
|
|
59
120
|
translations[locale] = translationsForLocale
|
|
60
121
|
}
|
|
@@ -71,21 +132,26 @@ export async function loadTranslations(
|
|
|
71
132
|
/**
|
|
72
133
|
* 載入指定語言的翻譯資源
|
|
73
134
|
*
|
|
74
|
-
* 期望在指定目錄中找到 `{locale}.json` 格式的檔案
|
|
135
|
+
* 期望在指定目錄中找到 `{locale}.json` 或 `{locale}.json5` 格式的檔案
|
|
75
136
|
*
|
|
76
|
-
* @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader
|
|
137
|
+
* @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader 或 Json5Loader
|
|
77
138
|
* @param directory - 包含翻譯檔案的目錄
|
|
78
139
|
* @param locale - 要載入的語言代碼
|
|
140
|
+
* @param extension - 檔案副檔名,預設 `.json`,可傳入 `.json5`
|
|
79
141
|
* @returns 翻譯資源,載入失敗則返回 null
|
|
80
142
|
* @public
|
|
81
143
|
*/
|
|
82
144
|
export async function loadLocale(
|
|
83
145
|
directory: string,
|
|
84
|
-
locale: string
|
|
146
|
+
locale: string,
|
|
147
|
+
extension: '.json' | '.json5' = '.json'
|
|
85
148
|
): Promise<Record<string, string> | null> {
|
|
86
|
-
const filePath = join(directory, `${locale}
|
|
149
|
+
const filePath = join(directory, `${locale}${extension}`)
|
|
87
150
|
try {
|
|
88
151
|
const content = await readFile(filePath, 'utf-8')
|
|
152
|
+
if (extension === '.json5') {
|
|
153
|
+
return (await parseJson5(content)) as Record<string, string>
|
|
154
|
+
}
|
|
89
155
|
return JSON.parse(content)
|
|
90
156
|
} catch {
|
|
91
157
|
return null
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TranslationMap } from '../I18nService'
|
|
8
|
+
import { CosmosError } from '../errors/CosmosError'
|
|
9
|
+
import { CosmosErrorCodes } from '../errors/codes'
|
|
8
10
|
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -219,7 +221,9 @@ export class EdgeKVLoader implements TranslationLoaderChain {
|
|
|
219
221
|
*/
|
|
220
222
|
async put(locale: string, translations: TranslationMap): Promise<void> {
|
|
221
223
|
if (!this.storage.put) {
|
|
222
|
-
throw new
|
|
224
|
+
throw new CosmosError(500, CosmosErrorCodes.KV_PUT_UNSUPPORTED, {
|
|
225
|
+
message: '[EdgeKVLoader] Storage does not support put operation',
|
|
226
|
+
})
|
|
223
227
|
}
|
|
224
228
|
|
|
225
229
|
const key = this.buildKey(locale)
|
|
@@ -239,7 +243,9 @@ export class EdgeKVLoader implements TranslationLoaderChain {
|
|
|
239
243
|
*/
|
|
240
244
|
async delete(locale: string): Promise<void> {
|
|
241
245
|
if (!this.storage.delete) {
|
|
242
|
-
throw new
|
|
246
|
+
throw new CosmosError(500, CosmosErrorCodes.KV_DELETE_UNSUPPORTED, {
|
|
247
|
+
message: '[EdgeKVLoader] Storage does not support delete operation',
|
|
248
|
+
})
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
const key = this.buildKey(locale)
|
|
@@ -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
|
*
|
|
@@ -19,9 +19,25 @@
|
|
|
19
19
|
import { readFile } from 'node:fs/promises'
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
import type { TranslationMap } from '../I18nService'
|
|
22
|
+
import { CosmosError } from '../errors/CosmosError'
|
|
23
|
+
import { CosmosErrorCodes } from '../errors/codes'
|
|
22
24
|
import { detectRuntime } from '../runtime/detector'
|
|
23
25
|
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 偵測 Bun 運行時是否原生支援 JSON5 解析
|
|
29
|
+
*
|
|
30
|
+
* Bun v1.2+ 提供 `Bun.JSON5` 全域物件
|
|
31
|
+
*
|
|
32
|
+
* @returns 是否具備 Bun 原生 JSON5 能力
|
|
33
|
+
*/
|
|
34
|
+
function hasBunJson5(): boolean {
|
|
35
|
+
return (
|
|
36
|
+
typeof globalThis.Bun !== 'undefined' &&
|
|
37
|
+
typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* 檔案系統載入器配置
|
|
27
43
|
*
|
|
@@ -39,6 +55,8 @@ export interface FileSystemLoaderConfig extends LoaderConfig {
|
|
|
39
55
|
/**
|
|
40
56
|
* 檔案副檔名
|
|
41
57
|
*
|
|
58
|
+
* 支援 `.json` 與 `.json5` 兩種格式
|
|
59
|
+
*
|
|
42
60
|
* @default '.json'
|
|
43
61
|
*/
|
|
44
62
|
extension?: string
|
|
@@ -80,10 +98,11 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
80
98
|
// 運行時檢查
|
|
81
99
|
const runtime = detectRuntime()
|
|
82
100
|
if (runtime === 'edge') {
|
|
83
|
-
throw new
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
|
|
101
|
+
throw new CosmosError(500, CosmosErrorCodes.EDGE_RUNTIME_UNSUPPORTED, {
|
|
102
|
+
message:
|
|
103
|
+
'[FileSystemLoader] This loader requires Node.js and cannot run in Edge Runtime. ' +
|
|
104
|
+
'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.',
|
|
105
|
+
})
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
this.name = config.name || 'FileSystemLoader'
|
|
@@ -94,6 +113,8 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
94
113
|
/**
|
|
95
114
|
* 載入指定語言的翻譯資源
|
|
96
115
|
*
|
|
116
|
+
* 根據 extension 自動判斷使用 JSON 或 JSON5 解析器
|
|
117
|
+
*
|
|
97
118
|
* @param locale - 語言代碼
|
|
98
119
|
* @returns 翻譯資源,載入失敗則返回 null
|
|
99
120
|
*/
|
|
@@ -101,7 +122,10 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
101
122
|
try {
|
|
102
123
|
const filePath = join(this.baseDir, `${locale}${this.extension}`)
|
|
103
124
|
const content = await readFile(filePath, 'utf-8')
|
|
104
|
-
const translations =
|
|
125
|
+
const translations =
|
|
126
|
+
this.extension === '.json5'
|
|
127
|
+
? await this.parseJson5(content)
|
|
128
|
+
: (JSON.parse(content) as TranslationMap)
|
|
105
129
|
return translations
|
|
106
130
|
} catch (_error) {
|
|
107
131
|
// 如果有備用載入器,嘗試使用備用載入器
|
|
@@ -112,6 +136,42 @@ export class FileSystemLoader implements TranslationLoaderChain {
|
|
|
112
136
|
}
|
|
113
137
|
}
|
|
114
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 解析 JSON5 內容
|
|
141
|
+
*
|
|
142
|
+
* 優先使用 Bun 原生 JSON5 解析器(Bun v1.2+),
|
|
143
|
+
* 若不可用則嘗試動態載入 `json5` npm 套件作為降級方案
|
|
144
|
+
*
|
|
145
|
+
* @param content - 要解析的 JSON5 字串
|
|
146
|
+
* @returns 解析後的翻譯資源
|
|
147
|
+
* @throws {Error} 若 Bun 原生與 json5 套件均不可用時
|
|
148
|
+
*/
|
|
149
|
+
private async parseJson5(content: string): Promise<TranslationMap> {
|
|
150
|
+
// 優先使用 Bun 原生 JSON5 解析器
|
|
151
|
+
if (hasBunJson5()) {
|
|
152
|
+
const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
|
|
153
|
+
parse: (text: string) => unknown
|
|
154
|
+
}
|
|
155
|
+
return bunJson5.parse(content) as TranslationMap
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 降級方案:動態載入 json5 npm 套件
|
|
159
|
+
// 使用變數作為路徑以避免靜態型別解析(json5 為可選 peer dependency)
|
|
160
|
+
try {
|
|
161
|
+
const pkgName = 'json5'
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
const json5Module = await (import(pkgName) as Promise<any>)
|
|
164
|
+
const json5 = json5Module.default ?? json5Module
|
|
165
|
+
return (json5 as { parse: (text: string) => unknown }).parse(content) as TranslationMap
|
|
166
|
+
} catch {
|
|
167
|
+
throw new CosmosError(500, CosmosErrorCodes.UNSUPPORTED_FORMAT, {
|
|
168
|
+
message:
|
|
169
|
+
'[FileSystemLoader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
|
|
170
|
+
'or the `json5` npm package. Please install it: bun add json5',
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
/**
|
|
116
176
|
* 設定備用載入器
|
|
117
177
|
*
|
|
@@ -0,0 +1,256 @@
|
|
|
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 { CosmosError } from '../errors/CosmosError'
|
|
21
|
+
import { CosmosErrorCodes } from '../errors/codes'
|
|
22
|
+
import { detectRuntime } from '../runtime/detector'
|
|
23
|
+
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Json5Loader 優先級策略
|
|
27
|
+
*
|
|
28
|
+
* - `json5-first`: 優先嘗試 `.json5`,失敗後退到 `.json`
|
|
29
|
+
* - `json-first`: 優先嘗試 `.json`,失敗後退到 `.json5`
|
|
30
|
+
* - `json5-only`: 只載入 `.json5`,不降級
|
|
31
|
+
* - `json-only`: 只載入 `.json`,不降級
|
|
32
|
+
*
|
|
33
|
+
* @public
|
|
34
|
+
* @since 3.2.0
|
|
35
|
+
*/
|
|
36
|
+
export type Json5LoaderPriority = 'json5-first' | 'json-first' | 'json5-only' | 'json-only'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Json5Loader 配置
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
* @since 3.2.0
|
|
43
|
+
*/
|
|
44
|
+
export interface Json5LoaderConfig extends LoaderConfig {
|
|
45
|
+
/**
|
|
46
|
+
* 翻譯檔案所在的基礎目錄
|
|
47
|
+
*
|
|
48
|
+
* @example '/app/lang'
|
|
49
|
+
*/
|
|
50
|
+
baseDir: string
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 載入優先級策略
|
|
54
|
+
*
|
|
55
|
+
* @default 'json5-first'
|
|
56
|
+
*/
|
|
57
|
+
priority?: Json5LoaderPriority
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 偵測 Bun 運行時是否原生支援 JSON5 解析
|
|
62
|
+
*
|
|
63
|
+
* Bun v1.2+ 提供 `Bun.JSON5` 全域物件
|
|
64
|
+
*
|
|
65
|
+
* @returns 是否具備 Bun 原生 JSON5 能力
|
|
66
|
+
*/
|
|
67
|
+
function hasBunJson5(): boolean {
|
|
68
|
+
return (
|
|
69
|
+
typeof globalThis.Bun !== 'undefined' &&
|
|
70
|
+
typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 解析 JSON5 格式字串
|
|
76
|
+
*
|
|
77
|
+
* 優先使用 Bun 原生 JSON5 解析器(Bun v1.2+),
|
|
78
|
+
* 降級至 `json5` npm 套件
|
|
79
|
+
*
|
|
80
|
+
* @param content - 要解析的 JSON5 字串
|
|
81
|
+
* @returns 解析結果
|
|
82
|
+
* @throws {Error} 若兩種方式均不可用
|
|
83
|
+
*/
|
|
84
|
+
async function parseJson5Content(content: string): Promise<unknown> {
|
|
85
|
+
if (hasBunJson5()) {
|
|
86
|
+
const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
|
|
87
|
+
parse: (text: string) => unknown
|
|
88
|
+
}
|
|
89
|
+
return bunJson5.parse(content)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 使用變數作為路徑以避免靜態型別解析(json5 為可選 peer dependency)
|
|
93
|
+
try {
|
|
94
|
+
const pkgName = 'json5'
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
const json5Module = await (import(pkgName) as Promise<any>)
|
|
97
|
+
const json5 = json5Module.default ?? json5Module
|
|
98
|
+
return (json5 as { parse: (text: string) => unknown }).parse(content)
|
|
99
|
+
} catch {
|
|
100
|
+
throw new CosmosError(500, CosmosErrorCodes.UNSUPPORTED_FORMAT, {
|
|
101
|
+
message:
|
|
102
|
+
'[Json5Loader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
|
|
103
|
+
'or the `json5` npm package. Please install it: bun add json5',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* JSON5 混合翻譯載入器
|
|
110
|
+
*
|
|
111
|
+
* 支援混合載入 `.json5` 與 `.json` 格式的翻譯檔案。
|
|
112
|
+
* 透過優先級配置彈性控制載入策略,並支援 fallback 降級鏈。
|
|
113
|
+
*
|
|
114
|
+
* @public
|
|
115
|
+
* @since 3.2.0
|
|
116
|
+
*
|
|
117
|
+
* @example json5-first 優先(預設)
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const loader = new Json5Loader({
|
|
120
|
+
* baseDir: '/app/lang',
|
|
121
|
+
* priority: 'json5-first'
|
|
122
|
+
* })
|
|
123
|
+
* // 嘗試 zh-TW.json5,若不存在則退回 zh-TW.json
|
|
124
|
+
* const translations = await loader.load('zh-TW')
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* @example 搭配 fallback 鏈
|
|
128
|
+
* ```typescript
|
|
129
|
+
* const loader = new Json5Loader({ baseDir: '/app/lang' })
|
|
130
|
+
* .fallback(new MemoryLoader({ en: { welcome: 'Hello' } }))
|
|
131
|
+
*
|
|
132
|
+
* const translations = await loader.load('en')
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export class Json5Loader implements TranslationLoaderChain {
|
|
136
|
+
public readonly name: string
|
|
137
|
+
private readonly baseDir: string
|
|
138
|
+
private readonly priority: Json5LoaderPriority
|
|
139
|
+
private fallbackLoader?: TranslationLoader
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 建立 Json5Loader 實例
|
|
143
|
+
*
|
|
144
|
+
* @param config - 載入器配置
|
|
145
|
+
* @throws {Error} 如果在 Edge Runtime 環境中使用
|
|
146
|
+
*/
|
|
147
|
+
constructor(config: Json5LoaderConfig) {
|
|
148
|
+
// 運行時檢查
|
|
149
|
+
const runtime = detectRuntime()
|
|
150
|
+
if (runtime === 'edge') {
|
|
151
|
+
throw new CosmosError(500, CosmosErrorCodes.EDGE_RUNTIME_UNSUPPORTED, {
|
|
152
|
+
message:
|
|
153
|
+
'[Json5Loader] This loader requires Node.js and cannot run in Edge Runtime. ' +
|
|
154
|
+
'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.',
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.name = config.name ?? 'Json5Loader'
|
|
159
|
+
this.baseDir = config.baseDir
|
|
160
|
+
this.priority = config.priority ?? 'json5-first'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 載入指定語言的翻譯資源
|
|
165
|
+
*
|
|
166
|
+
* 根據優先級策略嘗試不同副檔名的檔案
|
|
167
|
+
*
|
|
168
|
+
* @param locale - 語言代碼
|
|
169
|
+
* @returns 翻譯資源,所有嘗試均失敗則返回 null
|
|
170
|
+
*/
|
|
171
|
+
async load(locale: string): Promise<TranslationMap | null> {
|
|
172
|
+
const result = await this.loadByPriority(locale)
|
|
173
|
+
|
|
174
|
+
if (result !== null) {
|
|
175
|
+
return result
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 嘗試外部 fallback 載入器
|
|
179
|
+
if (this.fallbackLoader) {
|
|
180
|
+
return this.fallbackLoader.load(locale)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 設定備用載入器
|
|
188
|
+
*
|
|
189
|
+
* @param loader - 備用載入器
|
|
190
|
+
* @returns 當前實例,支援鏈式調用
|
|
191
|
+
*/
|
|
192
|
+
fallback(loader: TranslationLoader): TranslationLoaderChain {
|
|
193
|
+
this.fallbackLoader = loader
|
|
194
|
+
return this
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 取得當前優先級策略
|
|
199
|
+
*
|
|
200
|
+
* @returns 優先級策略字串
|
|
201
|
+
*/
|
|
202
|
+
getPriority(): Json5LoaderPriority {
|
|
203
|
+
return this.priority
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 根據優先級策略依序嘗試載入翻譯檔案
|
|
208
|
+
*
|
|
209
|
+
* @param locale - 語言代碼
|
|
210
|
+
* @returns 翻譯資源,或 null
|
|
211
|
+
*/
|
|
212
|
+
private async loadByPriority(locale: string): Promise<TranslationMap | null> {
|
|
213
|
+
switch (this.priority) {
|
|
214
|
+
case 'json5-first':
|
|
215
|
+
return (await this.tryLoadJson5(locale)) ?? (await this.tryLoadJson(locale))
|
|
216
|
+
case 'json-first':
|
|
217
|
+
return (await this.tryLoadJson(locale)) ?? (await this.tryLoadJson5(locale))
|
|
218
|
+
case 'json5-only':
|
|
219
|
+
return this.tryLoadJson5(locale)
|
|
220
|
+
case 'json-only':
|
|
221
|
+
return this.tryLoadJson(locale)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 嘗試載入 `.json5` 格式的翻譯檔案
|
|
227
|
+
*
|
|
228
|
+
* @param locale - 語言代碼
|
|
229
|
+
* @returns 翻譯資源,或 null
|
|
230
|
+
*/
|
|
231
|
+
private async tryLoadJson5(locale: string): Promise<TranslationMap | null> {
|
|
232
|
+
try {
|
|
233
|
+
const filePath = join(this.baseDir, `${locale}.json5`)
|
|
234
|
+
const content = await readFile(filePath, 'utf-8')
|
|
235
|
+
return (await parseJson5Content(content)) as TranslationMap
|
|
236
|
+
} catch {
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 嘗試載入 `.json` 格式的翻譯檔案
|
|
243
|
+
*
|
|
244
|
+
* @param locale - 語言代碼
|
|
245
|
+
* @returns 翻譯資源,或 null
|
|
246
|
+
*/
|
|
247
|
+
private async tryLoadJson(locale: string): Promise<TranslationMap | null> {
|
|
248
|
+
try {
|
|
249
|
+
const filePath = join(this.baseDir, `${locale}.json`)
|
|
250
|
+
const content = await readFile(filePath, 'utf-8')
|
|
251
|
+
return JSON.parse(content) as TranslationMap
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TranslationMap } from '../I18nService'
|
|
8
|
+
import { CosmosError } from '../errors/CosmosError'
|
|
9
|
+
import { CosmosErrorCodes } from '../errors/codes'
|
|
8
10
|
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -124,16 +126,12 @@ export class RemoteLoader implements TranslationLoaderChain {
|
|
|
124
126
|
* @returns 翻譯資源,載入失敗則返回 null
|
|
125
127
|
*/
|
|
126
128
|
async load(locale: string): Promise<TranslationMap | null> {
|
|
127
|
-
let _lastError: Error | null = null
|
|
128
|
-
|
|
129
129
|
// 重試邏輯
|
|
130
130
|
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
131
131
|
try {
|
|
132
132
|
const result = await this.fetchWithTimeout(locale)
|
|
133
133
|
return result
|
|
134
|
-
} catch
|
|
135
|
-
_lastError = error as Error
|
|
136
|
-
|
|
134
|
+
} catch {
|
|
137
135
|
// 如果不是最後一次嘗試,等待後重試
|
|
138
136
|
if (attempt < this.retries) {
|
|
139
137
|
const delay = this.retryDelay * 2 ** attempt
|
|
@@ -160,6 +158,7 @@ export class RemoteLoader implements TranslationLoaderChain {
|
|
|
160
158
|
const url = this.url.replace(':locale', locale)
|
|
161
159
|
const controller = new AbortController()
|
|
162
160
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
161
|
+
timeoutId.unref?.()
|
|
163
162
|
|
|
164
163
|
try {
|
|
165
164
|
const headers: Record<string, string> = { ...this.headers }
|
|
@@ -181,11 +180,17 @@ export class RemoteLoader implements TranslationLoaderChain {
|
|
|
181
180
|
if (cached) {
|
|
182
181
|
return cached
|
|
183
182
|
}
|
|
183
|
+
|
|
184
|
+
// ETag 與本地翻譯快取脫鉤時,丟棄 stale ETag 後重新抓取完整內容。
|
|
185
|
+
this.etagMap.delete(locale)
|
|
186
|
+
return this.fetchWithTimeout(locale)
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
// 處理錯誤狀態碼
|
|
187
190
|
if (!response.ok) {
|
|
188
|
-
throw new
|
|
191
|
+
throw new CosmosError(response.status, CosmosErrorCodes.HTTP_ERROR, {
|
|
192
|
+
message: `HTTP error! status: ${response.status}`,
|
|
193
|
+
})
|
|
189
194
|
}
|
|
190
195
|
|
|
191
196
|
const data = (await response.json()) as TranslationMap
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { GravitoException, SystemException } from '@gravito/core'
|
|
3
|
+
import { CosmosError } from '../../src/errors/CosmosError'
|
|
4
|
+
import { CosmosErrorCodes } from '../../src/errors/codes'
|
|
5
|
+
|
|
6
|
+
describe('CosmosError contract', () => {
|
|
7
|
+
it('satisfies GravitoException contract', () => {
|
|
8
|
+
const err = new CosmosError(500, CosmosErrorCodes.MISSING_TRANSLATION, {
|
|
9
|
+
message: 'Missing translation: common.welcome',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(err).toBeInstanceOf(Error)
|
|
13
|
+
expect(err).toBeInstanceOf(GravitoException)
|
|
14
|
+
expect(err).toBeInstanceOf(SystemException)
|
|
15
|
+
expect(err).toBeInstanceOf(CosmosError)
|
|
16
|
+
|
|
17
|
+
expect(err.code).toBe('cosmos.missing_translation')
|
|
18
|
+
expect(err.status).toBe(500)
|
|
19
|
+
expect(typeof err.code).toBe('string')
|
|
20
|
+
expect(typeof err.status).toBe('number')
|
|
21
|
+
expect(err.name).toBe('CosmosError')
|
|
22
|
+
expect(err.name).not.toBe('Error')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('extends SystemException (not DomainException or AuthException)', () => {
|
|
26
|
+
const err = new CosmosError(500, CosmosErrorCodes.LOADER_FAILED)
|
|
27
|
+
expect(err).toBeInstanceOf(SystemException)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('preserves message', () => {
|
|
31
|
+
const err = new CosmosError(500, CosmosErrorCodes.MISSING_TRANSLATION, {
|
|
32
|
+
message: 'Missing translation: test.key',
|
|
33
|
+
})
|
|
34
|
+
expect(err.message).toBe('Missing translation: test.key')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('preserves cause', () => {
|
|
38
|
+
const cause = new Error('original error')
|
|
39
|
+
const err = new CosmosError(500, CosmosErrorCodes.LOADER_FAILED, { cause })
|
|
40
|
+
expect(err.cause).toBe(cause)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('instanceof chain holds for all error codes', () => {
|
|
44
|
+
const codes = Object.values(CosmosErrorCodes)
|
|
45
|
+
for (const code of codes) {
|
|
46
|
+
const err = new CosmosError(500, code)
|
|
47
|
+
expect(err).toBeInstanceOf(CosmosError)
|
|
48
|
+
expect(err).toBeInstanceOf(SystemException)
|
|
49
|
+
expect(err).toBeInstanceOf(GravitoException)
|
|
50
|
+
expect(err).toBeInstanceOf(Error)
|
|
51
|
+
expect(err.code).toBe(code)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('all error codes are cosmos.* namespaced', () => {
|
|
56
|
+
const codes = Object.values(CosmosErrorCodes)
|
|
57
|
+
for (const code of codes) {
|
|
58
|
+
expect(code).toMatch(/^cosmos\./)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('CosmosErrorCodes covers expected domains', () => {
|
|
63
|
+
expect(CosmosErrorCodes.MISSING_TRANSLATION).toBe('cosmos.missing_translation')
|
|
64
|
+
expect(CosmosErrorCodes.LOADER_FAILED).toBe('cosmos.loader_failed')
|
|
65
|
+
expect(CosmosErrorCodes.UNSUPPORTED_FORMAT).toBe('cosmos.unsupported_format')
|
|
66
|
+
expect(CosmosErrorCodes.FILE_NOT_FOUND).toBe('cosmos.file_not_found')
|
|
67
|
+
expect(CosmosErrorCodes.FILE_READ_FAILED).toBe('cosmos.file_read_failed')
|
|
68
|
+
expect(CosmosErrorCodes.HTTP_ERROR).toBe('cosmos.http_error')
|
|
69
|
+
expect(CosmosErrorCodes.EDGE_RUNTIME_UNSUPPORTED).toBe('cosmos.edge_runtime_unsupported')
|
|
70
|
+
expect(CosmosErrorCodes.KV_PUT_UNSUPPORTED).toBe('cosmos.kv_put_unsupported')
|
|
71
|
+
expect(CosmosErrorCodes.KV_DELETE_UNSUPPORTED).toBe('cosmos.kv_delete_unsupported')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -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/**/*"],
|