@gravito/cosmos 3.0.0 → 3.1.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 (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +105 -45
  3. package/README.zh-TW.md +102 -22
  4. package/build.ts +35 -0
  5. package/docs/plans/01-performance.md +187 -0
  6. package/docs/plans/02-architecture.md +309 -0
  7. package/docs/plans/03-api-enhancement.md +345 -0
  8. package/docs/plans/04-testing.md +431 -0
  9. package/docs/plans/README.md +47 -0
  10. package/ion/src/index.js +1179 -1138
  11. package/package.json +18 -5
  12. package/src/I18nService.ts +657 -94
  13. package/src/index.ts +51 -6
  14. package/src/loader.ts +45 -12
  15. package/tests/helpers/factory.ts +41 -0
  16. package/tests/performance/translate.bench.ts +27 -0
  17. package/tests/unit/api.test.ts +37 -0
  18. package/tests/unit/detector.test.ts +70 -0
  19. package/tests/unit/edge.test.ts +100 -0
  20. package/tests/unit/fallback.test.ts +66 -0
  21. package/tests/unit/lazy.test.ts +35 -0
  22. package/tests/{loader.test.ts → unit/loader.test.ts} +1 -1
  23. package/tests/{manager.test.ts → unit/manager.test.ts} +1 -2
  24. package/tests/unit/plural.test.ts +58 -0
  25. package/tests/{service.test.ts → unit/service.test.ts} +7 -2
  26. package/tsconfig.json +12 -24
  27. package/dist/core/src/Application.d.ts +0 -185
  28. package/dist/core/src/ConfigManager.d.ts +0 -21
  29. package/dist/core/src/Container.d.ts +0 -38
  30. package/dist/core/src/Event.d.ts +0 -5
  31. package/dist/core/src/EventManager.d.ts +0 -123
  32. package/dist/core/src/GlobalErrorHandlers.d.ts +0 -31
  33. package/dist/core/src/GravitoServer.d.ts +0 -20
  34. package/dist/core/src/HookManager.d.ts +0 -70
  35. package/dist/core/src/Listener.d.ts +0 -4
  36. package/dist/core/src/Logger.d.ts +0 -20
  37. package/dist/core/src/PlanetCore.d.ts +0 -207
  38. package/dist/core/src/Route.d.ts +0 -25
  39. package/dist/core/src/Router.d.ts +0 -232
  40. package/dist/core/src/ServiceProvider.d.ts +0 -150
  41. package/dist/core/src/adapters/PhotonAdapter.d.ts +0 -142
  42. package/dist/core/src/adapters/bun/BunContext.d.ts +0 -36
  43. package/dist/core/src/adapters/bun/BunNativeAdapter.d.ts +0 -21
  44. package/dist/core/src/adapters/bun/BunRequest.d.ts +0 -27
  45. package/dist/core/src/adapters/bun/RadixNode.d.ts +0 -15
  46. package/dist/core/src/adapters/bun/RadixRouter.d.ts +0 -31
  47. package/dist/core/src/adapters/bun/types.d.ts +0 -20
  48. package/dist/core/src/adapters/types.d.ts +0 -186
  49. package/dist/core/src/engine/AOTRouter.d.ts +0 -117
  50. package/dist/core/src/engine/FastContext.d.ts +0 -34
  51. package/dist/core/src/engine/Gravito.d.ts +0 -191
  52. package/dist/core/src/engine/MinimalContext.d.ts +0 -36
  53. package/dist/core/src/engine/analyzer.d.ts +0 -21
  54. package/dist/core/src/engine/index.d.ts +0 -26
  55. package/dist/core/src/engine/path.d.ts +0 -26
  56. package/dist/core/src/engine/pool.d.ts +0 -83
  57. package/dist/core/src/engine/types.d.ts +0 -107
  58. package/dist/core/src/exceptions/AuthenticationException.d.ts +0 -4
  59. package/dist/core/src/exceptions/AuthorizationException.d.ts +0 -4
  60. package/dist/core/src/exceptions/GravitoException.d.ts +0 -15
  61. package/dist/core/src/exceptions/HttpException.d.ts +0 -5
  62. package/dist/core/src/exceptions/ModelNotFoundException.d.ts +0 -6
  63. package/dist/core/src/exceptions/ValidationException.d.ts +0 -14
  64. package/dist/core/src/exceptions/index.d.ts +0 -6
  65. package/dist/core/src/helpers/Arr.d.ts +0 -14
  66. package/dist/core/src/helpers/Str.d.ts +0 -18
  67. package/dist/core/src/helpers/data.d.ts +0 -5
  68. package/dist/core/src/helpers/errors.d.ts +0 -12
  69. package/dist/core/src/helpers/response.d.ts +0 -17
  70. package/dist/core/src/helpers.d.ts +0 -38
  71. package/dist/core/src/http/CookieJar.d.ts +0 -37
  72. package/dist/core/src/http/middleware/BodySizeLimit.d.ts +0 -6
  73. package/dist/core/src/http/middleware/Cors.d.ts +0 -12
  74. package/dist/core/src/http/middleware/Csrf.d.ts +0 -11
  75. package/dist/core/src/http/middleware/HeaderTokenGate.d.ts +0 -11
  76. package/dist/core/src/http/middleware/SecurityHeaders.d.ts +0 -17
  77. package/dist/core/src/http/middleware/ThrottleRequests.d.ts +0 -12
  78. package/dist/core/src/http/types.d.ts +0 -312
  79. package/dist/core/src/index.d.ts +0 -60
  80. package/dist/core/src/runtime.d.ts +0 -63
  81. package/dist/core/src/security/Encrypter.d.ts +0 -24
  82. package/dist/core/src/security/Hasher.d.ts +0 -29
  83. package/dist/core/src/testing/HttpTester.d.ts +0 -38
  84. package/dist/core/src/testing/TestResponse.d.ts +0 -78
  85. package/dist/core/src/testing/index.d.ts +0 -2
  86. package/dist/core/src/types/events.d.ts +0 -94
  87. package/dist/cosmos/src/I18nService.d.ts +0 -144
  88. package/dist/cosmos/src/index.d.ts +0 -21
  89. package/dist/cosmos/src/loader.d.ts +0 -11
  90. package/dist/index.js +0 -168
  91. package/dist/photon/src/index.d.ts +0 -2
  92. package/dist/src/index.js +0 -173
@@ -1,59 +1,281 @@
1
- import type { MiddlewareHandler } from '@gravito/photon'
1
+ /**
2
+ * @file packages/cosmos/src/I18nService.ts
3
+ * @module @gravito/cosmos/service
4
+ * @description Core services and interfaces for internationalization.
5
+ */
2
6
 
7
+ import type { GravitoMiddleware } from '@gravito/core'
8
+ import { loadLocale } from './loader'
9
+
10
+ /**
11
+ * Interface for locale detectors used to determine the user's preferred locale from a request context.
12
+ *
13
+ * @public
14
+ * @since 3.0.0
15
+ */
16
+ export interface LocaleDetector {
17
+ /** The unique name of the detector (e.g., 'query', 'header'). */
18
+ name: string
19
+ /**
20
+ * Detect the locale from the given context.
21
+ *
22
+ * @param c - The application context (e.g., Hono context).
23
+ * @returns The detected locale string, or undefined if not found.
24
+ */
25
+ detect(c: any): string | undefined | Promise<string | undefined>
26
+ }
27
+
28
+ /**
29
+ * A map of translations where keys are translation keys and values
30
+ * are either the translated string or a nested map for grouping.
31
+ *
32
+ * @public
33
+ * @since 3.0.0
34
+ */
3
35
  export type TranslationMap = {
4
36
  [key: string]: string | TranslationMap
5
37
  }
6
38
 
39
+ /**
40
+ * Utility type to extract all possible dot-notation keys from a nested translation schema.
41
+ *
42
+ * @template T - The translation schema object.
43
+ * @public
44
+ */
45
+ export type NestedKeyOf<T> = T extends object
46
+ ? {
47
+ [K in keyof T & (string | number)]: T[K] extends object
48
+ ? `${K}` | `${K}.${NestedKeyOf<T[K]>}`
49
+ : `${K}`
50
+ }[keyof T & (string | number)]
51
+ : never
52
+
53
+ /**
54
+ * Configuration for lazy loading translation files.
55
+ *
56
+ * @public
57
+ */
58
+ export interface LazyLoadConfig {
59
+ /** The base directory where translation files are located. */
60
+ baseDir: string
61
+ /** Optional list of locales to preload on startup. */
62
+ preload?: string[]
63
+ }
64
+
65
+ /**
66
+ * Configuration for translation fallback strategies.
67
+ *
68
+ * @public
69
+ */
70
+ export interface FallbackConfig {
71
+ /**
72
+ * Custom fallback chains for specific locales.
73
+ * Key is the requested locale, value is an array of fallback locales.
74
+ */
75
+ fallbackChain?: Record<string, string[]>
76
+ /**
77
+ * Strategy to use when a translation key is missing.
78
+ * - 'key': Return the key itself (default).
79
+ * - 'empty': Return an empty string.
80
+ * - 'throw': Throw an error.
81
+ * - (key, locale) => string: Custom handler function.
82
+ */
83
+ onMissingKey?: 'key' | 'empty' | 'throw' | ((key: string, locale: string) => string)
84
+ /** Whether to log a warning when a key is missing. */
85
+ warnOnMissing?: boolean
86
+ }
87
+
88
+ /**
89
+ * Configuration for the I18n service.
90
+ *
91
+ * @public
92
+ * @since 3.0.0
93
+ */
7
94
  export interface I18nConfig {
95
+ /** The default locale to use when no locale is detected or for fallbacks. */
8
96
  defaultLocale: string
97
+ /** List of locales officially supported by the application. */
9
98
  supportedLocales: string[]
10
- // Path to translation files, or a Record of translations
11
- // If undefined, it will look into `resources/lang` by default (conceptually, handled by loader)
99
+ /**
100
+ * Static translations indexed by locale.
101
+ * Keys are locale strings (e.g., 'en', 'zh-TW').
102
+ */
12
103
  translations?: Record<string, TranslationMap>
104
+ /**
105
+ * Configuration for lazy loading translation files from the filesystem.
106
+ */
107
+ lazyLoad?: LazyLoadConfig
108
+ /**
109
+ * Configuration for fallback strategies and missing key handling.
110
+ */
111
+ fallback?: FallbackConfig
112
+ }
113
+
114
+ /**
115
+ * Statistics about the I18n service state and performance.
116
+ *
117
+ * @public
118
+ */
119
+ export interface I18nStats {
120
+ /** Number of locales currently loaded. */
121
+ localesCount: number
122
+ /** Estimated total number of translation keys across all locales. */
123
+ totalKeys: number
124
+ /** Percentage of translation requests served from cache. */
125
+ cacheHitRate: number
126
+ /** Number of entries currently in the translation cache. */
127
+ cacheSize: number
13
128
  }
14
129
 
15
- export interface I18nService {
130
+ /**
131
+ * Interface for the I18n service providing translation capabilities.
132
+ *
133
+ * It allows for setting and getting the current locale, translating strings
134
+ * with optional replacements, and checking for key existence.
135
+ *
136
+ * @template Schema - The translation schema for type-safe keys.
137
+ * @public
138
+ * @since 3.0.0
139
+ */
140
+ export interface I18nService<Schema = TranslationMap> {
141
+ /** The current active locale for this instance. */
16
142
  locale: string
143
+ /**
144
+ * Set the active locale for this service instance.
145
+ *
146
+ * @param locale - Valid locale string from supportedLocales.
147
+ * @throws Error if locale is not supported (depending on implementation).
148
+ */
17
149
  setLocale(locale: string): void
150
+ /**
151
+ * Get the current active locale.
152
+ *
153
+ * @returns Current locale string.
154
+ */
18
155
  getLocale(): string
19
- t(key: string, replacements?: Record<string, string | number>): string
20
- has(key: string): boolean
21
- // Create a request-scoped instance
22
- clone(locale?: string): I18nService
156
+ /**
157
+ * Ensure translations for a locale are loaded (useful for lazy loading).
158
+ *
159
+ * @param locale - The locale to load.
160
+ * @returns Promise that resolves when loading is complete.
161
+ */
162
+ ensureLocale(locale: string): Promise<void>
163
+ /**
164
+ * Translate a key into the current locale.
165
+ *
166
+ * Supports parameter replacement using `:key` syntax and pluralization
167
+ * if a `count` parameter is provided.
168
+ *
169
+ * @param key - The translation key (e.g., 'auth.login_success').
170
+ * @param replacements - Optional placeholders replaced by values.
171
+ * @returns The translated string, or the key itself if not found.
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * i18n.t('messages.hello', { name: 'John' }); // "Hello John"
176
+ * i18n.t('items.count', { count: 5 }); // "5 items"
177
+ * ```
178
+ */
179
+ t(
180
+ key: NestedKeyOf<Schema> | (string & {}),
181
+ replacements?: Record<string, string | number>
182
+ ): string
183
+ /**
184
+ * Translate multiple keys at once.
185
+ *
186
+ * @param keysOrEntries - Array of keys or [key, replacements] tuples.
187
+ * @returns Object mapping each key to its translated string.
188
+ */
189
+ tMany(
190
+ keysOrEntries: Array<
191
+ | NestedKeyOf<Schema>
192
+ | (string & {})
193
+ | [NestedKeyOf<Schema> | (string & {}), Record<string, string | number>?]
194
+ >
195
+ ): Record<string, string>
196
+ /**
197
+ * Check if a translation key exists for the current locale.
198
+ *
199
+ * @param key - The key to check.
200
+ * @returns True if the key exists, false otherwise.
201
+ */
202
+ has(key: NestedKeyOf<Schema> | (string & {})): boolean
203
+ /**
204
+ * Create a new request-scoped instance of the I18n service.
205
+ *
206
+ * This is typically used in middleware to provide a fresh instance per request
207
+ * that shares the same translation resources but has its own locale state.
208
+ *
209
+ * @param locale - Optional initial locale for the new instance.
210
+ * @returns A new I18nService instance.
211
+ */
212
+ clone(locale?: string): I18nService<Schema>
213
+
214
+ /**
215
+ * Get list of all currently loaded locales.
216
+ * @returns Array of locale strings.
217
+ */
218
+ getLocales(): string[]
219
+ /**
220
+ * Check if translations for a specific locale are already loaded.
221
+ * @param locale - Locale to check.
222
+ * @returns True if loaded.
223
+ */
224
+ isLocaleLoaded(locale: string): boolean
225
+ /**
226
+ * Get performance and usage statistics.
227
+ * @returns I18nStats object.
228
+ */
229
+ getStats(): I18nStats
23
230
  }
24
231
 
25
232
  /**
26
- * Request-scoped I18n Instance
27
- * Holds the state (locale) for a single request, but shares the heavy resources (translations)
233
+ * Request-scoped I18n Instance.
234
+ *
235
+ * Holds the state (current locale) for a single request, but shares the heavy
236
+ * resources (translation bundles) through the central I18nManager.
237
+ *
238
+ * @template Schema - The translation schema for type-safe keys.
239
+ * @public
240
+ * @since 3.0.0
28
241
  */
29
- export class I18nInstance implements I18nService {
242
+ export class I18nInstance<Schema = TranslationMap> implements I18nService<Schema> {
243
+ /** The current active locale for this instance. */
30
244
  private _locale: string
31
245
 
32
246
  /**
33
247
  * Create a new I18nInstance.
34
248
  *
35
- * @param manager - The I18nManager instance.
249
+ * @param manager - The central I18nManager instance.
36
250
  * @param initialLocale - The initial locale for this instance.
37
251
  */
38
252
  constructor(
39
- private manager: I18nManager,
253
+ public readonly manager: I18nManager<Schema>,
40
254
  initialLocale: string
41
255
  ) {
42
256
  this._locale = initialLocale
43
257
  }
44
258
 
259
+ /**
260
+ * Get the current active locale.
261
+ */
45
262
  get locale(): string {
46
263
  return this._locale
47
264
  }
48
265
 
266
+ /**
267
+ * Set the current active locale.
268
+ */
49
269
  set locale(value: string) {
50
270
  this.setLocale(value)
51
271
  }
52
272
 
53
273
  /**
54
- * Set the current locale.
274
+ * Set the active locale for this instance.
275
+ *
276
+ * Validates that the locale is among the supported locales defined in config.
55
277
  *
56
- * @param locale - The locale to set.
278
+ * @param locale - Valid locale string from supportedLocales.
57
279
  */
58
280
  setLocale(locale: string) {
59
281
  if (this.manager.getConfig().supportedLocales.includes(locale)) {
@@ -62,54 +284,151 @@ export class I18nInstance implements I18nService {
62
284
  }
63
285
 
64
286
  /**
65
- * Get the current locale.
287
+ * Ensure translations for a locale are loaded.
288
+ *
289
+ * Delegates to the manager's loading mechanism (e.g., filesystem read).
290
+ *
291
+ * @param locale - Locale to load.
292
+ */
293
+ async ensureLocale(locale: string): Promise<void> {
294
+ return this.manager.ensureLocale(locale)
295
+ }
296
+
297
+ /**
298
+ * Get the current locale string.
66
299
  *
67
- * @returns The current locale string.
300
+ * @returns Current locale.
68
301
  */
69
302
  getLocale(): string {
70
303
  return this._locale
71
304
  }
72
305
 
73
306
  /**
74
- * Translate a key.
307
+ * Translate a key into the current locale.
308
+ *
309
+ * Supports parameter replacement and pluralization.
75
310
  *
76
- * @param key - The translation key (e.g., 'messages.welcome').
77
- * @param replacements - Optional replacements for parameters in the translation string.
78
- * @returns The translated string, or the key if not found.
311
+ * @param key - The translation key (dot notation supported).
312
+ * @param replacements - Key-value pairs for parameter replacement.
313
+ * @returns The translated string.
79
314
  */
80
- t(key: string, replacements?: Record<string, string | number>): string {
315
+ t(
316
+ key: NestedKeyOf<Schema> | (string & {}),
317
+ replacements?: Record<string, string | number>
318
+ ): string {
81
319
  return this.manager.translate(this._locale, key, replacements)
82
320
  }
83
321
 
84
322
  /**
85
- * Check if a translation key exists.
323
+ * Translate multiple keys at once for the current locale.
86
324
  *
87
- * @param key - The translation key to check.
88
- * @returns True if the key exists, false otherwise.
325
+ * @param keysOrEntries - Array of keys or [key, replacements] tuples.
326
+ * @returns Map of key to translated string.
327
+ */
328
+ tMany(
329
+ keysOrEntries: Array<
330
+ | NestedKeyOf<Schema>
331
+ | (string & {})
332
+ | [NestedKeyOf<Schema> | (string & {}), Record<string, string | number>?]
333
+ >
334
+ ): Record<string, string> {
335
+ const result: Record<string, string> = {}
336
+ for (const item of keysOrEntries) {
337
+ if (typeof item === 'string') {
338
+ result[item] = this.t(item)
339
+ } else {
340
+ const [key, replacements] = item
341
+ result[key] = this.t(key, replacements)
342
+ }
343
+ }
344
+ return result
345
+ }
346
+
347
+ /**
348
+ * Check if a translation key exists for the current locale.
349
+ *
350
+ * @param key - Key to check.
351
+ * @returns True if key exists.
89
352
  */
90
- has(key: string): boolean {
353
+ has(key: NestedKeyOf<Schema> | (string & {})): boolean {
91
354
  return this.t(key) !== key
92
355
  }
93
356
 
94
357
  /**
95
358
  * Clone the current instance with a potentially new locale.
96
359
  *
97
- * @param locale - Optional new locale for the cloned instance.
360
+ * Shares the same manager and underlying resources.
361
+ *
362
+ * @param locale - Optional new locale for the clone.
98
363
  * @returns A new I18nInstance.
99
364
  */
100
- clone(locale?: string): I18nService {
365
+ clone(locale?: string): I18nService<Schema> {
101
366
  return new I18nInstance(this.manager, locale || this._locale)
102
367
  }
368
+
369
+ /**
370
+ * Get the current I18n configuration from the manager.
371
+ * @returns I18nConfig
372
+ */
373
+ getConfig(): I18nConfig {
374
+ return this.manager.getConfig()
375
+ }
376
+
377
+ /**
378
+ * Access the central translation bundles.
379
+ */
380
+ get translations(): Record<string, TranslationMap> {
381
+ return this.manager.translations
382
+ }
383
+
384
+ /**
385
+ * Get list of all currently loaded locales.
386
+ */
387
+ getLocales(): string[] {
388
+ return this.manager.getLocales()
389
+ }
390
+
391
+ /**
392
+ * Check if translations for a specific locale are already loaded.
393
+ */
394
+ isLocaleLoaded(locale: string): boolean {
395
+ return this.manager.isLocaleLoaded(locale)
396
+ }
397
+
398
+ /**
399
+ * Get performance and usage statistics.
400
+ */
401
+ getStats(): I18nStats {
402
+ return this.manager.getStats()
403
+ }
103
404
  }
104
405
 
105
406
  /**
106
- * Global I18n Manager
107
- * Holds shared configuration and translation resources
407
+ * Global I18n Manager.
408
+ *
409
+ * The central hub for internationalization. Holds configuration, shared translation
410
+ * resources, pluralization rules, and handles the actual translation logic
411
+ * including fallbacks and caching.
412
+ *
413
+ * @template Schema - The translation schema for type-safe keys.
414
+ * @public
415
+ * @since 3.0.0
108
416
  */
109
- export class I18nManager implements I18nService {
110
- private translations: Record<string, TranslationMap> = {}
111
- // Default instance for global usage (e.g. CLI or background jobs)
112
- private globalInstance: I18nInstance
417
+ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema> {
418
+ /** Map of translation bundles indexed by locale. */
419
+ public translations: Record<string, TranslationMap> = {}
420
+ /** Internal cache for resolved translation strings. */
421
+ private cache = new Map<string, string>()
422
+ /** Cache for Intl.PluralRules instances. */
423
+ private pluralRules = new Map<string, Intl.PluralRules>()
424
+ /** Set of locales that have been successfully loaded. */
425
+ private loadedLocales = new Set<string>()
426
+ /** Counter for cache hits. */
427
+ private cacheHits = 0
428
+ /** Counter for cache misses. */
429
+ private cacheMisses = 0
430
+ /** Default instance for global/CLI usage. */
431
+ private globalInstance: I18nInstance<Schema>
113
432
 
114
433
  /**
115
434
  * Create a new I18nManager.
@@ -125,6 +444,7 @@ export class I18nManager implements I18nService {
125
444
 
126
445
  // --- I18nService Implementation (Delegates to global instance) ---
127
446
 
447
+ /** The global active locale. */
128
448
  get locale(): string {
129
449
  return this.globalInstance.locale
130
450
  }
@@ -135,8 +455,7 @@ export class I18nManager implements I18nService {
135
455
 
136
456
  /**
137
457
  * Set the global locale.
138
- *
139
- * @param locale - The locale to set.
458
+ * @param locale - Locale string.
140
459
  */
141
460
  setLocale(locale: string): void {
142
461
  this.globalInstance.setLocale(locale)
@@ -144,8 +463,6 @@ export class I18nManager implements I18nService {
144
463
 
145
464
  /**
146
465
  * Get the global locale.
147
- *
148
- * @returns The global locale string.
149
466
  */
150
467
  getLocale(): string {
151
468
  return this.globalInstance.getLocale()
@@ -154,125 +471,371 @@ export class I18nManager implements I18nService {
154
471
  /**
155
472
  * Translate a key using the global locale.
156
473
  *
157
- * @param key - The translation key.
158
- * @param replacements - Optional replacements.
159
- * @returns The translated string.
474
+ * @param key - Translation key.
475
+ * @param replacements - Replacement parameters.
160
476
  */
161
- t(key: string, replacements?: Record<string, string | number>): string {
477
+ t(
478
+ key: NestedKeyOf<Schema> | (string & {}),
479
+ replacements?: Record<string, string | number>
480
+ ): string {
162
481
  return this.globalInstance.t(key, replacements)
163
482
  }
164
483
 
484
+ /**
485
+ * Translate multiple keys at once using the global locale.
486
+ */
487
+ tMany(
488
+ keysOrEntries: Array<
489
+ | NestedKeyOf<Schema>
490
+ | (string & {})
491
+ | [NestedKeyOf<Schema> | (string & {}), Record<string, string | number>?]
492
+ >
493
+ ): Record<string, string> {
494
+ return this.globalInstance.tMany(keysOrEntries)
495
+ }
496
+
165
497
  /**
166
498
  * Check if a translation key exists in the global locale.
167
- *
168
- * @param key - The translation key.
169
- * @returns True if found.
170
499
  */
171
- has(key: string): boolean {
500
+ has(key: NestedKeyOf<Schema> | (string & {})): boolean {
172
501
  return this.globalInstance.has(key)
173
502
  }
174
503
 
175
504
  /**
176
- * Clone the global instance.
505
+ * Create a request-scoped I18nInstance from this manager.
177
506
  *
178
- * @param locale - Optional locale for the clone.
179
- * @returns A new I18nInstance.
507
+ * @param locale - Optional initial locale.
180
508
  */
181
- clone(locale?: string): I18nService {
509
+ clone(locale?: string): I18nService<Schema> {
182
510
  return new I18nInstance(this, locale || this.config.defaultLocale)
183
511
  }
184
512
 
185
- // --- Manager Internal API ---
513
+ /**
514
+ * Get list of all currently loaded locales.
515
+ */
516
+ getLocales(): string[] {
517
+ return Object.keys(this.translations)
518
+ }
519
+
520
+ /**
521
+ * Check if translations for a specific locale are already loaded.
522
+ */
523
+ isLocaleLoaded(locale: string): boolean {
524
+ return this.loadedLocales.has(locale) || locale in this.translations
525
+ }
526
+
527
+ /**
528
+ * Get performance and usage statistics.
529
+ */
530
+ getStats(): I18nStats {
531
+ const totalKeys = Object.values(this.translations).reduce((acc, map) => {
532
+ // Very rough estimate of keys, deep counting is expensive
533
+ return acc + Object.keys(map).length
534
+ }, 0)
535
+
536
+ const totalRequests = this.cacheHits + this.cacheMisses
537
+ const cacheHitRate = totalRequests > 0 ? (this.cacheHits / totalRequests) * 100 : 0
538
+
539
+ return {
540
+ localesCount: Object.keys(this.translations).length,
541
+ totalKeys,
542
+ cacheHitRate,
543
+ cacheSize: this.cache.size,
544
+ }
545
+ }
186
546
 
187
547
  /**
188
- * Get the I18n configuration.
548
+ * Ensure translations for a locale are loaded from the filesystem if lazy loading is enabled.
189
549
  *
190
- * @returns The configuration object.
550
+ * @param locale - Locale to load.
551
+ */
552
+ async ensureLocale(locale: string): Promise<void> {
553
+ // If already loaded or no lazy load config, skip
554
+ if (this.loadedLocales.has(locale) || !this.config.lazyLoad) {
555
+ return
556
+ }
557
+
558
+ // Attempt to load
559
+ const translations = await loadLocale(this.config.lazyLoad.baseDir, locale)
560
+ if (translations) {
561
+ this.addResource(locale, translations)
562
+ this.loadedLocales.add(locale)
563
+ }
564
+ }
565
+
566
+ // --- Manager Internal API ---
567
+
568
+ /**
569
+ * Get the current shared I18n configuration.
191
570
  */
192
571
  getConfig(): I18nConfig {
193
572
  return this.config
194
573
  }
195
574
 
196
575
  /**
197
- * Add a resource bundle for a specific locale.
576
+ * Add or merge a resource bundle for a specific locale.
577
+ *
578
+ * @param locale - The locale string (e.g., 'en', 'fr').
579
+ * @param translations - The translation map to merge into the existing bundle.
198
580
  *
199
- * @param locale - The locale string.
200
- * @param translations - The translations object.
581
+ * @example
582
+ * ```typescript
583
+ * manager.addResource('es', {
584
+ * greeting: 'Hola',
585
+ * auth: { login: 'Iniciar sesión' }
586
+ * });
587
+ * ```
201
588
  */
202
589
  addResource(locale: string, translations: TranslationMap) {
203
590
  this.translations[locale] = {
204
591
  ...(this.translations[locale] || {}),
205
592
  ...translations,
206
593
  }
594
+ this.invalidateCache(locale)
207
595
  }
208
596
 
209
597
  /**
210
- * Internal translation logic used by instances
598
+ * Invalidate the translation cache.
599
+ *
600
+ * @param locale - If provided, only invalidates keys for this locale.
211
601
  */
212
- translate(locale: string, key: string, replacements?: Record<string, string | number>): string {
602
+ private invalidateCache(locale?: string) {
603
+ if (locale) {
604
+ for (const key of this.cache.keys()) {
605
+ if (key.startsWith(`${locale}:`)) {
606
+ this.cache.delete(key)
607
+ }
608
+ }
609
+ } else {
610
+ this.cache.clear()
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Get the plural form for a locale and count.
616
+ */
617
+ private getPluralForm(locale: string, count: number): string {
618
+ if (!this.pluralRules.has(locale)) {
619
+ this.pluralRules.set(locale, new Intl.PluralRules(locale))
620
+ }
621
+ return this.pluralRules.get(locale)!.select(count)
622
+ }
623
+
624
+ /**
625
+ * Resolve a dot-notation key to its value in a specific locale.
626
+ */
627
+ private resolveKey(locale: string, key: string): string | TranslationMap | undefined {
213
628
  const keys = key.split('.')
214
- let value: any = this.translations[locale]
629
+ let value: string | TranslationMap | undefined = this.translations[locale]
215
630
 
216
- // 1. Try current locale
217
631
  for (const k of keys) {
218
632
  if (value && typeof value === 'object' && k in value) {
219
- value = value[k]
633
+ value = (value as TranslationMap)[k]
220
634
  } else {
221
- value = undefined
222
- break
635
+ return undefined
223
636
  }
224
637
  }
638
+ return value
639
+ }
225
640
 
226
- // 2. If not found, try fallback (defaultLocale)
227
- if (value === undefined && locale !== this.config.defaultLocale) {
228
- let fallbackValue: any = this.translations[this.config.defaultLocale]
229
- for (const k of keys) {
230
- if (fallbackValue && typeof fallbackValue === 'object' && k in fallbackValue) {
231
- fallbackValue = fallbackValue[k]
232
- } else {
233
- fallbackValue = undefined
234
- break
235
- }
641
+ /**
642
+ * Resolve a key using the configured fallback chain.
643
+ */
644
+ private resolveFallback(locale: string, key: string): string | TranslationMap | undefined {
645
+ const chain = this.config.fallback?.fallbackChain?.[locale] ?? [this.config.defaultLocale]
646
+
647
+ for (const fallbackLocale of chain) {
648
+ // Avoid infinite recursion if fallback points to itself
649
+ if (fallbackLocale === locale) continue
650
+
651
+ const value = this.resolveKey(fallbackLocale, key)
652
+ if (value !== undefined) {
653
+ return value
236
654
  }
237
- value = fallbackValue
238
655
  }
239
656
 
240
- if (value === undefined || typeof value !== 'string') {
241
- return key // Return key if not found
657
+ return undefined
658
+ }
659
+
660
+ /**
661
+ * Handle cases where a translation key is missing after fallbacks.
662
+ */
663
+ private handleMissingKey(key: string, locale: string): string {
664
+ const handler = this.config.fallback?.onMissingKey ?? 'key'
665
+
666
+ if (this.config.fallback?.warnOnMissing) {
667
+ console.warn(`[i18n] Missing translation: ${key} (${locale})`)
668
+ }
669
+
670
+ if (typeof handler === 'function') {
671
+ return handler(key, locale)
672
+ }
673
+
674
+ switch (handler) {
675
+ case 'empty':
676
+ return ''
677
+ case 'throw':
678
+ throw new Error(`Missing translation: ${key}`)
679
+ default:
680
+ return key
681
+ }
682
+ }
683
+
684
+ /**
685
+ * The core translation logic. Handles caching, key resolution, fallbacks,
686
+ * pluralization, and parameter replacement.
687
+ *
688
+ * @param locale - Locale to translate into.
689
+ * @param key - Translation key.
690
+ * @param replacements - Placeholder replacements.
691
+ * @returns Translated string.
692
+ */
693
+ translate(locale: string, key: string, replacements?: Record<string, string | number>): string {
694
+ const cacheKey = `${locale}:${key}`
695
+ let value: string | TranslationMap | undefined
696
+
697
+ if (this.cache.has(cacheKey)) {
698
+ this.cacheHits++
699
+ value = this.cache.get(cacheKey)
700
+ } else {
701
+ this.cacheMisses++
702
+
703
+ // 1. Try current locale
704
+ value = this.resolveKey(locale, key)
705
+
706
+ // 2. Fallback
707
+ if (value === undefined) {
708
+ value = this.resolveFallback(locale, key)
709
+ }
710
+
711
+ if (value !== undefined && typeof value === 'string') {
712
+ this.cache.set(cacheKey, value)
713
+ }
242
714
  }
243
715
 
244
- // 3. Replacements
245
- if (replacements) {
246
- for (const [search, replace] of Object.entries(replacements)) {
247
- value = value.replace(new RegExp(`:${search}`, 'g'), String(replace))
716
+ // Pluralization
717
+ if (value && typeof value === 'object' && replacements?.count !== undefined) {
718
+ const count = Number(replacements.count)
719
+ const pluralMap = value as TranslationMap
720
+ const form = this.getPluralForm(locale, count)
721
+
722
+ if (count === 0 && 'zero' in pluralMap) {
723
+ value = pluralMap.zero
724
+ } else if (form in pluralMap) {
725
+ value = pluralMap[form]
726
+ } else if ('other' in pluralMap) {
727
+ value = pluralMap.other
248
728
  }
249
729
  }
250
730
 
731
+ if (value === undefined) {
732
+ return this.handleMissingKey(key, locale)
733
+ }
734
+
735
+ if (typeof value !== 'string') {
736
+ return key
737
+ }
738
+
739
+ if (replacements && Object.keys(replacements).length > 0) {
740
+ value = value.replace(REPLACEMENT_REGEX, (match, key) => {
741
+ return (replacements as Record<string, unknown>)[key] !== undefined
742
+ ? String((replacements as Record<string, unknown>)[key])
743
+ : match
744
+ })
745
+ }
746
+
251
747
  return value
252
748
  }
253
749
  }
254
750
 
751
+ /** Regex for finding placeholders in translation strings. */
752
+ const REPLACEMENT_REGEX = /:([a-zA-Z0-9_]+)/g
753
+
754
+ /**
755
+ * Detector that extracts the locale from a route parameter named 'locale'.
756
+ *
757
+ * @example
758
+ * ```typescript
759
+ * // router.get('/:locale/home', ...)
760
+ * ```
761
+ * @public
762
+ */
763
+ export const RouteParamDetector: LocaleDetector = {
764
+ name: 'routeParam',
765
+ detect: (c) => c.req.param('locale'),
766
+ }
767
+
768
+ /**
769
+ * Detector that extracts the locale from a query parameter named 'lang'.
770
+ *
771
+ * @example
772
+ * ```typescript
773
+ * // GET /home?lang=zh-TW
774
+ * ```
775
+ * @public
776
+ */
777
+ export const QueryDetector: LocaleDetector = {
778
+ name: 'query',
779
+ detect: (c) => c.req.query('lang'),
780
+ }
781
+
782
+ /**
783
+ * Detector that extracts the locale from the 'Accept-Language' HTTP header.
784
+ *
785
+ * Picks the first (preferred) language from the comma-separated list.
786
+ *
787
+ * @public
788
+ */
789
+ export const HeaderDetector: LocaleDetector = {
790
+ name: 'header',
791
+ detect: (c) => {
792
+ const acceptLang = c.req.header('Accept-Language')
793
+ if (acceptLang) {
794
+ return acceptLang.split(',')[0]?.trim()
795
+ }
796
+ return undefined
797
+ },
798
+ }
799
+
255
800
  /**
256
- * Locale Middleware
801
+ * Default list of detectors used by the middleware.
802
+ * Order: Route Parameter > Query Parameter > Accept-Language Header.
257
803
  *
258
- * Detects locale from:
259
- * 1. Route Parameter (e.g. /:locale/foo) - Recommended for SEO
260
- * 2. Header (Accept-Language) - Recommended for APIs
804
+ * @public
261
805
  */
262
- export const localeMiddleware = (i18nManager: I18nService): MiddlewareHandler => {
806
+ export const DefaultDetectors = [RouteParamDetector, QueryDetector, HeaderDetector]
807
+
808
+ /**
809
+ * Middleware for Gravito/Photon that handles request-scoped internationalization.
810
+ *
811
+ * It uses a list of detectors to determine the locale, ensures it is loaded,
812
+ * and injects a request-scoped `I18nService` instance into the context under the key 'i18n'.
813
+ *
814
+ * @param i18nManager - The central I18nManager instance.
815
+ * @param detectors - Optional custom list of detectors.
816
+ * @returns GravitoMiddleware
817
+ *
818
+ * @public
819
+ */
820
+ export const localeMiddleware = (
821
+ i18nManager: I18nService,
822
+ detectors: LocaleDetector[] = DefaultDetectors
823
+ ): GravitoMiddleware => {
263
824
  return async (c, next) => {
264
- // Determine initial locale
265
- // Priority: 1. Route Param 2. Query ?lang= 3. Header 4. Default
266
- let locale = c.req.param('locale') || c.req.query('lang')
267
-
268
- if (!locale) {
269
- const acceptLang = c.req.header('Accept-Language')
270
- if (acceptLang) {
271
- // Simple extraction: 'en-US,en;q=0.9' -> 'en-US'
272
- locale = acceptLang.split(',')[0]?.trim()
825
+ let locale: string | undefined
826
+
827
+ for (const detector of detectors) {
828
+ const result = await detector.detect(c)
829
+ if (result) {
830
+ locale = result
831
+ break
273
832
  }
274
833
  }
275
834
 
835
+ if (locale) {
836
+ await i18nManager.ensureLocale(locale)
837
+ }
838
+
276
839
  // Clone a request-scoped instance
277
840
  const i18n = i18nManager.clone(locale)
278
841