@gravito/cosmos 3.1.0 → 3.2.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/MIGRATION.md +331 -0
- package/package.json +20 -5
- package/scripts/check-coverage.ts +64 -0
- package/src/HMRWatcher.ts +305 -0
- package/src/I18nService.ts +149 -9
- package/src/index.edge.ts +35 -0
- package/src/index.node.ts +20 -0
- package/src/index.ts +14 -0
- package/src/loader.ts +31 -14
- package/src/loaders/ChainedLoader.ts +117 -0
- package/src/loaders/CloudflareKVLoader.ts +194 -0
- package/src/loaders/EdgeKVLoader.ts +248 -0
- package/src/loaders/FileSystemLoader.ts +125 -0
- package/src/loaders/MemoryLoader.ts +161 -0
- package/src/loaders/RemoteLoader.ts +235 -0
- package/src/loaders/TranslationLoader.ts +98 -0
- package/src/loaders/VercelKVLoader.ts +192 -0
- package/src/runtime/detector.ts +97 -0
- package/src/runtime/path-utils.ts +169 -0
- package/tests/unit/detector.test.ts +1 -6
- package/tests/unit/edge-kv-loader.test.ts +202 -0
- package/tests/unit/hmr.test.ts +255 -0
- package/tests/unit/loader.test.ts +57 -29
- package/tests/unit/loaders.test.ts +332 -0
- package/tests/unit/memory-loader.test.ts +130 -0
- package/tests/unit/path-utils.test.ts +135 -0
- package/tests/unit/runtime-detector.test.ts +86 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/loaders/MemoryLoader.ts
|
|
3
|
+
* @module @gravito/cosmos/loaders
|
|
4
|
+
* @description 從記憶體載入翻譯資源的實現
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TranslationMap } from '../I18nService'
|
|
8
|
+
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 記憶體載入器配置
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
* @since 3.2.0
|
|
15
|
+
*/
|
|
16
|
+
export interface MemoryLoaderConfig extends LoaderConfig {
|
|
17
|
+
/**
|
|
18
|
+
* 預先載入的翻譯資源
|
|
19
|
+
*
|
|
20
|
+
* Key 為語言代碼,Value 為翻譯 Map
|
|
21
|
+
*/
|
|
22
|
+
translations: Record<string, TranslationMap>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 記憶體翻譯載入器
|
|
27
|
+
*
|
|
28
|
+
* 從記憶體載入預先打包的翻譯資源
|
|
29
|
+
* 適用於 Edge Runtime 環境或靜態翻譯場景
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
* @since 3.2.0
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* import { MemoryLoader } from '@gravito/cosmos'
|
|
37
|
+
* import en from './lang/en.json'
|
|
38
|
+
* import zhTW from './lang/zh-TW.json'
|
|
39
|
+
*
|
|
40
|
+
* const loader = new MemoryLoader({
|
|
41
|
+
* translations: {
|
|
42
|
+
* en,
|
|
43
|
+
* 'zh-TW': zhTW
|
|
44
|
+
* }
|
|
45
|
+
* })
|
|
46
|
+
*
|
|
47
|
+
* const translations = await loader.load('zh-TW')
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* ## 適用場景
|
|
51
|
+
*
|
|
52
|
+
* - **靜態翻譯**: Build-time 打包的翻譯資源
|
|
53
|
+
* - **Edge Runtime**: 無檔案系統的邊緣環境
|
|
54
|
+
* - **測試環境**: 快速的測試資料載入
|
|
55
|
+
* - **小型應用**: 翻譯量較少的應用(<100KB)
|
|
56
|
+
*
|
|
57
|
+
* ## 優缺點
|
|
58
|
+
*
|
|
59
|
+
* **優點**:
|
|
60
|
+
* - 極快的載入速度(記憶體讀取)
|
|
61
|
+
* - 無需網路請求
|
|
62
|
+
* - Edge Runtime 相容
|
|
63
|
+
*
|
|
64
|
+
* **缺點**:
|
|
65
|
+
* - 增加 Bundle Size
|
|
66
|
+
* - 無法動態更新翻譯
|
|
67
|
+
* - 不適合大量翻譯
|
|
68
|
+
*/
|
|
69
|
+
export class MemoryLoader implements TranslationLoaderChain {
|
|
70
|
+
public readonly name: string
|
|
71
|
+
private translations: Record<string, TranslationMap>
|
|
72
|
+
private fallbackLoader?: TranslationLoader
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 建立記憶體載入器實例
|
|
76
|
+
*
|
|
77
|
+
* @param config - 載入器配置
|
|
78
|
+
*/
|
|
79
|
+
constructor(config: MemoryLoaderConfig) {
|
|
80
|
+
this.name = config.name || 'MemoryLoader'
|
|
81
|
+
this.translations = config.translations
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 載入指定語言的翻譯資源
|
|
86
|
+
*
|
|
87
|
+
* @param locale - 語言代碼
|
|
88
|
+
* @returns 翻譯資源,如果不存在則嘗試使用 fallback loader
|
|
89
|
+
*/
|
|
90
|
+
async load(locale: string): Promise<TranslationMap | null> {
|
|
91
|
+
const translations = this.translations[locale]
|
|
92
|
+
|
|
93
|
+
if (translations) {
|
|
94
|
+
return translations
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 嘗試使用備用載入器
|
|
98
|
+
if (this.fallbackLoader) {
|
|
99
|
+
return this.fallbackLoader.load(locale)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 設定備用載入器
|
|
107
|
+
*
|
|
108
|
+
* @param loader - 備用載入器
|
|
109
|
+
* @returns 當前實例,支援鏈式調用
|
|
110
|
+
*/
|
|
111
|
+
fallback(loader: TranslationLoader): TranslationLoaderChain {
|
|
112
|
+
this.fallbackLoader = loader
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 新增或更新語言的翻譯資源
|
|
118
|
+
*
|
|
119
|
+
* @param locale - 語言代碼
|
|
120
|
+
* @param translations - 翻譯資源
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* loader.addTranslations('fr', { hello: 'Bonjour' })
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
addTranslations(locale: string, translations: TranslationMap): void {
|
|
128
|
+
this.translations[locale] = {
|
|
129
|
+
...(this.translations[locale] || {}),
|
|
130
|
+
...translations,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 移除語言的翻譯資源
|
|
136
|
+
*
|
|
137
|
+
* @param locale - 語言代碼
|
|
138
|
+
*/
|
|
139
|
+
removeTranslations(locale: string): void {
|
|
140
|
+
delete this.translations[locale]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 取得所有已載入的語言列表
|
|
145
|
+
*
|
|
146
|
+
* @returns 語言代碼陣列
|
|
147
|
+
*/
|
|
148
|
+
getLoadedLocales(): string[] {
|
|
149
|
+
return Object.keys(this.translations)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 檢查是否已載入指定語言
|
|
154
|
+
*
|
|
155
|
+
* @param locale - 語言代碼
|
|
156
|
+
* @returns 是否已載入
|
|
157
|
+
*/
|
|
158
|
+
hasLocale(locale: string): boolean {
|
|
159
|
+
return locale in this.translations
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/loaders/RemoteLoader.ts
|
|
3
|
+
* @module @gravito/cosmos/loaders
|
|
4
|
+
* @description 從遠端 HTTP API 載入翻譯資源的實現
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TranslationMap } from '../I18nService'
|
|
8
|
+
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 遠端載入器配置
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
* @since 3.1.0
|
|
15
|
+
*/
|
|
16
|
+
export interface RemoteLoaderConfig extends LoaderConfig {
|
|
17
|
+
/**
|
|
18
|
+
* 遠端 API 的 URL 模板
|
|
19
|
+
*
|
|
20
|
+
* 使用 `:locale` 作為語言代碼的佔位符
|
|
21
|
+
*
|
|
22
|
+
* @example 'https://cms.example.com/i18n/:locale'
|
|
23
|
+
*/
|
|
24
|
+
url: string
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* HTTP 請求標頭
|
|
28
|
+
*
|
|
29
|
+
* @example { 'Authorization': 'Bearer token', 'Accept': 'application/json' }
|
|
30
|
+
*/
|
|
31
|
+
headers?: Record<string, string>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 請求超時時間 (毫秒)
|
|
35
|
+
*
|
|
36
|
+
* @default 10000 (10 秒)
|
|
37
|
+
*/
|
|
38
|
+
timeout?: number
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 重試次數
|
|
42
|
+
*
|
|
43
|
+
* @default 3
|
|
44
|
+
*/
|
|
45
|
+
retries?: number
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 重試延遲 (毫秒)
|
|
49
|
+
*
|
|
50
|
+
* 使用指數退避策略: delay * (2 ^ attempt)
|
|
51
|
+
*
|
|
52
|
+
* @default 1000 (1 秒)
|
|
53
|
+
*/
|
|
54
|
+
retryDelay?: number
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 是否啟用 ETag 快取
|
|
58
|
+
*
|
|
59
|
+
* 啟用後,會在後續請求中發送 If-None-Match 標頭
|
|
60
|
+
* 如果伺服器返回 304 Not Modified,則使用快取的翻譯
|
|
61
|
+
*
|
|
62
|
+
* @default true
|
|
63
|
+
*/
|
|
64
|
+
etagCache?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 遠端翻譯載入器
|
|
69
|
+
*
|
|
70
|
+
* 從遠端 HTTP API 載入翻譯資源
|
|
71
|
+
* 支援 ETag 快取、重試機制、超時處理
|
|
72
|
+
*
|
|
73
|
+
* @public
|
|
74
|
+
* @since 3.1.0
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const loader = new RemoteLoader({
|
|
79
|
+
* url: 'https://api.example.com/i18n/:locale',
|
|
80
|
+
* headers: { 'Authorization': 'Bearer your-token' },
|
|
81
|
+
* timeout: 5000,
|
|
82
|
+
* retries: 3,
|
|
83
|
+
* etagCache: true
|
|
84
|
+
* })
|
|
85
|
+
*
|
|
86
|
+
* const translations = await loader.load('zh-TW')
|
|
87
|
+
* // GET https://api.example.com/i18n/zh-TW
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export class RemoteLoader implements TranslationLoaderChain {
|
|
91
|
+
public readonly name: string
|
|
92
|
+
private url: string
|
|
93
|
+
private headers: Record<string, string>
|
|
94
|
+
private timeout: number
|
|
95
|
+
private retries: number
|
|
96
|
+
private retryDelay: number
|
|
97
|
+
private etagCache: boolean
|
|
98
|
+
private fallbackLoader?: TranslationLoader
|
|
99
|
+
|
|
100
|
+
/** ETag 快取:locale -> etag */
|
|
101
|
+
private etagMap = new Map<string, string>()
|
|
102
|
+
/** 翻譯快取:locale -> translations (用於 ETag 304 回應) */
|
|
103
|
+
private translationCache = new Map<string, TranslationMap>()
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 建立遠端載入器實例
|
|
107
|
+
*
|
|
108
|
+
* @param config - 載入器配置
|
|
109
|
+
*/
|
|
110
|
+
constructor(config: RemoteLoaderConfig) {
|
|
111
|
+
this.name = config.name || 'RemoteLoader'
|
|
112
|
+
this.url = config.url
|
|
113
|
+
this.headers = config.headers || {}
|
|
114
|
+
this.timeout = config.timeout ?? 10000
|
|
115
|
+
this.retries = config.retries ?? 3
|
|
116
|
+
this.retryDelay = config.retryDelay ?? 1000
|
|
117
|
+
this.etagCache = config.etagCache ?? true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 載入指定語言的翻譯資源
|
|
122
|
+
*
|
|
123
|
+
* @param locale - 語言代碼
|
|
124
|
+
* @returns 翻譯資源,載入失敗則返回 null
|
|
125
|
+
*/
|
|
126
|
+
async load(locale: string): Promise<TranslationMap | null> {
|
|
127
|
+
let _lastError: Error | null = null
|
|
128
|
+
|
|
129
|
+
// 重試邏輯
|
|
130
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
131
|
+
try {
|
|
132
|
+
const result = await this.fetchWithTimeout(locale)
|
|
133
|
+
return result
|
|
134
|
+
} catch (error) {
|
|
135
|
+
_lastError = error as Error
|
|
136
|
+
|
|
137
|
+
// 如果不是最後一次嘗試,等待後重試
|
|
138
|
+
if (attempt < this.retries) {
|
|
139
|
+
const delay = this.retryDelay * 2 ** attempt
|
|
140
|
+
await this.sleep(delay)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 所有重試都失敗,嘗試備用載入器
|
|
146
|
+
if (this.fallbackLoader) {
|
|
147
|
+
return this.fallbackLoader.load(locale)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 發送 HTTP 請求並處理 ETag 快取
|
|
155
|
+
*
|
|
156
|
+
* @param locale - 語言代碼
|
|
157
|
+
* @returns 翻譯資源
|
|
158
|
+
*/
|
|
159
|
+
private async fetchWithTimeout(locale: string): Promise<TranslationMap | null> {
|
|
160
|
+
const url = this.url.replace(':locale', locale)
|
|
161
|
+
const controller = new AbortController()
|
|
162
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const headers: Record<string, string> = { ...this.headers }
|
|
166
|
+
|
|
167
|
+
// 如果啟用 ETag 快取且有快取的 ETag,加入 If-None-Match 標頭
|
|
168
|
+
if (this.etagCache && this.etagMap.has(locale)) {
|
|
169
|
+
headers['If-None-Match'] = this.etagMap.get(locale)!
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const response = await fetch(url, {
|
|
173
|
+
method: 'GET',
|
|
174
|
+
headers,
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// 處理 304 Not Modified
|
|
179
|
+
if (response.status === 304) {
|
|
180
|
+
const cached = this.translationCache.get(locale)
|
|
181
|
+
if (cached) {
|
|
182
|
+
return cached
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 處理錯誤狀態碼
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(`HTTP error! status: ${response.status}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const data = (await response.json()) as TranslationMap
|
|
192
|
+
|
|
193
|
+
// 儲存 ETag 和翻譯快取
|
|
194
|
+
if (this.etagCache) {
|
|
195
|
+
const etag = response.headers.get('ETag')
|
|
196
|
+
if (etag) {
|
|
197
|
+
this.etagMap.set(locale, etag)
|
|
198
|
+
this.translationCache.set(locale, data)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return data
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timeoutId)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 延遲函數
|
|
210
|
+
*
|
|
211
|
+
* @param ms - 延遲時間 (毫秒)
|
|
212
|
+
*/
|
|
213
|
+
private sleep(ms: number): Promise<void> {
|
|
214
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 設定備用載入器
|
|
219
|
+
*
|
|
220
|
+
* @param loader - 備用載入器
|
|
221
|
+
* @returns 當前實例,支援鏈式調用
|
|
222
|
+
*/
|
|
223
|
+
fallback(loader: TranslationLoader): TranslationLoaderChain {
|
|
224
|
+
this.fallbackLoader = loader
|
|
225
|
+
return this
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 清除 ETag 和翻譯快取
|
|
230
|
+
*/
|
|
231
|
+
clearCache(): void {
|
|
232
|
+
this.etagMap.clear()
|
|
233
|
+
this.translationCache.clear()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/loaders/TranslationLoader.ts
|
|
3
|
+
* @module @gravito/cosmos/loaders
|
|
4
|
+
* @description 抽象的翻譯載入器介面,支援多種翻譯來源
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TranslationMap } from '../I18nService'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 翻譯載入器介面
|
|
11
|
+
*
|
|
12
|
+
* 定義統一的載入介面,支援多種實現:
|
|
13
|
+
* - FileSystemLoader: 從檔案系統載入
|
|
14
|
+
* - RemoteLoader: 從 HTTP API 載入
|
|
15
|
+
* - DatabaseLoader: 從資料庫載入
|
|
16
|
+
* - EdgeLoader: Edge Runtime 相容的載入器
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
* @since 3.1.0
|
|
20
|
+
*/
|
|
21
|
+
export interface TranslationLoader {
|
|
22
|
+
/**
|
|
23
|
+
* 載入器名稱,用於識別和除錯
|
|
24
|
+
*/
|
|
25
|
+
readonly name: string
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 載入指定語言的翻譯資源
|
|
29
|
+
*
|
|
30
|
+
* @param locale - 要載入的語言代碼 (如 'en', 'zh-TW')
|
|
31
|
+
* @returns 翻譯資源,如果載入失敗則返回 null
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const translations = await loader.load('zh-TW')
|
|
36
|
+
* if (translations) {
|
|
37
|
+
* console.log(translations.welcome) // "歡迎"
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
load(locale: string): Promise<TranslationMap | null>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 支援鏈式組合的翻譯載入器介面
|
|
46
|
+
*
|
|
47
|
+
* 允許多個載入器組合,實現降級策略:
|
|
48
|
+
* 主要載入器失敗時,自動嘗試備用載入器
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
* @since 3.1.0
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const loader = new FileSystemLoader(config)
|
|
56
|
+
* .fallback(new RemoteLoader(remoteConfig))
|
|
57
|
+
* .fallback(new MemoryLoader(fallbackTranslations))
|
|
58
|
+
*
|
|
59
|
+
* // 載入順序: FileSystem -> Remote -> Memory
|
|
60
|
+
* const translations = await loader.load('zh-TW')
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export interface TranslationLoaderChain extends TranslationLoader {
|
|
64
|
+
/**
|
|
65
|
+
* 設定備用載入器
|
|
66
|
+
*
|
|
67
|
+
* 當當前載入器失敗時,會嘗試使用備用載入器
|
|
68
|
+
*
|
|
69
|
+
* @param loader - 備用載入器
|
|
70
|
+
* @returns 鏈式載入器實例,支援繼續串接
|
|
71
|
+
*/
|
|
72
|
+
fallback(loader: TranslationLoader): TranslationLoaderChain
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 載入器配置的基礎介面
|
|
77
|
+
*
|
|
78
|
+
* 所有載入器都應該接受的通用配置選項
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
* @since 3.1.0
|
|
82
|
+
*/
|
|
83
|
+
export interface LoaderConfig {
|
|
84
|
+
/**
|
|
85
|
+
* 載入器名稱 (可選)
|
|
86
|
+
*/
|
|
87
|
+
name?: string
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 快取策略 (可選)
|
|
91
|
+
*/
|
|
92
|
+
cache?: {
|
|
93
|
+
/** 是否啟用快取 */
|
|
94
|
+
enabled: boolean
|
|
95
|
+
/** 快取過期時間 (毫秒) */
|
|
96
|
+
ttl?: number
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/loaders/VercelKVLoader.ts
|
|
3
|
+
* @module @gravito/cosmos/loaders
|
|
4
|
+
* @description Vercel KV 載入器
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TranslationMap } from '../I18nService'
|
|
8
|
+
import type { KVStorage } from './EdgeKVLoader'
|
|
9
|
+
import { EdgeKVLoader } from './EdgeKVLoader'
|
|
10
|
+
import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Vercel KV 適配器
|
|
14
|
+
*
|
|
15
|
+
* 將 @vercel/kv 適配到 KVStorage 介面
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
* @since 3.2.0
|
|
19
|
+
*/
|
|
20
|
+
export class VercelKVAdapter implements KVStorage {
|
|
21
|
+
constructor(private kv: any) {} // @vercel/kv 型別
|
|
22
|
+
|
|
23
|
+
async get(key: string): Promise<string | null> {
|
|
24
|
+
return this.kv.get(key)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async put(key: string, value: string): Promise<void> {
|
|
28
|
+
await this.kv.set(key, value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async delete(key: string): Promise<void> {
|
|
32
|
+
await this.kv.del(key)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Vercel KV 載入器配置
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
* @since 3.2.0
|
|
41
|
+
*/
|
|
42
|
+
export interface VercelKVLoaderConfig extends LoaderConfig {
|
|
43
|
+
/**
|
|
44
|
+
* Vercel KV 實例
|
|
45
|
+
*
|
|
46
|
+
* 從 @vercel/kv 導入
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* import { kv } from '@vercel/kv'
|
|
51
|
+
*
|
|
52
|
+
* const loader = new VercelKVLoader({ kv })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
kv: any // @vercel/kv
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Key 前綴
|
|
59
|
+
*
|
|
60
|
+
* @default 'i18n'
|
|
61
|
+
*/
|
|
62
|
+
prefix?: string
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Key 模板
|
|
66
|
+
*
|
|
67
|
+
* @default ':prefix::locale'
|
|
68
|
+
*/
|
|
69
|
+
keyTemplate?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Vercel KV 載入器
|
|
74
|
+
*
|
|
75
|
+
* 從 Vercel KV (基於 Redis) 載入翻譯資源
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
* @since 3.2.0
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* // app/api/route.ts (Vercel Edge Function)
|
|
83
|
+
* import { OrbitCosmos, VercelKVLoader } from '@gravito/cosmos/edge'
|
|
84
|
+
* import { kv } from '@vercel/kv'
|
|
85
|
+
*
|
|
86
|
+
* const cosmos = new OrbitCosmos({
|
|
87
|
+
* defaultLocale: 'en',
|
|
88
|
+
* supportedLocales: ['en', 'zh-TW'],
|
|
89
|
+
* loaders: [
|
|
90
|
+
* new VercelKVLoader({
|
|
91
|
+
* kv,
|
|
92
|
+
* prefix: 'i18n'
|
|
93
|
+
* })
|
|
94
|
+
* ]
|
|
95
|
+
* })
|
|
96
|
+
*
|
|
97
|
+
* export async function GET() {
|
|
98
|
+
* const i18n = cosmos.clone('zh-TW')
|
|
99
|
+
* await i18n.ensureLocale('zh-TW')
|
|
100
|
+
* return new Response(i18n.t('welcome'))
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* ## 上傳翻譯到 Vercel KV
|
|
105
|
+
*
|
|
106
|
+
* 使用 Vercel CLI 或 API 上傳翻譯:
|
|
107
|
+
*
|
|
108
|
+
* ```typescript
|
|
109
|
+
* // scripts/upload-translations.ts
|
|
110
|
+
* import { kv } from '@vercel/kv'
|
|
111
|
+
* import { readFileSync } from 'fs'
|
|
112
|
+
*
|
|
113
|
+
* const locales = ['en', 'zh-TW', 'ja']
|
|
114
|
+
*
|
|
115
|
+
* for (const locale of locales) {
|
|
116
|
+
* const translations = JSON.parse(
|
|
117
|
+
* readFileSync(`./lang/${locale}.json`, 'utf-8')
|
|
118
|
+
* )
|
|
119
|
+
* await kv.set(`i18n:${locale}`, JSON.stringify(translations))
|
|
120
|
+
* console.log(`Uploaded ${locale}`)
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* ## 特點
|
|
125
|
+
*
|
|
126
|
+
* - **基於 Redis**: 高效能的記憶體資料庫
|
|
127
|
+
* - **全球複製**: 資料複製到多個區域
|
|
128
|
+
* - **低延遲**: Edge 環境快速存取
|
|
129
|
+
* - **動態更新**: 即時更新翻譯
|
|
130
|
+
* - **TTL 支援**: 可設定過期時間
|
|
131
|
+
*
|
|
132
|
+
* ## 環境變數
|
|
133
|
+
*
|
|
134
|
+
* 需要在 Vercel 專案中設定:
|
|
135
|
+
*
|
|
136
|
+
* ```env
|
|
137
|
+
* KV_URL=your-kv-url
|
|
138
|
+
* KV_REST_API_URL=your-api-url
|
|
139
|
+
* KV_REST_API_TOKEN=your-api-token
|
|
140
|
+
* KV_REST_API_READ_ONLY_TOKEN=your-read-only-token
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export class VercelKVLoader implements TranslationLoaderChain {
|
|
144
|
+
private loader: EdgeKVLoader
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 建立 Vercel KV 載入器實例
|
|
148
|
+
*
|
|
149
|
+
* @param config - 載入器配置
|
|
150
|
+
*/
|
|
151
|
+
constructor(config: VercelKVLoaderConfig) {
|
|
152
|
+
const adapter = new VercelKVAdapter(config.kv)
|
|
153
|
+
this.loader = new EdgeKVLoader({
|
|
154
|
+
storage: adapter,
|
|
155
|
+
prefix: config.prefix,
|
|
156
|
+
keyTemplate: config.keyTemplate,
|
|
157
|
+
name: config.name || 'VercelKVLoader',
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get name(): string {
|
|
162
|
+
return this.loader.name
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async load(locale: string): Promise<TranslationMap | null> {
|
|
166
|
+
return this.loader.load(locale)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fallback(loader: TranslationLoader): TranslationLoaderChain {
|
|
170
|
+
this.loader.fallback(loader)
|
|
171
|
+
return this
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 儲存翻譯資源到 Vercel KV
|
|
176
|
+
*
|
|
177
|
+
* @param locale - 語言代碼
|
|
178
|
+
* @param translations - 翻譯資源
|
|
179
|
+
*/
|
|
180
|
+
async put(locale: string, translations: TranslationMap): Promise<void> {
|
|
181
|
+
return this.loader.put(locale, translations)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 從 Vercel KV 刪除翻譯資源
|
|
186
|
+
*
|
|
187
|
+
* @param locale - 語言代碼
|
|
188
|
+
*/
|
|
189
|
+
async delete(locale: string): Promise<void> {
|
|
190
|
+
return this.loader.delete(locale)
|
|
191
|
+
}
|
|
192
|
+
}
|