@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.
- package/MIGRATION.md +331 -0
- package/README.md +105 -45
- package/README.zh-TW.md +102 -22
- 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 +22 -6
- package/scripts/check-coverage.ts +64 -0
- package/src/HMRWatcher.ts +305 -0
- package/src/I18nService.ts +715 -91
- package/src/index.edge.ts +35 -0
- package/src/index.node.ts +20 -0
- package/src/index.ts +39 -6
- package/src/loader.ts +64 -14
- package/src/loaders/ChainedLoader.ts +117 -0
- package/src/loaders/CloudflareKVLoader.ts +194 -0
- package/src/loaders/EdgeKVLoader.ts +248 -0
- package/src/loaders/FileSystemLoader.ts +125 -0
- package/src/loaders/MemoryLoader.ts +161 -0
- package/src/loaders/RemoteLoader.ts +235 -0
- package/src/loaders/TranslationLoader.ts +98 -0
- package/src/loaders/VercelKVLoader.ts +192 -0
- package/src/runtime/detector.ts +97 -0
- package/src/runtime/path-utils.ts +169 -0
- package/tests/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 +65 -0
- package/tests/unit/edge-kv-loader.test.ts +202 -0
- package/tests/unit/edge.test.ts +100 -0
- package/tests/unit/fallback.test.ts +66 -0
- package/tests/unit/hmr.test.ts +255 -0
- package/tests/unit/lazy.test.ts +35 -0
- package/tests/unit/loader.test.ts +72 -0
- package/tests/unit/loaders.test.ts +332 -0
- package/tests/{manager.test.ts → unit/manager.test.ts} +1 -1
- package/tests/unit/memory-loader.test.ts +130 -0
- package/tests/unit/path-utils.test.ts +135 -0
- package/tests/unit/plural.test.ts +58 -0
- package/tests/unit/runtime-detector.test.ts +86 -0
- package/tests/{service.test.ts → unit/service.test.ts} +2 -2
- package/tsconfig.json +12 -24
- package/.turbo/turbo-build.log +0 -20
- package/.turbo/turbo-test$colon$ci.log +0 -35
- package/.turbo/turbo-test$colon$coverage.log +0 -35
- package/.turbo/turbo-test.log +0 -27
- package/.turbo/turbo-typecheck.log +0 -2
- package/dist/index.cjs +0 -309
- package/dist/index.d.cts +0 -274
- package/dist/index.d.ts +0 -274
- package/dist/index.js +0 -277
- package/tests/loader.test.ts +0 -44
package/src/I18nService.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
328
|
+
* Set the active locale for this instance.
|
|
115
329
|
*
|
|
116
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 (
|
|
137
|
-
* @param replacements -
|
|
138
|
-
* @returns The translated string
|
|
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(
|
|
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
|
-
*
|
|
377
|
+
* Translate multiple keys at once for the current locale.
|
|
146
378
|
*
|
|
147
|
-
* @param
|
|
148
|
-
* @returns
|
|
379
|
+
* @param keysOrEntries - Array of keys or [key, replacements] tuples.
|
|
380
|
+
* @returns Map of key to translated string.
|
|
149
381
|
*/
|
|
150
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
186
|
-
private
|
|
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 -
|
|
232
|
-
* @param replacements -
|
|
233
|
-
* @returns The translated string.
|
|
545
|
+
* @param key - Translation key.
|
|
546
|
+
* @param replacements - Replacement parameters.
|
|
234
547
|
*/
|
|
235
|
-
t(
|
|
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
|
-
*
|
|
576
|
+
* Create a request-scoped I18nInstance from this manager.
|
|
251
577
|
*
|
|
252
|
-
* @param locale - Optional locale
|
|
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
|
-
|
|
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
|
-
*
|
|
619
|
+
* Ensure translations for a locale are loaded from the filesystem if lazy loading is enabled.
|
|
263
620
|
*
|
|
264
|
-
* @
|
|
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
|
|
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
|
-
*
|
|
700
|
+
* Invalidate the translation cache.
|
|
701
|
+
*
|
|
702
|
+
* @param locale - If provided, only invalidates keys for this locale.
|
|
285
703
|
*/
|
|
286
|
-
|
|
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
|
-
|
|
296
|
-
break
|
|
773
|
+
return undefined
|
|
297
774
|
}
|
|
298
775
|
}
|
|
776
|
+
return value
|
|
777
|
+
}
|
|
299
778
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
*
|
|
895
|
+
* Detector that extracts the locale from a route parameter named 'locale'.
|
|
336
896
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
897
|
+
* @example
|
|
898
|
+
* ```typescript
|
|
899
|
+
* // router.get('/:locale/home', ...)
|
|
900
|
+
* ```
|
|
901
|
+
* @public
|
|
340
902
|
*/
|
|
341
|
-
export const
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|