@stacksjs/i18n 0.70.85 → 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/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
- '&': '&amp;',
495
- '<': '&lt;',
496
- '>': '&gt;',
497
- '"': '&quot;',
498
- '\'': '&#39;',
499
- }
500
-
501
- return str.replace(/[&<>"']/g, char => htmlEscapes[char] || char)
502
- }