@gravito/cosmos 3.0.1 → 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.
Files changed (55) hide show
  1. package/MIGRATION.md +331 -0
  2. package/README.md +105 -45
  3. package/README.zh-TW.md +102 -22
  4. package/docs/plans/01-performance.md +187 -0
  5. package/docs/plans/02-architecture.md +309 -0
  6. package/docs/plans/03-api-enhancement.md +345 -0
  7. package/docs/plans/04-testing.md +431 -0
  8. package/docs/plans/README.md +47 -0
  9. package/ion/src/index.js +1179 -1138
  10. package/package.json +22 -6
  11. package/scripts/check-coverage.ts +64 -0
  12. package/src/HMRWatcher.ts +305 -0
  13. package/src/I18nService.ts +715 -91
  14. package/src/index.edge.ts +35 -0
  15. package/src/index.node.ts +20 -0
  16. package/src/index.ts +39 -6
  17. package/src/loader.ts +64 -14
  18. package/src/loaders/ChainedLoader.ts +117 -0
  19. package/src/loaders/CloudflareKVLoader.ts +194 -0
  20. package/src/loaders/EdgeKVLoader.ts +248 -0
  21. package/src/loaders/FileSystemLoader.ts +125 -0
  22. package/src/loaders/MemoryLoader.ts +161 -0
  23. package/src/loaders/RemoteLoader.ts +235 -0
  24. package/src/loaders/TranslationLoader.ts +98 -0
  25. package/src/loaders/VercelKVLoader.ts +192 -0
  26. package/src/runtime/detector.ts +97 -0
  27. package/src/runtime/path-utils.ts +169 -0
  28. package/tests/helpers/factory.ts +41 -0
  29. package/tests/performance/translate.bench.ts +27 -0
  30. package/tests/unit/api.test.ts +37 -0
  31. package/tests/unit/detector.test.ts +65 -0
  32. package/tests/unit/edge-kv-loader.test.ts +202 -0
  33. package/tests/unit/edge.test.ts +100 -0
  34. package/tests/unit/fallback.test.ts +66 -0
  35. package/tests/unit/hmr.test.ts +255 -0
  36. package/tests/unit/lazy.test.ts +35 -0
  37. package/tests/unit/loader.test.ts +72 -0
  38. package/tests/unit/loaders.test.ts +332 -0
  39. package/tests/{manager.test.ts → unit/manager.test.ts} +1 -1
  40. package/tests/unit/memory-loader.test.ts +130 -0
  41. package/tests/unit/path-utils.test.ts +135 -0
  42. package/tests/unit/plural.test.ts +58 -0
  43. package/tests/unit/runtime-detector.test.ts +86 -0
  44. package/tests/{service.test.ts → unit/service.test.ts} +2 -2
  45. package/tsconfig.json +12 -24
  46. package/.turbo/turbo-build.log +0 -20
  47. package/.turbo/turbo-test$colon$ci.log +0 -35
  48. package/.turbo/turbo-test$colon$coverage.log +0 -35
  49. package/.turbo/turbo-test.log +0 -27
  50. package/.turbo/turbo-typecheck.log +0 -2
  51. package/dist/index.cjs +0 -309
  52. package/dist/index.d.cts +0 -274
  53. package/dist/index.d.ts +0 -274
  54. package/dist/index.js +0 -277
  55. package/tests/loader.test.ts +0 -44
@@ -1,4 +1,32 @@
1
+ /**
2
+ * @file packages/cosmos/src/I18nService.ts
3
+ * @module @gravito/cosmos/service
4
+ * @description Core services and interfaces for internationalization.
5
+ */
6
+
1
7
  import type { GravitoMiddleware } from '@gravito/core'
8
+ import { LRUCache } from 'lru-cache'
9
+ import { type HMRConfig, HMRWatcher } from './HMRWatcher'
10
+ import { loadLocale } from './loader'
11
+ import type { TranslationLoader } from './loaders/TranslationLoader'
12
+
13
+ /**
14
+ * Interface for locale detectors used to determine the user's preferred locale from a request context.
15
+ *
16
+ * @public
17
+ * @since 3.0.0
18
+ */
19
+ export interface LocaleDetector {
20
+ /** The unique name of the detector (e.g., 'query', 'header'). */
21
+ name: string
22
+ /**
23
+ * Detect the locale from the given context.
24
+ *
25
+ * @param c - The application context (e.g., Hono context).
26
+ * @returns The detected locale string, or undefined if not found.
27
+ */
28
+ detect(c: any): string | undefined | Promise<string | undefined>
29
+ }
2
30
 
