@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/HMRWatcher.ts
|
|
3
|
+
* @module @gravito/cosmos/hmr
|
|
4
|
+
* @description 翻譯檔案的熱重載 (Hot Module Replacement) 監視器
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **Node.js Only**
|
|
7
|
+
*
|
|
8
|
+
* HMR 功能依賴 Node.js 的 fs.watch,無法在 Edge Runtime 使用
|
|
9
|
+
*
|
|
10
|
+
* Edge Runtime 環境請使用 RemoteLoader 的 ETag 快取機制
|
|
11
|
+
*
|
|
12
|
+
* @since 3.1.0
|
|
13
|
+
* @platform node
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FSWatcher } from 'node:fs'
|
|
17
|
+
import { watch } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { detectRuntime } from './runtime/detector'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HMR 配置
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
* @since 3.1.0
|
|
26
|
+
*/
|
|
27
|
+
export interface HMRConfig {
|
|
28
|
+
/**
|
|
29
|
+
* 是否啟用 HMR
|
|
30
|
+
*
|
|
31
|
+
* 建議僅在開發模式啟用
|
|
32
|
+
*
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
35
|
+
enabled: boolean
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 要監視的目錄路徑陣列
|
|
39
|
+
*
|
|
40
|
+
* @example ['/app/lang', '/app/i18n']
|
|
41
|
+
*/
|
|
42
|
+
watchDirs: string[]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 防抖延遲 (毫秒)
|
|
46
|
+
*
|
|
47
|
+
* 避免短時間內多次觸發重新載入
|
|
48
|
+
*
|
|
49
|
+
* @default 300
|
|
50
|
+
*/
|
|
51
|
+
debounce?: number
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 要監視的檔案副檔名
|
|
55
|
+
*
|
|
56
|
+
* @default ['.json']
|
|
57
|
+
*/
|
|
58
|
+
extensions?: string[]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 是否在檔案變更時顯示日誌
|
|
62
|
+
*
|
|
63
|
+
* @default true
|
|
64
|
+
*/
|
|
65
|
+
verbose?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 翻譯檔案變更事件
|
|
70
|
+
*
|
|
71
|
+
* @public
|
|
72
|
+
* @since 3.1.0
|
|
73
|
+
*/
|
|
74
|
+
export interface TranslationChangeEvent {
|
|
75
|
+
/** 變更的檔案路徑 */
|
|
76
|
+
filePath: string
|
|
77
|
+
/** 受影響的語言代碼 */
|
|
78
|
+
locale: string
|
|
79
|
+
/** 變更類型 */
|
|
80
|
+
type: 'change' | 'rename'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 變更監聽器函數
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
* @since 3.1.0
|
|
88
|
+
*/
|
|
89
|
+
export type ChangeListener = (event: TranslationChangeEvent) => void | Promise<void>
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* HMR 監視器
|
|
93
|
+
*
|
|
94
|
+
* 監視翻譯檔案的變更,並在檔案變更時觸發回調
|
|
95
|
+
* 支援防抖、多目錄監視、副檔名篩選等功能
|
|
96
|
+
*
|
|
97
|
+
* @public
|
|
98
|
+
* @since 3.1.0
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* const watcher = new HMRWatcher({
|
|
103
|
+
* enabled: true,
|
|
104
|
+
* watchDirs: ['./lang'],
|
|
105
|
+
* debounce: 300,
|
|
106
|
+
* verbose: true
|
|
107
|
+
* })
|
|
108
|
+
*
|
|
109
|
+
* watcher.onChange(async ({ locale, filePath }) => {
|
|
110
|
+
* console.log(`Reloading ${locale} from ${filePath}`)
|
|
111
|
+
* await i18nManager.reloadLocale(locale)
|
|
112
|
+
* })
|
|
113
|
+
*
|
|
114
|
+
* watcher.start()
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export class HMRWatcher {
|
|
118
|
+
private config: Required<HMRConfig>
|
|
119
|
+
private watchers: FSWatcher[] = []
|
|
120
|
+
private listeners: ChangeListener[] = []
|
|
121
|
+
private debounceTimers = new Map<string, NodeJS.Timeout>()
|
|
122
|
+
private isWatching = false
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 建立 HMR 監視器實例
|
|
126
|
+
*
|
|
127
|
+
* @param config - HMR 配置
|
|
128
|
+
*/
|
|
129
|
+
constructor(config: HMRConfig) {
|
|
130
|
+
// 運行時檢查 - Edge 環境靜默失敗
|
|
131
|
+
const runtime = detectRuntime()
|
|
132
|
+
if (runtime === 'edge') {
|
|
133
|
+
if (config.verbose ?? true) {
|
|
134
|
+
console.warn(
|
|
135
|
+
'[HMRWatcher] HMR is not supported in Edge Runtime. ' +
|
|
136
|
+
'Consider using RemoteLoader with ETag caching instead.'
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
// 靜默處理,不拋出錯誤
|
|
140
|
+
this.config = {
|
|
141
|
+
enabled: false, // 強制停用
|
|
142
|
+
watchDirs: [],
|
|
143
|
+
debounce: 300,
|
|
144
|
+
extensions: ['.json'],
|
|
145
|
+
verbose: false,
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.config = {
|
|
151
|
+
enabled: config.enabled,
|
|
152
|
+
watchDirs: config.watchDirs,
|
|
153
|
+
debounce: config.debounce ?? 300,
|
|
154
|
+
extensions: config.extensions ?? ['.json'],
|
|
155
|
+
verbose: config.verbose ?? true,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 註冊變更監聽器
|
|
161
|
+
*
|
|
162
|
+
* @param listener - 監聽器函數
|
|
163
|
+
* @returns 當前實例,支援鏈式調用
|
|
164
|
+
*/
|
|
165
|
+
onChange(listener: ChangeListener): HMRWatcher {
|
|
166
|
+
this.listeners.push(listener)
|
|
167
|
+
return this
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 移除變更監聽器
|
|
172
|
+
*
|
|
173
|
+
* @param listener - 要移除的監聽器函數
|
|
174
|
+
*/
|
|
175
|
+
removeListener(listener: ChangeListener): void {
|
|
176
|
+
const index = this.listeners.indexOf(listener)
|
|
177
|
+
if (index > -1) {
|
|
178
|
+
this.listeners.splice(index, 1)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 開始監視
|
|
184
|
+
*/
|
|
185
|
+
start(): void {
|
|
186
|
+
if (!this.config.enabled) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (this.isWatching) {
|
|
191
|
+
console.warn('[HMRWatcher] Already watching')
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const dir of this.config.watchDirs) {
|
|
196
|
+
try {
|
|
197
|
+
const watcher = watch(dir, { recursive: false }, (eventType, filename) => {
|
|
198
|
+
if (!filename) {
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 檢查副檔名
|
|
203
|
+
const hasValidExtension = this.config.extensions.some((ext) => filename.endsWith(ext))
|
|
204
|
+
if (!hasValidExtension) {
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 提取語言代碼 (假設檔案名稱為 {locale}.json)
|
|
209
|
+
const locale = filename.replace(/\.[^/.]+$/, '')
|
|
210
|
+
const filePath = join(dir, filename)
|
|
211
|
+
|
|
212
|
+
this.handleChange({
|
|
213
|
+
filePath,
|
|
214
|
+
locale,
|
|
215
|
+
type: eventType === 'rename' ? 'rename' : 'change',
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
this.watchers.push(watcher)
|
|
220
|
+
|
|
221
|
+
if (this.config.verbose) {
|
|
222
|
+
console.log(`[HMRWatcher] Watching directory: ${dir}`)
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error(`[HMRWatcher] Failed to watch directory: ${dir}`, error)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.isWatching = true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 停止監視
|
|
234
|
+
*/
|
|
235
|
+
stop(): void {
|
|
236
|
+
for (const watcher of this.watchers) {
|
|
237
|
+
watcher.close()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.watchers = []
|
|
241
|
+
this.isWatching = false
|
|
242
|
+
|
|
243
|
+
// 清除所有防抖計時器
|
|
244
|
+
for (const timer of this.debounceTimers.values()) {
|
|
245
|
+
clearTimeout(timer)
|
|
246
|
+
}
|
|
247
|
+
this.debounceTimers.clear()
|
|
248
|
+
|
|
249
|
+
if (this.config.verbose) {
|
|
250
|
+
console.log('[HMRWatcher] Stopped watching')
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 處理檔案變更事件
|
|
256
|
+
*
|
|
257
|
+
* @param event - 變更事件
|
|
258
|
+
*/
|
|
259
|
+
private handleChange(event: TranslationChangeEvent): void {
|
|
260
|
+
const key = `${event.locale}:${event.type}`
|
|
261
|
+
|
|
262
|
+
// 清除舊的計時器
|
|
263
|
+
if (this.debounceTimers.has(key)) {
|
|
264
|
+
clearTimeout(this.debounceTimers.get(key)!)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 設定新的防抖計時器
|
|
268
|
+
const timer = setTimeout(async () => {
|
|
269
|
+
this.debounceTimers.delete(key)
|
|
270
|
+
|
|
271
|
+
if (this.config.verbose) {
|
|
272
|
+
console.log(`[HMRWatcher] File ${event.type}: ${event.filePath} (${event.locale})`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 通知所有監聽器
|
|
276
|
+
for (const listener of this.listeners) {
|
|
277
|
+
try {
|
|
278
|
+
await listener(event)
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error('[HMRWatcher] Error in change listener:', error)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}, this.config.debounce)
|
|
284
|
+
|
|
285
|
+
this.debounceTimers.set(key, timer)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 檢查是否正在監視
|
|
290
|
+
*
|
|
291
|
+
* @returns 是否正在監視
|
|
292
|
+
*/
|
|
293
|
+
isActive(): boolean {
|
|
294
|
+
return this.isWatching
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 取得監視的目錄列表
|
|
299
|
+
*
|
|
300
|
+
* @returns 目錄路徑陣列
|
|
301
|
+
*/
|
|
302
|
+
getWatchDirs(): readonly string[] {
|
|
303
|
+
return [...this.config.watchDirs]
|
|
304
|
+
}
|
|
305
|
+
}
|
package/src/I18nService.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { GravitoMiddleware } from '@gravito/core'
|
|
8
|
+
import { LRUCache } from 'lru-cache'
|
|
9
|
+
import { type HMRConfig, HMRWatcher } from './HMRWatcher'
|
|
8
10
|
import { loadLocale } from './loader'
|
|
11
|
+
import type { TranslationLoader } from './loaders/TranslationLoader'
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Interface for locale detectors used to determine the user's preferred locale from a request context.
|
|
@@ -60,6 +63,13 @@ export interface LazyLoadConfig {
|
|
|
60
63
|
baseDir: string
|
|
61
64
|
/** Optional list of locales to preload on startup. */
|
|
62
65
|
preload?: string[]
|
|
66
|
+
/**
|
|
67
|
+
* Custom loader function for testing or specialized loading strategies.
|
|
68
|
+
* If not provided, defaults to the filesystem loader.
|
|
69
|
+
*
|
|
70
|
+
* @deprecated 自 v3.1.0 起建議使用 loaders 陣列
|
|
71
|
+
*/
|
|
72
|
+
loader?: (baseDir: string, locale: string) => Promise<Record<string, string> | null>
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/**
|
|
@@ -109,6 +119,50 @@ export interface I18nConfig {
|
|
|
109
119
|
* Configuration for fallback strategies and missing key handling.
|
|
110
120
|
*/
|
|
111
121
|
fallback?: FallbackConfig
|
|
122
|
+
/**
|
|
123
|
+
* 翻譯載入器陣列
|
|
124
|
+
*
|
|
125
|
+
* 支援多個載入器的鏈式組合,實現降級策略
|
|
126
|
+
* 當第一個載入器失敗時,會自動嘗試下一個載入器
|
|
127
|
+
*
|
|
128
|
+
* @since 3.1.0
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```typescript
|
|
132
|
+
* import { FileSystemLoader, RemoteLoader } from '@gravito/cosmos'
|
|
133
|
+
*
|
|
134
|
+
* const config: I18nConfig = {
|
|
135
|
+
* defaultLocale: 'zh-TW',
|
|
136
|
+
* supportedLocales: ['zh-TW', 'en'],
|
|
137
|
+
* loaders: [
|
|
138
|
+
* new FileSystemLoader({ baseDir: './lang' }),
|
|
139
|
+
* new RemoteLoader({ url: 'https://api.example.com/i18n/:locale' })
|
|
140
|
+
* ]
|
|
141
|
+
* }
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
loaders?: TranslationLoader[]
|
|
145
|
+
/**
|
|
146
|
+
* HMR (熱重載) 配置
|
|
147
|
+
*
|
|
148
|
+
* 在開發模式下啟用,可以在翻譯檔案變更時自動重新載入
|
|
149
|
+
*
|
|
150
|
+
* @since 3.1.0
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```typescript
|
|
154
|
+
* const config: I18nConfig = {
|
|
155
|
+
* defaultLocale: 'zh-TW',
|
|
156
|
+
* supportedLocales: ['zh-TW', 'en'],
|
|
157
|
+
* hmr: {
|
|
158
|
+
* enabled: process.env.NODE_ENV === 'development',
|
|
159
|
+
* watchDirs: ['./lang'],
|
|
160
|
+
* debounce: 300
|
|
161
|
+
* }
|
|
162
|
+
* }
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
hmr?: HMRConfig
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
/**
|
|
@@ -418,17 +472,21 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
418
472
|
/** Map of translation bundles indexed by locale. */
|
|
419
473
|
public translations: Record<string, TranslationMap> = {}
|
|
420
474
|
/** Internal cache for resolved translation strings. */
|
|
421
|
-
private cache
|
|
475
|
+
private cache: LRUCache<string, string>
|
|
422
476
|
/** Cache for Intl.PluralRules instances. */
|
|
423
477
|
private pluralRules = new Map<string, Intl.PluralRules>()
|
|
424
478
|
/** Set of locales that have been successfully loaded. */
|
|
425
479
|
private loadedLocales = new Set<string>()
|
|
480
|
+
/** Map of pending locale load promises for coalescing. */
|
|
481
|
+
private loadingPromises = new Map<string, Promise<void>>()
|
|
426
482
|
/** Counter for cache hits. */
|
|
427
483
|
private cacheHits = 0
|
|
428
484
|
/** Counter for cache misses. */
|
|
429
485
|
private cacheMisses = 0
|
|
430
486
|
/** Default instance for global/CLI usage. */
|
|
431
487
|
private globalInstance: I18nInstance<Schema>
|
|
488
|
+
/** HMR watcher for development mode. */
|
|
489
|
+
private hmrWatcher?: HMRWatcher
|
|
432
490
|
|
|
433
491
|
/**
|
|
434
492
|
* Create a new I18nManager.
|
|
@@ -439,7 +497,20 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
439
497
|
if (config.translations) {
|
|
440
498
|
this.translations = config.translations
|
|
441
499
|
}
|
|
500
|
+
this.cache = new LRUCache<string, string>({
|
|
501
|
+
max: 10000,
|
|
502
|
+
ttl: 1000 * 60 * 60, // 1 hour
|
|
503
|
+
})
|
|
442
504
|
this.globalInstance = new I18nInstance(this, config.defaultLocale)
|
|
505
|
+
|
|
506
|
+
// 初始化 HMR
|
|
507
|
+
if (config.hmr?.enabled) {
|
|
508
|
+
this.hmrWatcher = new HMRWatcher(config.hmr)
|
|
509
|
+
this.hmrWatcher.onChange(async ({ locale }) => {
|
|
510
|
+
await this.reloadLocale(locale)
|
|
511
|
+
})
|
|
512
|
+
this.hmrWatcher.start()
|
|
513
|
+
}
|
|
443
514
|
}
|
|
444
515
|
|
|
445
516
|
// --- I18nService Implementation (Delegates to global instance) ---
|
|
@@ -550,17 +621,48 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
550
621
|
* @param locale - Locale to load.
|
|
551
622
|
*/
|
|
552
623
|
async ensureLocale(locale: string): Promise<void> {
|
|
553
|
-
// If already loaded
|
|
554
|
-
if (this.loadedLocales.has(locale)
|
|
624
|
+
// If already loaded, skip
|
|
625
|
+
if (this.loadedLocales.has(locale)) {
|
|
555
626
|
return
|
|
556
627
|
}
|
|
557
628
|
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
this.addResource(locale, translations)
|
|
562
|
-
this.loadedLocales.add(locale)
|
|
629
|
+
// Check for pending load promise to coalesce requests
|
|
630
|
+
if (this.loadingPromises.has(locale)) {
|
|
631
|
+
return this.loadingPromises.get(locale)
|
|
563
632
|
}
|
|
633
|
+
|
|
634
|
+
// Create a new load promise
|
|
635
|
+
const loadPromise = (async () => {
|
|
636
|
+
try {
|
|
637
|
+
let translations: TranslationMap | null = null
|
|
638
|
+
|
|
639
|
+
// 優先使用新的 loaders 配置
|
|
640
|
+
if (this.config.loaders && this.config.loaders.length > 0) {
|
|
641
|
+
// 嘗試每個載入器,直到成功載入
|
|
642
|
+
for (const loader of this.config.loaders) {
|
|
643
|
+
translations = await loader.load(locale)
|
|
644
|
+
if (translations) {
|
|
645
|
+
break
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// 向後相容:使用舊的 lazyLoad 配置
|
|
650
|
+
else if (this.config.lazyLoad) {
|
|
651
|
+
const loaderFn = this.config.lazyLoad.loader || loadLocale
|
|
652
|
+
translations = await loaderFn(this.config.lazyLoad.baseDir, locale)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (translations) {
|
|
656
|
+
this.addResource(locale, translations)
|
|
657
|
+
this.loadedLocales.add(locale)
|
|
658
|
+
}
|
|
659
|
+
} finally {
|
|
660
|
+
this.loadingPromises.delete(locale)
|
|
661
|
+
}
|
|
662
|
+
})()
|
|
663
|
+
|
|
664
|
+
this.loadingPromises.set(locale, loadPromise)
|
|
665
|
+
return loadPromise
|
|
564
666
|
}
|
|
565
667
|
|
|
566
668
|
// --- Manager Internal API ---
|
|
@@ -601,6 +703,11 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
601
703
|
*/
|
|
602
704
|
private invalidateCache(locale?: string) {
|
|
603
705
|
if (locale) {
|
|
706
|
+
// LRUCache doesn't support iterating keys efficiently in all versions,
|
|
707
|
+
// but newer versions (v7+) are Map-like.
|
|
708
|
+
// However, iterating over cache to delete by prefix is expensive.
|
|
709
|
+
// For now, we clear everything for simplicity and correctness, or we assume keys are iterable.
|
|
710
|
+
// lru-cache v11 (installed) supports keys().
|
|
604
711
|
for (const key of this.cache.keys()) {
|
|
605
712
|
if (key.startsWith(`${locale}:`)) {
|
|
606
713
|
this.cache.delete(key)
|
|
@@ -611,6 +718,37 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
611
718
|
}
|
|
612
719
|
}
|
|
613
720
|
|
|
721
|
+
/**
|
|
722
|
+
* 重新載入指定語言的翻譯資源
|
|
723
|
+
*
|
|
724
|
+
* 會清除該語言的快取並重新從載入器載入
|
|
725
|
+
* 主要用於 HMR 和手動重新載入場景
|
|
726
|
+
*
|
|
727
|
+
* @param locale - 要重新載入的語言代碼
|
|
728
|
+
* @returns Promise that resolves when reloading is complete
|
|
729
|
+
* @public
|
|
730
|
+
* @since 3.1.0
|
|
731
|
+
*
|
|
732
|
+
* @example
|
|
733
|
+
* ```typescript
|
|
734
|
+
* // 手動重新載入某個語言
|
|
735
|
+
* await i18nManager.reloadLocale('zh-TW')
|
|
736
|
+
*
|
|
737
|
+
* // HMR 自動觸發重新載入
|
|
738
|
+
* hmrWatcher.onChange(({ locale }) => {
|
|
739
|
+
* i18nManager.reloadLocale(locale)
|
|
740
|
+
* })
|
|
741
|
+
* ```
|
|
742
|
+
*/
|
|
743
|
+
async reloadLocale(locale: string): Promise<void> {
|
|
744
|
+
// 清除已載入標記
|
|
745
|
+
this.loadedLocales.delete(locale)
|
|
746
|
+
// 清除快取
|
|
747
|
+
this.invalidateCache(locale)
|
|
748
|
+
// 重新載入
|
|
749
|
+
return this.ensureLocale(locale)
|
|
750
|
+
}
|
|
751
|
+
|
|
614
752
|
/**
|
|
615
753
|
* Get the plural form for a locale and count.
|
|
616
754
|
*/
|
|
@@ -646,7 +784,9 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
|
|
|
646
784
|
|
|
647
785
|
for (const fallbackLocale of chain) {
|
|
648
786
|
// Avoid infinite recursion if fallback points to itself
|
|
649
|
-
if (fallbackLocale === locale)
|
|
787
|
+
if (fallbackLocale === locale) {
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
650
790
|
|
|
651
791
|
const value = this.resolveKey(fallbackLocale, key)
|
|
652
792
|
if (value !== undefined) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/index.edge.ts
|
|
3
|
+
* @module @gravito/cosmos/edge
|
|
4
|
+
* @description Edge Runtime 入口點
|
|
5
|
+
*
|
|
6
|
+
* 此入口點不包含 Node.js 特定功能:
|
|
7
|
+
* - 不導出 FileSystemLoader (依賴 node:fs)
|
|
8
|
+
* - 不導出 HMRWatcher (依賴 node:fs)
|
|
9
|
+
* - 不導出舊的 loader.ts (依賴 node:fs)
|
|
10
|
+
*
|
|
11
|
+
* Edge Runtime 環境包括:
|
|
12
|
+
* - Cloudflare Workers
|
|
13
|
+
* - Vercel Edge Functions
|
|
14
|
+
* - Deno Deploy
|
|
15
|
+
* - 其他邊緣運行環境
|
|
16
|
+
*
|
|
17
|
+
* @since 3.2.0
|
|
18
|
+
* @platform edge
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Core services
|
|
22
|
+
export * from './I18nService'
|
|
23
|
+
export { I18nOrbit, OrbitCosmos } from './index'
|
|
24
|
+
export * from './loaders/ChainedLoader'
|
|
25
|
+
export * from './loaders/CloudflareKVLoader'
|
|
26
|
+
export * from './loaders/EdgeKVLoader'
|
|
27
|
+
export * from './loaders/MemoryLoader'
|
|
28
|
+
export * from './loaders/RemoteLoader'
|
|
29
|
+
// Edge-compatible loaders
|
|
30
|
+
export * from './loaders/TranslationLoader'
|
|
31
|
+
export * from './loaders/VercelKVLoader'
|
|
32
|
+
|
|
33
|
+
// Runtime utilities
|
|
34
|
+
export * from './runtime/detector'
|
|
35
|
+
export * from './runtime/path-utils'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/src/index.node.ts
|
|
3
|
+
* @module @gravito/cosmos/node
|
|
4
|
+
* @description Node.js 專用入口點
|
|
5
|
+
*
|
|
6
|
+
* 包含完整功能,包括 Node.js 特定功能:
|
|
7
|
+
* - FileSystemLoader
|
|
8
|
+
* - HMRWatcher
|
|
9
|
+
* - 舊的 loader.ts (向後相容)
|
|
10
|
+
*
|
|
11
|
+
* @since 3.2.0
|
|
12
|
+
* @platform node
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export * from './HMRWatcher'
|
|
16
|
+
// Re-export everything from main index
|
|
17
|
+
export * from './index'
|
|
18
|
+
export * from './loader'
|
|
19
|
+
// Ensure all Node.js features are explicitly available
|
|
20
|
+
export * from './loaders/FileSystemLoader'
|
package/src/index.ts
CHANGED
|
@@ -77,5 +77,19 @@ export class OrbitCosmos implements GravitoOrbit {
|
|
|
77
77
|
*/
|
|
78
78
|
export const I18nOrbit = OrbitCosmos
|
|
79
79
|
|
|
80
|
+
export * from './HMRWatcher'
|
|
80
81
|
export * from './I18nService'
|
|
81
82
|
export * from './loader'
|
|
83
|
+
export * from './loaders/ChainedLoader'
|
|
84
|
+
export * from './loaders/CloudflareKVLoader'
|
|
85
|
+
export * from './loaders/EdgeKVLoader'
|
|
86
|
+
export * from './loaders/FileSystemLoader'
|
|
87
|
+
export * from './loaders/MemoryLoader'
|
|
88
|
+
export * from './loaders/RemoteLoader'
|
|
89
|
+
// Loaders
|
|
90
|
+
export * from './loaders/TranslationLoader'
|
|
91
|
+
export * from './loaders/VercelKVLoader'
|
|
92
|
+
|
|
93
|
+
// Runtime utilities
|
|
94
|
+
export * from './runtime/detector'
|
|
95
|
+
export * from './runtime/path-utils'
|
package/src/loader.ts
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/cosmos/src/loader.ts
|
|
3
3
|
* @module @gravito/cosmos/loader
|
|
4
|
-
* @description
|
|
4
|
+
* @description 載入翻譯檔案的工具函數 (向後相容層)
|
|
5
|
+
*
|
|
6
|
+
* @deprecated 自 v3.1.0 起建議使用 FileSystemLoader 類別
|
|
7
|
+
* 這些函數將在 v4.0.0 中移除
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* // 舊寫法 (deprecated)
|
|
12
|
+
* import { loadLocale } from '@gravito/cosmos/loader'
|
|
13
|
+
* const translations = await loadLocale('/lang', 'zh-TW')
|
|
14
|
+
*
|
|
15
|
+
* // 新寫法 (推薦)
|
|
16
|
+
* import { FileSystemLoader } from '@gravito/cosmos'
|
|
17
|
+
* const loader = new FileSystemLoader({ baseDir: '/lang' })
|
|
18
|
+
* const translations = await loader.load('zh-TW')
|
|
19
|
+
* ```
|
|
5
20
|
*/
|
|
6
21
|
|
|
7
22
|
import { readdir, readFile } from 'node:fs/promises'
|
|
8
23
|
import { join, parse } from 'node:path'
|
|
9
24
|
|
|
10
25
|
/**
|
|
11
|
-
*
|
|
26
|
+
* 從目錄載入所有翻譯檔案
|
|
12
27
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
28
|
+
* 掃描指定目錄中的所有 JSON 檔案並載入為翻譯資源
|
|
29
|
+
* 檔案名稱(不含副檔名)將作為語言代碼
|
|
30
|
+
*
|
|
31
|
+
* @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader
|
|
32
|
+
* @param directory - 翻譯目錄的絕對路徑
|
|
33
|
+
* @returns 語言代碼到翻譯資源的對應表
|
|
34
|
+
* @public
|
|
15
35
|
*
|
|
16
36
|
* @example
|
|
17
37
|
* ```
|
|
@@ -19,10 +39,6 @@ import { join, parse } from 'node:path'
|
|
|
19
39
|
* /en.json -> { "welcome": "Hello" }
|
|
20
40
|
* /zh-TW.json -> { "welcome": "你好" }
|
|
21
41
|
* ```
|
|
22
|
-
*
|
|
23
|
-
* @param directory - The absolute path to the translations directory.
|
|
24
|
-
* @returns A promise that resolves to a map of locale -> translations.
|
|
25
|
-
* @public
|
|
26
42
|
*/
|
|
27
43
|
export async function loadTranslations(
|
|
28
44
|
directory: string
|
|
@@ -45,7 +61,7 @@ export async function loadTranslations(
|
|
|
45
61
|
}
|
|
46
62
|
} catch (_e) {
|
|
47
63
|
console.warn(
|
|
48
|
-
`[
|
|
64
|
+
`[Cosmos] Could not load translations from ${directory}. Directory might not exist.`
|
|
49
65
|
)
|
|
50
66
|
}
|
|
51
67
|
|
|
@@ -53,13 +69,14 @@ export async function loadTranslations(
|
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
/**
|
|
56
|
-
*
|
|
72
|
+
* 載入指定語言的翻譯資源
|
|
57
73
|
*
|
|
58
|
-
*
|
|
74
|
+
* 期望在指定目錄中找到 `{locale}.json` 格式的檔案
|
|
59
75
|
*
|
|
60
|
-
* @
|
|
61
|
-
* @param
|
|
62
|
-
* @
|
|
76
|
+
* @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader
|
|
77
|
+
* @param directory - 包含翻譯檔案的目錄
|
|
78
|
+
* @param locale - 要載入的語言代碼
|
|
79
|
+
* @returns 翻譯資源,載入失敗則返回 null
|
|
63
80
|
* @public
|
|
64
81
|
*/
|
|
65
82
|
export async function loadLocale(
|