@stacksjs/i18n 0.70.86 → 0.70.87
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/package.json +13 -4
- package/src/bootstrap.ts +0 -107
- package/src/formatter.ts +0 -399
- package/src/index.ts +0 -100
- package/src/loader.ts +0 -335
- package/src/locale-switch.ts +0 -81
- package/src/pluralization.ts +0 -290
- package/src/translator.ts +0 -502
- package/src/types.ts +0 -238
package/src/translator.ts
DELETED
|
@@ -1,502 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core Translator
|
|
3
|
-
*
|
|
4
|
-
* Handles translation lookups, interpolation, and locale management.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type {
|
|
8
|
-
I18nConfig,
|
|
9
|
-
I18nInstance,
|
|
10
|
-
InterpolationValues,
|
|
11
|
-
TranslationMessages,
|
|
12
|
-
Translations,
|
|
13
|
-
} from './types'
|
|
14
|
-
import { parsePluralForms, selectPluralForm, getPluralCategory } from './pluralization'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Default configuration
|
|
18
|
-
*/
|
|
19
|
-
const defaultConfig: I18nConfig = {
|
|
20
|
-
locale: 'en',
|
|
21
|
-
fallbackLocale: 'en',
|
|
22
|
-
availableLocales: ['en'],
|
|
23
|
-
messages: {},
|
|
24
|
-
warnMissing: true,
|
|
25
|
-
escapeValues: false,
|
|
26
|
-
keySeparator: '.',
|
|
27
|
-
pluralSeparator: '|',
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Global i18n state
|
|
32
|
-
*/
|
|
33
|
-
let currentLocale = 'en'
|
|
34
|
-
let fallbackLocale = 'en'
|
|
35
|
-
const translations: Translations = {}
|
|
36
|
-
let config: I18nConfig = { ...defaultConfig }
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* I18n class for creating isolated instances
|
|
40
|
-
*/
|
|
41
|
-
export class I18n implements I18nInstance {
|
|
42
|
-
private _locale: string
|
|
43
|
-
private _fallbackLocale: string
|
|
44
|
-
private _messages: Translations
|
|
45
|
-
private _config: I18nConfig
|
|
46
|
-
|
|
47
|
-
constructor(options: Partial<I18nConfig> = {}) {
|
|
48
|
-
this._config = { ...defaultConfig, ...options }
|
|
49
|
-
this._locale = this._config.locale
|
|
50
|
-
this._fallbackLocale = this._config.fallbackLocale
|
|
51
|
-
this._messages = this._config.messages || {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
get locale(): string {
|
|
55
|
-
return this._locale
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
set locale(value: string) {
|
|
59
|
-
this._locale = value
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
get fallbackLocale(): string {
|
|
63
|
-
return this._fallbackLocale
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
get availableLocales(): string[] {
|
|
67
|
-
return Object.keys(this._messages)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Translate a key
|
|
72
|
-
*/
|
|
73
|
-
t = (key: string, values?: InterpolationValues, locale?: string): string => {
|
|
74
|
-
return this.translate(key, values, locale)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Translate with pluralization
|
|
79
|
-
*/
|
|
80
|
-
tc = (key: string, count: number, values?: InterpolationValues, locale?: string): string => {
|
|
81
|
-
return this.translatePlural(key, count, values, locale)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Check if translation exists
|
|
86
|
-
*/
|
|
87
|
-
te = (key: string, locale?: string): boolean => {
|
|
88
|
-
return this.hasTranslation(key, locale)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Get translation message
|
|
93
|
-
*/
|
|
94
|
-
tm = (key: string, locale?: string): TranslationMessages | string | undefined => {
|
|
95
|
-
return this.getMessage(key, locale ?? this.locale)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Set the current locale
|
|
100
|
-
*/
|
|
101
|
-
setLocale = (locale: string): void => {
|
|
102
|
-
this._locale = locale
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Add translations for a locale
|
|
107
|
-
*/
|
|
108
|
-
addTranslations = (locale: string, messages: TranslationMessages): void => {
|
|
109
|
-
if (!this._messages[locale]) {
|
|
110
|
-
this._messages[locale] = {}
|
|
111
|
-
}
|
|
112
|
-
this._messages[locale] = deepMerge(this._messages[locale], messages)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Format a date
|
|
117
|
-
*/
|
|
118
|
-
d = (value: Date | number, format?: string): string => {
|
|
119
|
-
const date = typeof value === 'number' ? new Date(value) : value
|
|
120
|
-
const options = this._config.dateTimeFormats?.[this._locale]?.[format || 'short'] || {}
|
|
121
|
-
return new Intl.DateTimeFormat(this._locale, options).format(date)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Format a number
|
|
126
|
-
*/
|
|
127
|
-
n = (value: number, format?: string): string => {
|
|
128
|
-
const options = this._config.numberFormats?.[this._locale]?.[format || 'decimal'] || {}
|
|
129
|
-
return new Intl.NumberFormat(this._locale, options).format(value)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Core translation logic.
|
|
134
|
-
*
|
|
135
|
-
* Locale fallback chain (BCP-47 aware):
|
|
136
|
-
* 1. Exact requested locale (`en-GB`)
|
|
137
|
-
* 2. Language part only (`en`) — so a missing British English key
|
|
138
|
-
* falls back to American English before going to the
|
|
139
|
-
* framework's default locale, which matches user intuition
|
|
140
|
-
* 3. Configured fallback locale
|
|
141
|
-
*
|
|
142
|
-
* Without step 2, an `en-GB` request that lacks a key would skip
|
|
143
|
-
* straight to (e.g.) `en-US` defaults — which on a Spanish app
|
|
144
|
-
* would mean the user gets Spanish for one missing string but English
|
|
145
|
-
* for the rest. The intermediate language fallback closes that gap.
|
|
146
|
-
*/
|
|
147
|
-
private translate(key: string, values?: InterpolationValues, locale?: string): string {
|
|
148
|
-
const targetLocale = locale || this._locale
|
|
149
|
-
let message = this.getMessage(key, targetLocale)
|
|
150
|
-
|
|
151
|
-
// Try language-part fallback (e.g. en-GB → en)
|
|
152
|
-
if (message === undefined) {
|
|
153
|
-
const dash = targetLocale.indexOf('-')
|
|
154
|
-
if (dash > 0) {
|
|
155
|
-
const langOnly = targetLocale.slice(0, dash)
|
|
156
|
-
if (langOnly !== this._fallbackLocale) {
|
|
157
|
-
message = this.getMessage(key, langOnly)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Try fallback locale
|
|
163
|
-
if (message === undefined && targetLocale !== this._fallbackLocale) {
|
|
164
|
-
message = this.getMessage(key, this._fallbackLocale)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Handle missing translation
|
|
168
|
-
if (message === undefined) {
|
|
169
|
-
if (this._config.warnMissing) {
|
|
170
|
-
console.warn(`[i18n] Missing translation: "${key}" for locale "${targetLocale}"`)
|
|
171
|
-
}
|
|
172
|
-
if (this._config.missingHandler) {
|
|
173
|
-
const result = this._config.missingHandler(targetLocale, key)
|
|
174
|
-
if (result !== undefined) return result
|
|
175
|
-
}
|
|
176
|
-
return key
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Handle object messages (return key path)
|
|
180
|
-
if (typeof message !== 'string') {
|
|
181
|
-
return key
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Interpolate values
|
|
185
|
-
return this.interpolate(message, values)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Translate with pluralization
|
|
190
|
-
*/
|
|
191
|
-
private translatePlural(
|
|
192
|
-
key: string,
|
|
193
|
-
count: number,
|
|
194
|
-
values?: InterpolationValues,
|
|
195
|
-
locale?: string,
|
|
196
|
-
): string {
|
|
197
|
-
const targetLocale = locale || this._locale
|
|
198
|
-
let message = this.getMessage(key, targetLocale)
|
|
199
|
-
|
|
200
|
-
// Try fallback locale
|
|
201
|
-
if (message === undefined && targetLocale !== this._fallbackLocale) {
|
|
202
|
-
message = this.getMessage(key, this._fallbackLocale)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (message === undefined || typeof message !== 'string') {
|
|
206
|
-
if (this._config.warnMissing) {
|
|
207
|
-
console.warn(`[i18n] Missing plural translation: "${key}" for locale "${targetLocale}"`)
|
|
208
|
-
}
|
|
209
|
-
return key
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Parse plural forms
|
|
213
|
-
const forms = parsePluralForms(message, this._config.pluralSeparator)
|
|
214
|
-
const selectedForm = selectPluralForm(forms, count, targetLocale)
|
|
215
|
-
|
|
216
|
-
// Interpolate with count and other values
|
|
217
|
-
const allValues = { count, n: count, ...values }
|
|
218
|
-
return this.interpolate(selectedForm, allValues)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Check if a translation exists
|
|
223
|
-
*/
|
|
224
|
-
private hasTranslation(key: string, locale?: string): boolean {
|
|
225
|
-
const targetLocale = locale || this._locale
|
|
226
|
-
return this.getMessage(key, targetLocale) !== undefined
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Get a message by key (supports nested keys)
|
|
231
|
-
*/
|
|
232
|
-
private getMessage(key: string, locale: string): TranslationMessages | string | undefined {
|
|
233
|
-
const messages = this._messages[locale]
|
|
234
|
-
if (!messages) return undefined
|
|
235
|
-
|
|
236
|
-
const parts = key.split(this._config.keySeparator || '.')
|
|
237
|
-
let current: TranslationMessages | string | undefined = messages
|
|
238
|
-
|
|
239
|
-
for (const part of parts) {
|
|
240
|
-
if (current === undefined || typeof current === 'string') {
|
|
241
|
-
return undefined
|
|
242
|
-
}
|
|
243
|
-
current = current[part]
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return current
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Interpolate values into a message
|
|
251
|
-
*/
|
|
252
|
-
private interpolate(message: string, values?: InterpolationValues): string {
|
|
253
|
-
if (!values) return message
|
|
254
|
-
|
|
255
|
-
return message.replace(/\{(\w+)\}/g, (match, key) => {
|
|
256
|
-
const value = values[key]
|
|
257
|
-
if (value === undefined || value === null) return match
|
|
258
|
-
|
|
259
|
-
const str = String(value)
|
|
260
|
-
return this._config.escapeValues ? escapeHtml(str) : str
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// =============================================================================
|
|
266
|
-
// Global API
|
|
267
|
-
// =============================================================================
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Set the current locale
|
|
271
|
-
*/
|
|
272
|
-
export function setLocale(locale: string): void {
|
|
273
|
-
currentLocale = locale
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Get the current locale
|
|
278
|
-
*/
|
|
279
|
-
export function getLocale(): string {
|
|
280
|
-
return currentLocale
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Set the fallback locale
|
|
285
|
-
*/
|
|
286
|
-
export function setFallbackLocale(locale: string): void {
|
|
287
|
-
fallbackLocale = locale
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Get available locales
|
|
292
|
-
*/
|
|
293
|
-
export function getAvailableLocales(): string[] {
|
|
294
|
-
return Object.keys(translations)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Add translations for a locale
|
|
299
|
-
*/
|
|
300
|
-
export function addTranslations(locale: string, messages: TranslationMessages): void {
|
|
301
|
-
if (!translations[locale]) {
|
|
302
|
-
translations[locale] = {}
|
|
303
|
-
}
|
|
304
|
-
translations[locale] = deepMerge(translations[locale], messages)
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Load translations (alias for addTranslations for multiple locales)
|
|
309
|
-
*/
|
|
310
|
-
export function loadTranslations(messages: Translations): void {
|
|
311
|
-
for (const [locale, localeMessages] of Object.entries(messages)) {
|
|
312
|
-
addTranslations(locale, localeMessages)
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Check if a translation exists
|
|
318
|
-
*/
|
|
319
|
-
export function hasTranslation(key: string, locale?: string): boolean {
|
|
320
|
-
const targetLocale = locale || currentLocale
|
|
321
|
-
return getMessage(key, targetLocale) !== undefined
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Get a message by key
|
|
326
|
-
*/
|
|
327
|
-
function getMessage(key: string, locale: string): TranslationMessages | string | undefined {
|
|
328
|
-
const messages = translations[locale]
|
|
329
|
-
if (!messages) return undefined
|
|
330
|
-
|
|
331
|
-
const parts = key.split(config.keySeparator || '.')
|
|
332
|
-
let current: TranslationMessages | string | undefined = messages
|
|
333
|
-
|
|
334
|
-
for (const part of parts) {
|
|
335
|
-
if (current === undefined || typeof current === 'string') {
|
|
336
|
-
return undefined
|
|
337
|
-
}
|
|
338
|
-
current = current[part]
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return current
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Interpolate values into a message
|
|
346
|
-
*/
|
|
347
|
-
function interpolate(message: string, values?: InterpolationValues): string {
|
|
348
|
-
if (!values) return message
|
|
349
|
-
|
|
350
|
-
return message.replace(/\{(\w+)\}/g, (match, key) => {
|
|
351
|
-
const value = values[key]
|
|
352
|
-
if (value === undefined || value === null) return match
|
|
353
|
-
return String(value)
|
|
354
|
-
})
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Translate a key
|
|
359
|
-
*/
|
|
360
|
-
export function t(key: string, values?: InterpolationValues, locale?: string): string {
|
|
361
|
-
const targetLocale = locale || currentLocale
|
|
362
|
-
let message = getMessage(key, targetLocale)
|
|
363
|
-
|
|
364
|
-
// Try fallback locale
|
|
365
|
-
if (message === undefined && targetLocale !== fallbackLocale) {
|
|
366
|
-
message = getMessage(key, fallbackLocale)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Handle missing translation
|
|
370
|
-
if (message === undefined) {
|
|
371
|
-
if (config.warnMissing) {
|
|
372
|
-
console.warn(`[i18n] Missing translation: "${key}" for locale "${targetLocale}"`)
|
|
373
|
-
}
|
|
374
|
-
return key
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (typeof message !== 'string') {
|
|
378
|
-
return key
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return interpolate(message, values)
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Alias for t()
|
|
386
|
-
*/
|
|
387
|
-
export const trans = t
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Translate with pluralization
|
|
391
|
-
*/
|
|
392
|
-
export function tc(
|
|
393
|
-
key: string,
|
|
394
|
-
count: number,
|
|
395
|
-
values?: InterpolationValues,
|
|
396
|
-
locale?: string,
|
|
397
|
-
): string {
|
|
398
|
-
const targetLocale = locale || currentLocale
|
|
399
|
-
let message = getMessage(key, targetLocale)
|
|
400
|
-
|
|
401
|
-
if (message === undefined && targetLocale !== fallbackLocale) {
|
|
402
|
-
message = getMessage(key, fallbackLocale)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (message === undefined || typeof message !== 'string') {
|
|
406
|
-
return key
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const forms = parsePluralForms(message, config.pluralSeparator)
|
|
410
|
-
const selectedForm = selectPluralForm(forms, count, targetLocale)
|
|
411
|
-
const allValues = { count, n: count, ...values }
|
|
412
|
-
|
|
413
|
-
return interpolate(selectedForm, allValues)
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Check if translation exists
|
|
418
|
-
*/
|
|
419
|
-
export function te(key: string, locale?: string): boolean {
|
|
420
|
-
return hasTranslation(key, locale)
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Get translation message object
|
|
425
|
-
*/
|
|
426
|
-
export function tm(key: string, locale?: string): TranslationMessages | string | undefined {
|
|
427
|
-
return getMessage(key, locale || currentLocale)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Create a new i18n instance
|
|
432
|
-
*/
|
|
433
|
-
export function createI18n(options: Partial<I18nConfig> = {}): I18n {
|
|
434
|
-
return new I18n(options)
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Use i18n composable (for Vue-like usage)
|
|
439
|
-
*/
|
|
440
|
-
export function useI18n(options: Partial<I18nConfig> = {}): I18nInstance {
|
|
441
|
-
return createI18n(options)
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Configure the global i18n instance
|
|
446
|
-
*/
|
|
447
|
-
export function configure(options: Partial<I18nConfig>): void {
|
|
448
|
-
config = { ...config, ...options }
|
|
449
|
-
|
|
450
|
-
if (options.locale) currentLocale = options.locale
|
|
451
|
-
if (options.fallbackLocale) fallbackLocale = options.fallbackLocale
|
|
452
|
-
if (options.messages) loadTranslations(options.messages)
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// =============================================================================
|
|
456
|
-
// Helpers
|
|
457
|
-
// =============================================================================
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Deep merge two objects
|
|
461
|
-
*/
|
|
462
|
-
function deepMerge(target: TranslationMessages, source: TranslationMessages): TranslationMessages {
|
|
463
|
-
const result = { ...target }
|
|
464
|
-
|
|
465
|
-
for (const key of Object.keys(source)) {
|
|
466
|
-
const sourceValue = source[key]
|
|
467
|
-
const targetValue = result[key]
|
|
468
|
-
|
|
469
|
-
if (
|
|
470
|
-
typeof sourceValue === 'object'
|
|
471
|
-
&& sourceValue !== null
|
|
472
|
-
&& typeof targetValue === 'object'
|
|
473
|
-
&& targetValue !== null
|
|
474
|
-
) {
|
|
475
|
-
result[key] = deepMerge(
|
|
476
|
-
targetValue as TranslationMessages,
|
|
477
|
-
sourceValue as TranslationMessages,
|
|
478
|
-
)
|
|
479
|
-
}
|
|
480
|
-
else {
|
|
481
|
-
if (sourceValue !== undefined)
|
|
482
|
-
result[key] = sourceValue
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return result
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Escape HTML characters
|
|
491
|
-
*/
|
|
492
|
-
function escapeHtml(str: string): string {
|
|
493
|
-
const htmlEscapes: Record<string, string> = {
|
|
494
|
-
'&': '&',
|
|
495
|
-
'<': '<',
|
|
496
|
-
'>': '>',
|
|
497
|
-
'"': '"',
|
|
498
|
-
'\'': ''',
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
return str.replace(/[&<>"']/g, char => htmlEscapes[char] || char)
|
|
502
|
-
}
|