3
31
  /**
4
32
  * A map of translations where keys are translation keys and values
@@ -11,6 +39,62 @@ export type TranslationMap = {
11
39
  [key: string]: string | TranslationMap
12
40
  }
13
41
 
42
+ /**
43
+ * Utility type to extract all possible dot-notation keys from a nested translation schema.
44
+ *
45
+ * @template T - The translation schema object.
46
+ * @public
47
+ */
48
+ export type NestedKeyOf<T> = T extends object
49
+ ? {
50
+ [K in keyof T & (string | number)]: T[K] extends object
51
+ ? `${K}` | `${K}.${NestedKeyOf<T[K]>}`
52
+ : `${K}`
53
+ }[keyof T & (string | number)]
54
+ : never
55
+
56
+ /**
57
+ * Configuration for lazy loading translation files.
58
+ *
59
+ * @public
60
+ */
61
+ export interface LazyLoadConfig {
62
+ /** The base directory where translation files are located. */
63
+ baseDir: string
64
+ /** Optional list of locales to preload on startup. */
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>
73
+ }
74
+
75
+ /**
76
+ * Configuration for translation fallback strategies.
77
+ *
78
+ * @public
79
+ */
80
+ export interface FallbackConfig {
81
+ /**
82
+ * Custom fallback chains for specific locales.
83
+ * Key is the requested locale, value is an array of fallback locales.
84
+ */
85
+ fallbackChain?: Record<string, string[]>
86
+ /**
87
+ * Strategy to use when a translation key is missing.
88
+ * - 'key': Return the key itself (default).
89
+ * - 'empty': Return an empty string.
90
+ * - 'throw': Throw an error.
91
+ * - (key, locale) => string: Custom handler function.
92
+ */
93
+ onMissingKey?: 'key' | 'empty' | 'throw' | ((key: string, locale: string) => string)
94
+ /** Whether to log a warning when a key is missing. */
95
+ warnOnMissing?: boolean
96
+ }
97
+
14
98
  /**
15
99
  * Configuration for the I18n service.
16
100
  *
@@ -18,15 +102,83 @@ export type TranslationMap = {
18
102
  * @since 3.0.0
19
103
  */
20
104
  export interface I18nConfig {
21
- /** The fallback locale to use when the requested one is not found. */
105
+ /** The default locale to use when no locale is detected or for fallbacks. */
22
106
  defaultLocale: string
23
107
  /** List of locales officially supported by the application. */
24
108
  supportedLocales: string[]
25
109
  /**
26
- * Optional record of translations indexed by locale.
110
+ * Static translations indexed by locale.
27
111
  * Keys are locale strings (e.g., 'en', 'zh-TW').
28
112
  */
29
113
  translations?: Record<string, TranslationMap>
114
+ /**
115
+ * Configuration for lazy loading translation files from the filesystem.
116
+ */
117
+ lazyLoad?: LazyLoadConfig
118
+ /**
119
+ * Configuration for fallback strategies and missing key handling.
120
+ */
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
166
+ }
167
+
168
+ /**
169
+ * Statistics about the I18n service state and performance.
170
+ *
171
+ * @public
172
+ */
173
+ export interface I18nStats {
174
+ /** Number of locales currently loaded. */
175
+ localesCount: number
176
+ /** Estimated total number of translation keys across all locales. */
177
+ totalKeys: number
178
+ /** Percentage of translation requests served from cache. */
179
+ cacheHitRate: number
180
+ /** Number of entries currently in the translation cache. */
181
+ cacheSize: number
30
182
  }
31
183
 
32
184
  /**
@@ -35,16 +187,18 @@ export interface I18nConfig {
35
187
  * It allows for setting and getting the current locale, translating strings
36
188
  * with optional replacements, and checking for key existence.
37
189
  *
190
+ * @template Schema - The translation schema for type-safe keys.
38
191
  * @public
39
192
  * @since 3.0.0
40
193
  */
