@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.
- package/CHANGELOG.md +7 -0
- package/README.md +105 -45
- package/README.zh-TW.md +102 -22
- package/build.ts +35 -0
- package/docs/plans/01-performance.md +187 -0
- package/docs/plans/02-architecture.md +309 -0
- package/docs/plans/03-api-enhancement.md +345 -0
- package/docs/plans/04-testing.md +431 -0
- package/docs/plans/README.md +47 -0
- package/ion/src/index.js +1179 -1138
- package/package.json +18 -5
- package/src/I18nService.ts +657 -94
- package/src/index.ts +51 -6
- package/src/loader.ts +45 -12
- package/tests/helpers/factory.ts +41 -0
- package/tests/performance/translate.bench.ts +27 -0
- package/tests/unit/api.test.ts +37 -0
- package/tests/unit/detector.test.ts +70 -0
- package/tests/unit/edge.test.ts +100 -0
- package/tests/unit/fallback.test.ts +66 -0
- package/tests/unit/lazy.test.ts +35 -0
- package/tests/{loader.test.ts → unit/loader.test.ts} +1 -1
- package/tests/{manager.test.ts → unit/manager.test.ts} +1 -2
- package/tests/unit/plural.test.ts +58 -0
- package/tests/{service.test.ts → unit/service.test.ts} +7 -2
- package/tsconfig.json +12 -24
- package/dist/core/src/Application.d.ts +0 -185
- package/dist/core/src/ConfigManager.d.ts +0 -21
- package/dist/core/src/Container.d.ts +0 -38
- package/dist/core/src/Event.d.ts +0 -5
- package/dist/core/src/EventManager.d.ts +0 -123
- package/dist/core/src/GlobalErrorHandlers.d.ts +0 -31
- package/dist/core/src/GravitoServer.d.ts +0 -20
- package/dist/core/src/HookManager.d.ts +0 -70
- package/dist/core/src/Listener.d.ts +0 -4
- package/dist/core/src/Logger.d.ts +0 -20
- package/dist/core/src/PlanetCore.d.ts +0 -207
- package/dist/core/src/Route.d.ts +0 -25
- package/dist/core/src/Router.d.ts +0 -232
- package/dist/core/src/ServiceProvider.d.ts +0 -150
- package/dist/core/src/adapters/PhotonAdapter.d.ts +0 -142
- package/dist/core/src/adapters/bun/BunContext.d.ts +0 -36
- package/dist/core/src/adapters/bun/BunNativeAdapter.d.ts +0 -21
- package/dist/core/src/adapters/bun/BunRequest.d.ts +0 -27
- package/dist/core/src/adapters/bun/RadixNode.d.ts +0 -15
- package/dist/core/src/adapters/bun/RadixRouter.d.ts +0 -31
- package/dist/core/src/adapters/bun/types.d.ts +0 -20
- package/dist/core/src/adapters/types.d.ts +0 -186
- package/dist/core/src/engine/AOTRouter.d.ts +0 -117
- package/dist/core/src/engine/FastContext.d.ts +0 -34
- package/dist/core/src/engine/Gravito.d.ts +0 -191
- package/dist/core/src/engine/MinimalContext.d.ts +0 -36
- package/dist/core/src/engine/analyzer.d.ts +0 -21
- package/dist/core/src/engine/index.d.ts +0 -26
- package/dist/core/src/engine/path.d.ts +0 -26
- package/dist/core/src/engine/pool.d.ts +0 -83
- package/dist/core/src/engine/types.d.ts +0 -107
- package/dist/core/src/exceptions/AuthenticationException.d.ts +0 -4
- package/dist/core/src/exceptions/AuthorizationException.d.ts +0 -4
- package/dist/core/src/exceptions/GravitoException.d.ts +0 -15
- package/dist/core/src/exceptions/HttpException.d.ts +0 -5
- package/dist/core/src/exceptions/ModelNotFoundException.d.ts +0 -6
- package/dist/core/src/exceptions/ValidationException.d.ts +0 -14
- package/dist/core/src/exceptions/index.d.ts +0 -6
- package/dist/core/src/helpers/Arr.d.ts +0 -14
- package/dist/core/src/helpers/Str.d.ts +0 -18
- package/dist/core/src/helpers/data.d.ts +0 -5
- package/dist/core/src/helpers/errors.d.ts +0 -12
- package/dist/core/src/helpers/response.d.ts +0 -17
- package/dist/core/src/helpers.d.ts +0 -38
- package/dist/core/src/http/CookieJar.d.ts +0 -37
- package/dist/core/src/http/middleware/BodySizeLimit.d.ts +0 -6
- package/dist/core/src/http/middleware/Cors.d.ts +0 -12
- package/dist/core/src/http/middleware/Csrf.d.ts +0 -11
- package/dist/core/src/http/middleware/HeaderTokenGate.d.ts +0 -11
- package/dist/core/src/http/middleware/SecurityHeaders.d.ts +0 -17
- package/dist/core/src/http/middleware/ThrottleRequests.d.ts +0 -12
- package/dist/core/src/http/types.d.ts +0 -312
- package/dist/core/src/index.d.ts +0 -60
- package/dist/core/src/runtime.d.ts +0 -63
- package/dist/core/src/security/Encrypter.d.ts +0 -24
- package/dist/core/src/security/Hasher.d.ts +0 -29
- package/dist/core/src/testing/HttpTester.d.ts +0 -38
- package/dist/core/src/testing/TestResponse.d.ts +0 -78
- package/dist/core/src/testing/index.d.ts +0 -2
- package/dist/core/src/types/events.d.ts +0 -94
- package/dist/cosmos/src/I18nService.d.ts +0 -144
- package/dist/cosmos/src/index.d.ts +0 -21
- package/dist/cosmos/src/loader.d.ts +0 -11
- package/dist/index.js +0 -168
- package/dist/photon/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -173
package/src/I18nService.ts
CHANGED
|
@@ -1,59 +1,281 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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
|
-
*
|
|
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
|
|
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 (
|
|
77
|
-
* @param replacements -
|
|
78
|
-
* @returns The translated string
|
|
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(
|
|
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
|
-
*
|
|
323
|
+
* Translate multiple keys at once for the current locale.
|
|
86
324
|
*
|
|
87
|
-
* @param
|
|
88
|
-
* @returns
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 -
|
|
158
|
-
* @param replacements -
|
|
159
|
-
* @returns The translated string.
|
|
474
|
+
* @param key - Translation key.
|
|
475
|
+
* @param replacements - Replacement parameters.
|
|
160
476
|
*/
|
|
161
|
-
t(
|
|
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
|
-
*
|
|
505
|
+
* Create a request-scoped I18nInstance from this manager.
|
|
177
506
|
*
|
|
178
|
-
* @param locale - Optional locale
|
|
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
|
-
|
|
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
|
-
*
|
|
548
|
+
* Ensure translations for a locale are loaded from the filesystem if lazy loading is enabled.
|
|
189
549
|
*
|
|
190
|
-
* @
|
|
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
|
-
* @
|
|
200
|
-
*
|
|
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
|
-
*
|
|
598
|
+
* Invalidate the translation cache.
|
|
599
|
+
*
|
|
600
|
+
* @param locale - If provided, only invalidates keys for this locale.
|
|
211
601
|
*/
|
|
212
|
-
|
|
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:
|
|
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
|
-
|
|
222
|
-
break
|
|
635
|
+
return undefined
|
|
223
636
|
}
|
|
224
637
|
}
|
|
638
|
+
return value
|
|
639
|
+
}
|
|
225
640
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
//
|
|
245
|
-
if (replacements) {
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
*
|
|
801
|
+
* Default list of detectors used by the middleware.
|
|
802
|
+
* Order: Route Parameter > Query Parameter > Accept-Language Header.
|
|
257
803
|
*
|
|
258
|
-
*
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|