@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.
@@ -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
+ }