41
- export interface I18nService {
42
- /** The current active locale. */
194
+ export interface I18nService<Schema = TranslationMap> {
195
+ /** The current active locale for this instance. */
43
196
  locale: string
44
197
  /**
45
198
  * Set the active locale for this service instance.
46
199
  *
47
200
  * @param locale - Valid locale string from supportedLocales.
201
+ * @throws Error if locale is not supported (depending on implementation).
48
202
  */
49
203
  setLocale(locale: string): void
50
204
  /**
@@ -53,67 +207,129 @@ export interface I18nService {
53
207
  * @returns Current locale string.
54
208
  */
55
209
  getLocale(): string
210
+ /**
211
+ * Ensure translations for a locale are loaded (useful for lazy loading).
212
+ *
213
+ * @param locale - The locale to load.
214
+ * @returns Promise that resolves when loading is complete.
215
+ */
216
+ ensureLocale(locale: string): Promise<void>
56
217
  /**
57
218
  * Translate a key into the current locale.
58
219
  *
220
+ * Supports parameter replacement using `:key` syntax and pluralization
221
+ * if a `count` parameter is provided.
222
+ *
59
223
  * @param key - The translation key (e.g., 'auth.login_success').
60
- * @param replacements - Optional placeholders in the format `:key` replaced by values.
224
+ * @param replacements - Optional placeholders replaced by values.
61
225
  * @returns The translated string, or the key itself if not found.
62
226
  *
63
227
  * @example
64
228
  * ```typescript
65
229
  * i18n.t('messages.hello', { name: 'John' }); // "Hello John"
230
+ * i18n.t('items.count', { count: 5 }); // "5 items"
66
231
  * ```
67
232
  */
68
- t(key: string, replacements?: Record<string, string | number>): string
233
+ t(
234
+ key: NestedKeyOf<Schema> | (string & {}),
235
+ replacements?: Record<string, string | number>
236
+ ): string
237
+ /**
238
+ * Translate multiple keys at once.
239
+ *
240
+ * @param keysOrEntries - Array of keys or [key, replacements] tuples.
241
+ * @returns Object mapping each key to its translated string.
242
+ */
243
+ tMany(
244
+ keysOrEntries: Array<
245
+ | NestedKeyOf<Schema>
246
+ | (string & {})
247
+ | [NestedKeyOf<Schema> | (string & {}), Record<string, string | number>?]
248
+ >
249
+ ): Record<string, string>
69
250
  /**
70
251
  * Check if a translation key exists for the current locale.
71
252
  *
72
253
  * @param key - The key to check.
73
254
  * @returns True if the key exists, false otherwise.
74
255
  */
75
- has(key: string): boolean
256
+ has(key: NestedKeyOf<Schema> | (string & {})): boolean
76
257
  /**
77
258
  * Create a new request-scoped instance of the I18n service.
78
259
  *
260
+ * This is typically used in middleware to provide a fresh instance per request
261
+ * that shares the same translation resources but has its own locale state.
262
+ *
79
263
  * @param locale - Optional initial locale for the new instance.
80
264
  * @returns A new I18nService instance.
81
265
  */
82
- clone(locale?: string): I18nService
266
+ clone(locale?: string): I18nService<Schema>
267
+
268
+ /**
269
+ * Get list of all currently loaded locales.
270
+ * @returns Array of locale strings.
271
+ */
272
+ getLocales(): string[]
273
+ /**
274
+ * Check if translations for a specific locale are already loaded.
275
+ * @param locale - Locale to check.
276
+ * @returns True if loaded.
277
+ */
278
+ isLocaleLoaded(locale: string): boolean
279
+ /**
280
+ * Get performance and usage statistics.
281
+ * @returns I18nStats object.
282
+ */
283
+ getStats(): I18nStats
83
284
  }
84
285
 
85
286
  /**
86
- * Request-scoped I18n Instance
87
- * Holds the state (locale) for a single request, but shares the heavy resources (translations)
287
+ * Request-scoped I18n Instance.
288
+ *
289
+ * Holds the state (current locale) for a single request, but shares the heavy
290
+ * resources (translation bundles) through the central I18nManager.
291
+ *
292
+ * @template Schema - The translation schema for type-safe keys.
293
+ * @public
294
+ * @since 3.0.0
88
295
  */
89
- export class I18nInstance implements I18nService {
296
+ export class I18nInstance<Schema = TranslationMap> implements I18nService<Schema> {
297
+ /** The current active locale for this instance. */
90
298
  private _locale: string
91
299
 
92
300
  /**
93
301
  * Create a new I18nInstance.
94
302
  *
95
- * @param manager - The I18nManager instance.
303
+ * @param manager - The central I18nManager instance.
96
304
  * @param initialLocale - The initial locale for this instance.
97
305
  */
98
306
  constructor(
99
- public readonly manager: I18nManager,
307
+ public readonly manager: I18nManager<Schema>,
100
308
  initialLocale: string
101
309
  ) {
102
310
  this._locale = initialLocale
103
311
  }
104
312
 
313
+ /**
314
+ * Get the current active locale.
315
+ */
105
316
  get locale(): string {
106
317
  return this._locale
107
318
  }
108
319
 
320
+ /**
321
+ * Set the current active locale.
322
+ */
109
323
  set locale(value: string) {
110
324
  this.setLocale(value)
111
325
  }
112
326
 
113
327
  /**
114
- * Set the current locale.
328
+ * Set the active locale for this instance.
115
329
  *
116
- * @param locale - The locale to set.
330
+ * Validates that the locale is among the supported locales defined in config.
331
+ *
332
+ * @param locale - Valid locale string from supportedLocales.
117
333
  */
118
334
  setLocale(locale: string) {
119
335
  if (this.manager.getConfig().supportedLocales.includes(locale)) {
@@ -122,68 +338,155 @@ export class I18nInstance implements I18nService {
122
338
  }
123
339
 
124
340
  /**
125
- * Get the current locale.
341
+ * Ensure translations for a locale are loaded.
342
+ *
343
+ * Delegates to the manager's loading mechanism (e.g., filesystem read).
344
+ *
345
+ * @param locale - Locale to load.
346
+ */
347
+ async ensureLocale(locale: string): Promise<void> {
348
+ return this.manager.ensureLocale(locale)
349
+ }
350
+
351
+ /**
352
+ * Get the current locale string.
126
353
  *
127
- * @returns The current locale string.
354
+ * @returns Current locale.
128
355
  */
129
356
  getLocale(): string {
130
357
  return this._locale
131
358
  }
132
359
 
133
360
  /**
134
- * Translate a key.
361
+ * Translate a key into the current locale.
362
+ *
363
+ * Supports parameter replacement and pluralization.
135
364
  *
136
- * @param key - The translation key (e.g., 'messages.welcome').
137
- * @param replacements - Optional replacements for parameters in the translation string.
138
- * @returns The translated string, or the key if not found.
365
+ * @param key - The translation key (dot notation supported).
366
+ * @param replacements - Key-value pairs for parameter replacement.
367
+ * @returns The translated string.
139
368
  */
140
- t(key: string, replacements?: Record<string, string | number>): string {
369
+ t(
370
+ key: NestedKeyOf<Schema> | (string & {}),
371
+ replacements?: Record<string, string | number>
372
+ ): string {
141
373
  return this.manager.translate(this._locale, key, replacements)
142
374
  }
143
375
 
144
376
  /**
145
- * Check if a translation key exists.
377
+ * Translate multiple keys at once for the current locale.
146
378
  *
147
- * @param key - The translation key to check.
148
- * @returns True if the key exists, false otherwise.
379
+ * @param keysOrEntries - Array of keys or [key, replacements] tuples.
380
+ * @returns Map of key to translated string.
149
381
  */
150
- has(key: string): boolean {
382
+ tMany(
383
+ keysOrEntries: Array<
384
+ | NestedKeyOf<Schema>
385
+ | (string & {})
386
+ | [NestedKeyOf<Schema> | (string & {}), Record<string, string | number>?]
387
+ >
388
+ ): Record<string, string> {
389
+ const result: Record<string, string> = {}
390
+ for (const item of keysOrEntries) {
391
+ if (typeof item === 'string') {
392
+ result[item] = this.t(item)
393
+ } else {
394
+ const [key, replacements] = item
395
+ result[key] = this.t(key, replacements)
396
+ }
397
+ }
398
+ return result
399
+ }
400
+
401
+ /**
402
+ * Check if a translation key exists for the current locale.
403
+ *
404
+ * @param key - Key to check.
405
+ * @returns True if key exists.
406
+ */
407
+ has(key: NestedKeyOf<Schema> | (string & {})): boolean {
151
408
  return this.t(key) !== key
152
409
  }
153
410
 
154
411
  /**
155
412
  * Clone the current instance with a potentially new locale.
156
413
  *
157
- * @param locale - Optional new locale for the cloned instance.
414
+ * Shares the same manager and underlying resources.
415
+ *
416
+ * @param locale - Optional new locale for the clone.
158
417
  * @returns A new I18nInstance.
159
418
  */
160
- clone(locale?: string): I18nService {
419
+ clone(locale?: string): I18nService<Schema> {
161
420
  return new I18nInstance(this.manager, locale || this._locale)
162
421
  }
163
422
 
164
423
  /**
165
- * Get the I18n configuration.
424
+ * Get the current I18n configuration from the manager.
425
+ * @returns I18nConfig
166
426
  */
167
427
  getConfig(): I18nConfig {
168
428
  return this.manager.getConfig()
169
429
  }
170
430
 
171
431
  /**
172
- * Get the translations.
432
+ * Access the central translation bundles.
173
433
  */
174
434
  get translations(): Record<string, TranslationMap> {
175
435
  return this.manager.translations
176
436
  }
437
+
438
+ /**
439
+ * Get list of all currently loaded locales.
440
+ */
441
+ getLocales(): string[] {
442
+ return this.manager.getLocales()
443
+ }
444
+
445
+ /**
446
+ * Check if translations for a specific locale are already loaded.
447
+ */
448
+ isLocaleLoaded(locale: string): boolean {
449
+ return this.manager.isLocaleLoaded(locale)
450
+ }
451
+
452
+ /**
453
+ * Get performance and usage statistics.
454
+ */
455
+ getStats(): I18nStats {
456
+ return this.manager.getStats()
457
+ }
177
458
  }
178
459
 
179
460
  /**
180
- * Global I18n Manager
181
- * Holds shared configuration and translation resources
461
+ * Global I18n Manager.
462
+ *
463
+ * The central hub for internationalization. Holds configuration, shared translation
464
+ * resources, pluralization rules, and handles the actual translation logic
465
+ * including fallbacks and caching.
466
+ *
467
+ * @template Schema - The translation schema for type-safe keys.
468
+ * @public
469
+ * @since 3.0.0
182
470
  */
183
- export class I18nManager implements I18nService {
471
+ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema> {
472
+ /** Map of translation bundles indexed by locale. */
184
473
  public translations: Record<string, TranslationMap> = {}
185
- // Default instance for global usage (e.g. CLI or background jobs)
186
- private globalInstance: I18nInstance
474
+ /** Internal cache for resolved translation strings. */
475
+ private cache: LRUCache<string, string>
476
+ /** Cache for Intl.PluralRules instances. */
477
+ private pluralRules = new Map<string, Intl.PluralRules>()
478
+ /** Set of locales that have been successfully loaded. */
479
+ private loadedLocales = new Set<string>()
480
+ /** Map of pending locale load promises for coalescing. */
481
+ private loadingPromises = new Map<string, Promise<void>>()
482
+ /** Counter for cache hits. */
483
+ private cacheHits = 0
484
+ /** Counter for cache misses. */
485
+ private cacheMisses = 0
486
+ /** Default instance for global/CLI usage. */
487
+ private globalInstance: I18nInstance<Schema>
488
+ /** HMR watcher for development mode. */
489
+ private hmrWatcher?: HMRWatcher
187
490
 
188
491
  /**
189
492
  * Create a new I18nManager.
@@ -194,11 +497,25 @@ export class I18nManager implements I18nService {
194
497
  if (config.translations) {
195
498
  this.translations = config.translations
196
499
  }
500
+ this.cache = new LRUCache<string, string>({
501
+ max: 10000,
502
+ ttl: 1000 * 60 * 60, // 1 hour
503
+ })
197
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
+ }
198
514
  }
199
515
 
200
516
  // --- I18nService Implementation (Delegates to global instance) ---
201
517
 
518
+ /** The global active locale. */
202
519
  get locale(): string {
203
520
  return this.globalInstance.locale
204
521
  }
@@ -209,8 +526,7 @@ export class I18nManager implements I18nService {
209
526
 
210
527
  /**
211
528
  * Set the global locale.
212
- *
213
- * @param locale - The locale to set.
529
+ * @param locale - Locale string.
214
530
  */
215
531
  setLocale(locale: string): void {
216
532
  this.globalInstance.setLocale(locale)
@@ -218,8 +534,6 @@ export class I18nManager implements I18nService {
218
534
 
219
535
  /**
220
536
  * Get the global locale.
221
- *
222
- * @returns The global locale string.
223
537
  */
224
538
  getLocale(): string {
225
539
  return this.globalInstance.getLocale()
@@ -228,95 +542,340 @@ export class I18nManager implements I18nService {
228
542
  /**
229
543
  * Translate a key using the global locale.
230
544
  *
231
- * @param key - The translation key.
232
- * @param replacements - Optional replacements.
233
- * @returns The translated string.
545
+ * @param key - Translation key.
546
+ * @param replacements - Replacement parameters.
234
547
  */
235
- t(key: string, replacements?: Record<string, string | number>): string {
548
+ t(
549
+ key: NestedKeyOf<Schema> | (string & {}),
550
+ replacements?: Record<string, string | number>
551
+ ): string {
236
552
  return this.globalInstance.t(key, replacements)
237
553
  }
238
554
 
555
+ /**
556
+ * Translate multiple keys at once using the global locale.
557
+ */
558
+ tMany(
559
+ keysOrEntries: Array<
560
+ | NestedKeyOf<Schema>
561
+ | (string & {})
562
+ | [NestedKeyOf<Schema> | (string & {}), Record<string, string | number>?]
563
+ >
564
+ ): Record<string, string> {
565
+ return this.globalInstance.tMany(keysOrEntries)
566
+ }
567
+
239
568
  /**
240
569
  * Check if a translation key exists in the global locale.
241
- *
242
- * @param key - The translation key.
243
- * @returns True if found.
244
570
  */
245
- has(key: string): boolean {
571
+ has(key: NestedKeyOf<Schema> | (string & {})): boolean {
246
572
  return this.globalInstance.has(key)
247
573
  }
248
574
 
249
575
  /**
250
- * Clone the global instance.
576
+ * Create a request-scoped I18nInstance from this manager.
251
577
  *
252
- * @param locale - Optional locale for the clone.
253
- * @returns A new I18nInstance.
578
+ * @param locale - Optional initial locale.
254
579
  */
255
- clone(locale?: string): I18nService {
580
+ clone(locale?: string): I18nService<Schema> {
256
581
  return new I18nInstance(this, locale || this.config.defaultLocale)
257
582
  }
258
583
 
259
- // --- Manager Internal API ---
584
+ /**
585
+ * Get list of all currently loaded locales.
586
+ */
587
+ getLocales(): string[] {
588
+ return Object.keys(this.translations)
589
+ }
590
+
591
+ /**
592
+ * Check if translations for a specific locale are already loaded.
593
+ */
594
+ isLocaleLoaded(locale: string): boolean {
595
+ return this.loadedLocales.has(locale) || locale in this.translations
596
+ }
597
+
598
+ /**
599
+ * Get performance and usage statistics.
600
+ */
601
+ getStats(): I18nStats {
602
+ const totalKeys = Object.values(this.translations).reduce((acc, map) => {
603
+ // Very rough estimate of keys, deep counting is expensive
604
+ return acc + Object.keys(map).length
605
+ }, 0)
606
+
607
+ const totalRequests = this.cacheHits + this.cacheMisses
608
+ const cacheHitRate = totalRequests > 0 ? (this.cacheHits / totalRequests) * 100 : 0
609
+
610
+ return {
611
+ localesCount: Object.keys(this.translations).length,
612
+ totalKeys,
613
+ cacheHitRate,
614
+ cacheSize: this.cache.size,
615
+ }
616
+ }
260
617
 
261
618
  /**
262
- * Get the I18n configuration.
619
+ * Ensure translations for a locale are loaded from the filesystem if lazy loading is enabled.
263
620
  *
264
- * @returns The configuration object.
621
+ * @param locale - Locale to load.
622
+ */
623
+ async ensureLocale(locale: string): Promise<void> {
624
+ // If already loaded, skip
625
+ if (this.loadedLocales.has(locale)) {
626
+ return
627
+ }
628
+
629
+ // Check for pending load promise to coalesce requests
630
+ if (this.loadingPromises.has(locale)) {
631
+ return this.loadingPromises.get(locale)
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
666
+ }
667
+
668
+ // --- Manager Internal API ---
669
+
670
+ /**
671
+ * Get the current shared I18n configuration.
265
672
  */
266
673
  getConfig(): I18nConfig {
267
674
  return this.config
268
675
  }
269
676
 
270
677
  /**
271
- * Add a resource bundle for a specific locale.
678
+ * Add or merge a resource bundle for a specific locale.
272
679
  *
273
- * @param locale - The locale string.
274
- * @param translations - The translations object.
680
+ * @param locale - The locale string (e.g., 'en', 'fr').
681
+ * @param translations - The translation map to merge into the existing bundle.
682
+ *
683
+ * @example
684
+ * ```typescript
685
+ * manager.addResource('es', {
686
+ * greeting: 'Hola',
687
+ * auth: { login: 'Iniciar sesión' }
688
+ * });
689
+ * ```
275
690
  */
276
691
  addResource(locale: string, translations: TranslationMap) {
277
692
  this.translations[locale] = {
278
693
  ...(this.translations[locale] || {}),
279
694
  ...translations,
280
695
  }
696
+ this.invalidateCache(locale)
281
697
  }
282
698
 
283
699
  /**
284
- * Internal translation logic used by instances
700
+ * Invalidate the translation cache.
701
+ *
702
+ * @param locale - If provided, only invalidates keys for this locale.
285
703
  */
286
- translate(locale: string, key: string, replacements?: Record<string, string | number>): string {
704
+ private invalidateCache(locale?: string) {
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().
711
+ for (const key of this.cache.keys()) {
712
+ if (key.startsWith(`${locale}:`)) {
713
+ this.cache.delete(key)
714
+ }
715
+ }
716
+ } else {
717
+ this.cache.clear()
718
+ }
719
+ }
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
+
752
+ /**
753
+ * Get the plural form for a locale and count.
754
+ */
755
+ private getPluralForm(locale: string, count: number): string {
756
+ if (!this.pluralRules.has(locale)) {
757
+ this.pluralRules.set(locale, new Intl.PluralRules(locale))
758
+ }
759
+ return this.pluralRules.get(locale)!.select(count)
760
+ }
761
+
762
+ /**
763
+ * Resolve a dot-notation key to its value in a specific locale.
764
+ */
765
+ private resolveKey(locale: string, key: string): string | TranslationMap | undefined {
287
766
  const keys = key.split('.')
288
767
  let value: string | TranslationMap | undefined = this.translations[locale]
289
768
 
290
- // 1. Try current locale
291
769
  for (const k of keys) {
292
770
  if (value && typeof value === 'object' && k in value) {
293
771
  value = (value as TranslationMap)[k]
294
772
  } else {
295
- value = undefined
296
- break
773
+ return undefined
297
774
  }
298
775
  }
776
+ return value
777
+ }
299
778
 
300
- // 2. If not found, try fallback (defaultLocale)
301
- if (value === undefined && locale !== this.config.defaultLocale) {
302
- let fallbackValue: string | TranslationMap | undefined =
303
- this.translations[this.config.defaultLocale]
304
- for (const k of keys) {
305
- if (fallbackValue && typeof fallbackValue === 'object' && k in fallbackValue) {
306
- fallbackValue = (fallbackValue as TranslationMap)[k]
307
- } else {
308
- fallbackValue = undefined
309
- break
310
- }
779
+ /**
780
+ * Resolve a key using the configured fallback chain.
781
+ */
782
+ private resolveFallback(locale: string, key: string): string | TranslationMap | undefined {
783
+ const chain = this.config.fallback?.fallbackChain?.[locale] ?? [this.config.defaultLocale]
784
+
785
+ for (const fallbackLocale of chain) {
786
+ // Avoid infinite recursion if fallback points to itself
787
+ if (fallbackLocale === locale) {
788
+ continue
789
+ }
790
+
791
+ const value = this.resolveKey(fallbackLocale, key)
792
+ if (value !== undefined) {
793
+ return value
311
794
  }
312
- value = fallbackValue
313
795
  }
314
796
 
315
- if (value === undefined || typeof value !== 'string') {
316
- return key // Return key if not found
797
+ return undefined
798
+ }
799
+
800
+ /**
801
+ * Handle cases where a translation key is missing after fallbacks.
802
+ */
803
+ private handleMissingKey(key: string, locale: string): string {
804
+ const handler = this.config.fallback?.onMissingKey ?? 'key'
805
+
806
+ if (this.config.fallback?.warnOnMissing) {
807
+ console.warn(`[i18n] Missing translation: ${key} (${locale})`)
808
+ }
809
+
810
+ if (typeof handler === 'function') {
811
+ return handler(key, locale)
812
+ }
813
+
814
+ switch (handler) {
815
+ case 'empty':
816
+ return ''
817
+ case 'throw':
818
+ throw new Error(`Missing translation: ${key}`)
819
+ default:
820
+ return key
821
+ }
822
+ }
823
+
824
+ /**
825
+ * The core translation logic. Handles caching, key resolution, fallbacks,
826
+ * pluralization, and parameter replacement.
827
+ *
828
+ * @param locale - Locale to translate into.
829
+ * @param key - Translation key.
830
+ * @param replacements - Placeholder replacements.
831
+ * @returns Translated string.
832
+ */
833
+ translate(locale: string, key: string, replacements?: Record<string, string | number>): string {
834
+ const cacheKey = `${locale}:${key}`
835
+ let value: string | TranslationMap | undefined
836
+
837
+ if (this.cache.has(cacheKey)) {
838
+ this.cacheHits++
839
+ value = this.cache.get(cacheKey)
840
+ } else {
841
+ this.cacheMisses++
842
+
843
+ // 1. Try current locale
844
+ value = this.resolveKey(locale, key)
845
+
846
+ // 2. Fallback
847
+ if (value === undefined) {
848
+ value = this.resolveFallback(locale, key)
849
+ }
850
+
851
+ if (value !== undefined && typeof value === 'string') {
852
+ this.cache.set(cacheKey, value)
853
+ }
854
+ }
855
+
856
+ // Pluralization
857
+ if (value && typeof value === 'object' && replacements?.count !== undefined) {
858
+ const count = Number(replacements.count)
859
+ const pluralMap = value as TranslationMap
860
+ const form = this.getPluralForm(locale, count)
861
+
862
+ if (count === 0 && 'zero' in pluralMap) {
863
+ value = pluralMap.zero
864
+ } else if (form in pluralMap) {
865
+ value = pluralMap[form]
866
+ } else if ('other' in pluralMap) {
867
+ value = pluralMap.other
868
+ }
869
+ }
870
+
871
+ if (value === undefined) {
872
+ return this.handleMissingKey(key, locale)
873
+ }
874
+
875
+ if (typeof value !== 'string') {
876
+ return key
317
877
  }
318
878
 
319
- // 3. Replacements
320
879
  if (replacements && Object.keys(replacements).length > 0) {
321
880
  value = value.replace(REPLACEMENT_REGEX, (match, key) => {
322
881
  return (replacements as Record<string, unknown>)[key] !== undefined
@@ -329,29 +888,94 @@ export class I18nManager implements I18nService {
329
888
  }
330
889
  }
331
890
 
891
+ /** Regex for finding placeholders in translation strings. */
332
892
  const REPLACEMENT_REGEX = /:([a-zA-Z0-9_]+)/g
333
893
 
334
894
  /**
335
- * Locale Middleware
895
+ * Detector that extracts the locale from a route parameter named 'locale'.
336
896
  *
337
- * Detects locale from:
338
- * 1. Route Parameter (e.g. /:locale/foo) - Recommended for SEO
339
- * 2. Header (Accept-Language) - Recommended for APIs
897
+ * @example
898
+ * ```typescript
899
+ * // router.get('/:locale/home', ...)
900
+ * ```
901
+ * @public
340
902
  */
341
- export const localeMiddleware = (i18nManager: I18nService): GravitoMiddleware => {
903
+ export const RouteParamDetector: LocaleDetector = {
904
+ name: 'routeParam',
905
+ detect: (c) => c.req.param('locale'),
906
+ }
907
+
908
+ /**
909
+ * Detector that extracts the locale from a query parameter named 'lang'.
910
+ *
911
+ * @example
912
+ * ```typescript
913
+ * // GET /home?lang=zh-TW
914
+ * ```
915
+ * @public
916
+ */
917
+ export const QueryDetector: LocaleDetector = {
918
+ name: 'query',
919
+ detect: (c) => c.req.query('lang'),
920
+ }
921
+
922
+ /**
923
+ * Detector that extracts the locale from the 'Accept-Language' HTTP header.
924
+ *
925
+ * Picks the first (preferred) language from the comma-separated list.
926
+ *
927
+ * @public
928
+ */
929
+ export const HeaderDetector: LocaleDetector = {
930
+ name: 'header',
931
+ detect: (c) => {
932
+ const acceptLang = c.req.header('Accept-Language')
933
+ if (acceptLang) {
934
+ return acceptLang.split(',')[0]?.trim()
935
+ }
936
+ return undefined
937
+ },
938
+ }
939
+
940
+ /**
941
+ * Default list of detectors used by the middleware.
942
+ * Order: Route Parameter > Query Parameter > Accept-Language Header.
943
+ *
944
+ * @public
945
+ */
946
+ export const DefaultDetectors = [RouteParamDetector, QueryDetector, HeaderDetector]
947
+
948
+ /**
949
+ * Middleware for Gravito/Photon that handles request-scoped internationalization.
950
+ *
951
+ * It uses a list of detectors to determine the locale, ensures it is loaded,
952
+ * and injects a request-scoped `I18nService` instance into the context under the key 'i18n'.
953
+ *
954
+ * @param i18nManager - The central I18nManager instance.
955
+ * @param detectors - Optional custom list of detectors.
956
+ * @returns GravitoMiddleware
957
+ *
958
+ * @public
959
+ */
960
+ export const localeMiddleware = (
961
+ i18nManager: I18nService,
962
+ detectors: LocaleDetector[] = DefaultDetectors
963
+ ): GravitoMiddleware => {
342
964
  return async (c, next) => {
343
- // Determine initial locale
344
- // Priority: 1. Route Param 2. Query ?lang= 3. Header 4. Default
345
- let locale = c.req.param('locale') || c.req.query('lang')
346
-
347
- if (!locale) {
348
- const acceptLang = c.req.header('Accept-Language')
349
- if (acceptLang) {
350
- // Simple extraction: 'en-US,en;q=0.9' -> 'en-US'
351
- locale = acceptLang.split(',')[0]?.trim()
965
+ let locale: string | undefined
966
+
967
+ for (const detector of detectors) {
968
+ const result = await detector.detect(c)
969
+ if (result) {
970
+ locale = result
971
+ break
352
972
  }
353
973
  }
354
974
 
975
+ if (locale) {
976
+ await i18nManager.ensureLocale(locale)
977
+ }
978
+
355
979
  // Clone a request-scoped instance
356
980
  const i18n = i18nManager.clone(locale)
357
981