@juit/vue-i18n 0.4.0 → 1.0.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.
@@ -0,0 +1,539 @@
1
+ import { computed, reactive, shallowRef, warn, watch } from 'vue'
2
+
3
+ import { ISO_COUNTRIES } from './iso-3166'
4
+ import { ISO_CURRENCIES } from './iso-4217'
5
+ import { ISO_LANGUAGES } from './iso-639'
6
+
7
+ import type {
8
+ DateTimeFormatAlias,
9
+ DateTimeFormats,
10
+ I18nOptions,
11
+ ISOCountry,
12
+ NumberFormatAlias,
13
+ NumberFormats,
14
+ Translation,
15
+ TranslationKey,
16
+ TranslationMessage,
17
+ } from './index'
18
+ import type { ISOLanguage } from './iso-639'
19
+
20
+ /* ===== TRANSLATOR INTERFACE =============================================== */
21
+
22
+ /**
23
+ * Parameters for the formatting of a translation.
24
+ *
25
+ * This type is used to pass parameters to the `t` and `tc` methods of the
26
+ * translator, allowing for the interpolation of values into the translated
27
+ * message.
28
+ *
29
+ * Numeric values use the current locale and the `default` number format
30
+ * before being interpolated into the message.
31
+ */
32
+ export interface TranslationParams {
33
+ [ key: string ]: string | number
34
+ }
35
+
36
+ /**
37
+ * Accepted inputs for date and time formatting.
38
+ *
39
+ * A non-empty string or a number is passed to the `Date` constructor before
40
+ * formatting. Numbers represent milliseconds since the Unix epoch.
41
+ *
42
+ * A `null`, `undefined`, or empty string input produces an empty string.
43
+ */
44
+ export type DateInput = Date | string | number | null | undefined
45
+
46
+ /**
47
+ * The translator interface for the application.
48
+ *
49
+ * This interface provides methods to translate messages, format numbers, and
50
+ * format dates and times.
51
+ *
52
+ * Configured instances can be accessed using the `useTranslator()` composition
53
+ * function, which will provide an instance of the translator.
54
+ */
55
+ export interface Translator {
56
+ /** The current ISO-639-1 language code used by this translator. */
57
+ language: ISOLanguage
58
+ /** The region (if any) used by this translator to localize translations. */
59
+ region: ISOCountry | undefined
60
+ /** The current locale. Assignments retain only the language and region. */
61
+ locale: Readonly<Intl.Locale>
62
+
63
+ /**
64
+ * Return the (possibly parameterized) translation for the specified message
65
+ * in the current language.
66
+ *
67
+ * Delegates to `tc(...)` with `n=1`. A supplied `params.n` overrides this
68
+ * count, including for plural selection.
69
+ */
70
+ t(key: TranslationKey | Translation, params?: TranslationParams): string
71
+
72
+ /**
73
+ * Return the (possibly parameterized) translation for the specified message
74
+ * in the current language, with pluralization.
75
+ *
76
+ * String messages can contain variants separated by unescaped pipes:
77
+ *
78
+ * * `" one apple | {n} apples "` when _two_ translations are separated by a
79
+ * pipe, the first is used for singular, and the second for zero or plural.
80
+ * * `" no apples | one apple | {n} apples "` when _three_ translations are
81
+ * separated by a pipe, the first will be used for zero, the second for
82
+ * singular, and the third for plural.
83
+ *
84
+ * Messages can also be readonly tuples: `[message]`, `[singular, plural]`,
85
+ * or `[zero, singular, plural]`. Pipes inside tuple elements are literal;
86
+ * placeholders and their escapes are still parsed.
87
+ *
88
+ * The `{n}` parameter defaults to the supplied count. `params.n` overrides
89
+ * both its displayed value and plural selection. Zero and one select their
90
+ * respective variants; every other count selects the plural variant.
91
+ */
92
+ tc(key: TranslationKey | Translation, n: number, params?: TranslationParams): string
93
+
94
+ /**
95
+ * Format a number according to the current locale.
96
+ *
97
+ * When `format` is provided, it will be used to configure the number format.
98
+ * Accepts a built-in or custom alias, or an `Intl.NumberFormatOptions`
99
+ * object. Omitting it selects the `default` alias. A null or undefined
100
+ * value produces an empty string.
101
+ */
102
+ n(value?: number | bigint | null | undefined, format?: NumberFormatAlias | Intl.NumberFormatOptions): string
103
+
104
+ /**
105
+ * Format a date and time according to the current locale.
106
+ *
107
+ * When `format` is provided, it will be used to configure the date and time
108
+ * format. Accepts a built-in or custom alias, or an
109
+ * `Intl.DateTimeFormatOptions` object. Omitting it selects the `default`
110
+ * alias. The explicit `timeZone` takes precedence over `format.timeZone`,
111
+ * then `defaultTimeZone`, then the runtime's time zone.
112
+ */
113
+ d(date?: DateInput, format?: DateTimeFormatAlias | Intl.DateTimeFormatOptions, timeZone?: string): string
114
+
115
+ /** Extra internationalization utilities */
116
+ utils: {
117
+ /**
118
+ * Return the name of the language for the given ISO-639-1 code localized
119
+ * using the current locale.
120
+ */
121
+ language(code: ISOLanguage): string
122
+
123
+ /**
124
+ * Return the name of the country (or region) for the given ISO-3166-1 code
125
+ * (or CLDR region code) using the current locale.
126
+ */
127
+ country(code: ISOCountry | 'EU' | 'UN'): string
128
+
129
+ /**
130
+ * Return the flag emoji for the given ISO-3166-1 code (or CLDR region
131
+ * code).
132
+ */
133
+ flag(code: ISOCountry | 'EU' | 'UN'): string
134
+
135
+ /**
136
+ * Merge translations and clear cached templates. Empty strings and
137
+ * undefined values are ignored; use a tuple containing an empty string
138
+ * for an intentionally blank message. Tuples are copied before storing.
139
+ * Updates affect subsequent calls but do not trigger reactive updates.
140
+ */
141
+ updateTranslations(translations: Partial<Record<TranslationKey, Partial<Translation>>>): void
142
+ }
143
+ }
144
+
145
+ /* ===== TRANSLATOR IMPLEMENTATION ========================================== */
146
+
147
+ function checkLocale(locale: Intl.Locale): void {
148
+ if (! ISO_LANGUAGES.includes(locale.language as any)) {
149
+ warn(`Unknown language code "${locale.language}"`)
150
+ }
151
+
152
+ if (! locale.region) return
153
+
154
+ if (! ISO_COUNTRIES.includes(locale.region as any)) {
155
+ warn(`Unknown region code "${locale.region}"`)
156
+ }
157
+ }
158
+
159
+ /** Create a _reactive_ translator object from the given options */
160
+ export function makeTranslator(options: I18nOptions): Translator {
161
+ // Parse the configured default language, or use the supplied locale.
162
+ const defaultLocale: Intl.Locale = typeof options.defaultLanguage === 'string' ?
163
+ new Intl.Locale(options.defaultLanguage) :
164
+ options.defaultLanguage
165
+
166
+ // Retain only the default language and optional region.
167
+ const defaultLanguage = defaultLocale.region ?
168
+ `${defaultLocale.language}-${defaultLocale.region}` :
169
+ defaultLocale.language
170
+
171
+ // Default time zone
172
+ const defaultTimeZone = options.defaultTimeZone
173
+
174
+ // Snapshot the translations, including copies of message tuples.
175
+ const translations: InternalTranslations = new Map()
176
+ Object.entries(options.translations ?? {}).forEach(([ key, value ]) => {
177
+ translations.set(key, copyTranslation(value))
178
+ })
179
+
180
+ // Use a null prototype so inherited properties cannot resolve as format aliases.
181
+ const dateTimeFormats: DateTimeFormats = Object.assign(Object.create(null), {
182
+ default: { dateStyle: 'medium', timeStyle: 'medium' },
183
+ short: { dateStyle: 'short', timeStyle: 'short' },
184
+ medium: { dateStyle: 'medium', timeStyle: 'medium' },
185
+ long: { dateStyle: 'long', timeStyle: 'long' },
186
+ full: { dateStyle: 'full', timeStyle: 'full' },
187
+
188
+ // Formats for dates only
189
+ date: { dateStyle: 'medium' },
190
+ shortDate: { dateStyle: 'short' },
191
+ mediumDate: { dateStyle: 'medium' },
192
+ longDate: { dateStyle: 'long' },
193
+ fullDate: { dateStyle: 'full' },
194
+
195
+ // Formats for times only
196
+ time: { timeStyle: 'medium' },
197
+ shortTime: { timeStyle: 'short' },
198
+ mediumTime: { timeStyle: 'medium' },
199
+ longTime: { timeStyle: 'long' },
200
+ fullTime: { timeStyle: 'full' },
201
+
202
+ // overrides and custom formats
203
+ ...options.dateTimeFormats,
204
+ })
205
+
206
+ // Use a null prototype so inherited properties cannot resolve as format aliases.
207
+ const numberFormats: NumberFormats = Object.assign(Object.create(null), {
208
+ // Create currency aliases for the codes reported by the runtime.
209
+ ...ISO_CURRENCIES.reduce((formats, currency) => {
210
+ formats[currency] = { style: 'currency', currency }
211
+ return formats
212
+ }, {} as Record<string, Intl.NumberFormatOptions>),
213
+ // Add the default number format
214
+ default: {},
215
+ // Overrides and custom formats
216
+ ...options.numberFormats,
217
+ })
218
+
219
+ // Initialize the current locale from the configured default.
220
+ const locale = shallowRef(new Intl.Locale(defaultLanguage))
221
+ watch(locale, checkLocale, { immediate: true })
222
+
223
+ // Try the current regional variant and base language, then the default equivalents.
224
+ const languages = computed(() => {
225
+ const { language, region } = locale.value
226
+ const order: string[] = [ language ]
227
+ if (region) order.unshift(`${language}-${region}`)
228
+ if (language !== defaultLanguage) order.push(defaultLanguage)
229
+ if (! order.includes(defaultLocale.language)) order.push(defaultLocale.language)
230
+ return order as any as LanguageKeys
231
+ })
232
+
233
+ // Build the translator object; its accessors read and write the locale ref.
234
+ const translator = {
235
+ get locale() {
236
+ return locale.value
237
+ },
238
+
239
+ set locale(value: Intl.Locale) {
240
+ locale.value = new Intl.Locale(value.language, { region: value.region })
241
+ },
242
+
243
+ get language(): ISOLanguage {
244
+ return translator.locale.language as ISOLanguage
245
+ },
246
+
247
+ set language(value: ISOLanguage) {
248
+ translator.locale = new Intl.Locale(value, { region: translator.region })
249
+ },
250
+
251
+ get region(): ISOCountry | undefined {
252
+ return translator.locale.region as ISOCountry
253
+ },
254
+
255
+ set region(value: ISOCountry | undefined) {
256
+ translator.locale = new Intl.Locale(translator.language, { region: value || undefined })
257
+ },
258
+
259
+ n(value?: number | bigint | null | undefined, format: string | Intl.NumberFormatOptions = 'default'): string {
260
+ if (value == null) return '' // null or undefined produces an empty string
261
+
262
+ const options = typeof format === 'string' ? numberFormats[format] : format
263
+ if (! options) warn(`NumberFormat alias "${format}" not found`)
264
+
265
+ return new Intl.NumberFormat(translator.locale, options).format(value)
266
+ },
267
+
268
+ t(translation: TranslationKey | Translation, params?: TranslationParams): string {
269
+ return translator.tc(translation, 1, params)
270
+ },
271
+
272
+ tc(translation: TranslationKey | Translation, n: number, params?: TranslationParams): string {
273
+ const template = getTemplate(translations, translation, languages.value)
274
+ const format = new Intl.NumberFormat(translator.locale, numberFormats['default'])
275
+ return replaceParams(template, { n, ...params }, format)
276
+ },
277
+
278
+ d(input?: DateInput, format: DateTimeFormatAlias | Intl.DateTimeFormatOptions = 'default', timeZone?: string): string {
279
+ if ((input == null) || (input === '')) return ''
280
+
281
+ const date = input instanceof Date ? input : new Date(input)
282
+ const options = typeof format === 'string' ? dateTimeFormats[format] : format
283
+ if (! options) warn(`DateTimeFormat alias "${format}" not found`)
284
+
285
+ // Prefer the explicit time zone, then the format's zone, then the default.
286
+ timeZone = timeZone ?? options?.timeZone ?? defaultTimeZone
287
+
288
+ // Preserve inherited and non-enumerable getters that spreading would lose.
289
+ // Define our own timeZone to override readonly properties without modifying the caller.
290
+ const optionsWithTimeZone = Object.create(options ?? null, {
291
+ timeZone: { value: timeZone, enumerable: true },
292
+ }) as Intl.DateTimeFormatOptions
293
+
294
+ // Format our date, optionally defaulting the time zone
295
+ return new Intl.DateTimeFormat(locale.value, optionsWithTimeZone).format(date)
296
+ },
297
+
298
+ utils: {
299
+ language(code: ISOLanguage): string {
300
+ return new Intl.DisplayNames(translator.locale, { type: 'language' }).of(code)!
301
+ },
302
+ country(code: ISOCountry | 'EU' | 'UN'): string {
303
+ return new Intl.DisplayNames(translator.locale, { type: 'region' }).of(code)!
304
+ },
305
+ flag(code: ISOCountry | 'EU' | 'UN'): string {
306
+ return [ ...code ]
307
+ .map((c) => c.codePointAt(0)!)
308
+ .map((n) => 0x1f1a5 + n)
309
+ .map((n) => String.fromCodePoint(n))
310
+ .join('')
311
+ },
312
+ updateTranslations(updates: Partial<Record<TranslationKey, Partial<Translation>>>): void {
313
+ // Validate and copy all inputs before changing stored messages or their cache.
314
+ const copiedUpdates = Object.entries(updates).map(([ key, translation ]) => [ key, copyTranslation(translation) ] as const)
315
+ let updated = false
316
+
317
+ for (const [ key, translation ] of copiedUpdates) {
318
+ for (const [ lang, value ] of translation) {
319
+ if (! value) continue
320
+
321
+ let translation = translations.get(key)
322
+ if (! translation) translation = new Map()
323
+ translation.set(lang, value)
324
+ translations.set(key, translation)
325
+ updated = true
326
+ }
327
+ }
328
+
329
+ // Clear the cache for the updated translations
330
+ // istanbul ignore else // No need to clear the cache if no updates were made.
331
+ if (updated) caches.delete(translations)
332
+ },
333
+ },
334
+ } as const satisfies Translator
335
+
336
+ // Return a reactive version of the translator
337
+ return reactive(translator)
338
+ }
339
+
340
+ /* ===== TRANSLATION UTILITIES ============================================== */
341
+
342
+ type LanguageKeys = readonly [ string, ...string[] ]
343
+ type InternalMessage = string | string[]
344
+ type InternalTranslation = Map<string, InternalMessage | undefined>
345
+ type InternalTranslations = Map<string, InternalTranslation>
346
+ type TemplatePart = string | { param: string }
347
+ type TranslationTemplate = { zero: TemplatePart[], singular: TemplatePart[], plural: TemplatePart[] }
348
+
349
+ /** Copy and validate message tuples, allowing omitted trailing variants but no gaps. */
350
+ function copyMessage(message: TranslationMessage | undefined): InternalMessage | undefined {
351
+ if (message == null || typeof message === 'string') return message
352
+
353
+ const variants = [ ...message ]
354
+ while (variants.length && variants[variants.length - 1] === undefined) variants.pop()
355
+ if (! variants.length || variants.length > 3) {
356
+ throw new TypeError('Translation tuples must contain one to three strings without gaps')
357
+ }
358
+ return variants.map((variant) => {
359
+ if (typeof variant !== 'string') {
360
+ throw new TypeError('Translation tuples must contain one to three strings without gaps')
361
+ }
362
+ return variant
363
+ })
364
+ }
365
+
366
+ /** Snapshot a translation, including any explicit plural tuples. */
367
+ function copyTranslation(translation: Partial<Translation> | undefined): InternalTranslation {
368
+ return new Map(Object.entries(translation ?? {}).map(([ language, message ]) => [ language, copyMessage(message) ] as const))
369
+ }
370
+
371
+ /**
372
+ * Cached parsed translation templates.
373
+ *
374
+ * The keys are:
375
+ * 1) the translations instance (WeakMap key)
376
+ * 2) the current language and optional region (first entry in the fallback order)
377
+ * 3) the translation key
378
+ */
379
+ const caches = new WeakMap<InternalTranslations, Map<string, Map<string, TranslationTemplate>>>()
380
+
381
+ /** Get the `TranslationTemplate` for the translation or translation key. */
382
+ function getTemplate(
383
+ translations: InternalTranslations,
384
+ translation: TranslationKey | Translation,
385
+ languages: LanguageKeys,
386
+ ): TranslationTemplate {
387
+ if (! translation) throw new Error('No translation key specified')
388
+
389
+ if (typeof translation === 'string') {
390
+ // Get the cache for the messages instance
391
+ let cache = caches.get(translations)
392
+ if (! cache) caches.set(translations, cache = new Map())
393
+
394
+ // Get the cache for the current language and optional region.
395
+ let languageCache = cache.get(languages[0])
396
+ if (! languageCache) cache.set(languages[0], languageCache = new Map())
397
+
398
+ // Get the translation from the cache or parse it
399
+ let template = languageCache.get(translation)
400
+ if (! template) {
401
+ let object = translations.get(translation)
402
+ if (! object) {
403
+ warn(`Translation key "${translation}" not found`)
404
+ object = new Map()
405
+ object.set(languages[languages.length - 1]!, translation)
406
+ }
407
+ template = extractTemplate(object, languages)
408
+
409
+ languageCache.set(translation, template)
410
+ }
411
+
412
+ return template
413
+ } else {
414
+ // Snapshot inline messages, including tuples, without caching the result.
415
+ const map = copyTranslation(translation)
416
+ return extractTemplate(map, languages)
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Select a message in fallback order and parse its zero, singular, and
422
+ * plural variants into literal strings and parameter tokens.
423
+ */
424
+ function extractTemplate(
425
+ translation: InternalTranslation,
426
+ languages: LanguageKeys,
427
+ ): TranslationTemplate {
428
+ let message: InternalMessage | undefined = undefined
429
+
430
+ for (const language of languages) {
431
+ message = translation.get(language)
432
+ if (message) break
433
+ }
434
+
435
+ if (! message) {
436
+ const language = languages[languages.length - 1]
437
+ const entries = Object.fromEntries(translation.entries())
438
+ warn(`Translation missing default language "${language}" in`, entries)
439
+ return { zero: [], singular: [], plural: [] }
440
+ }
441
+
442
+ // Tuple elements are already separated; their pipes remain literal.
443
+ const translations = typeof message === 'string' ? splitVariants(message) : message
444
+ const [ first, second, third ] = translations.map(parseTemplate)
445
+ if (translations.length === 1) {
446
+ return { zero: first!, singular: first!, plural: first! }
447
+ } else if (translations.length === 2) {
448
+ return { zero: second!, singular: first!, plural: second! }
449
+ } else {
450
+ return { zero: first!, singular: second!, plural: third! }
451
+ }
452
+ }
453
+
454
+ /** Split variants at unescaped pipes and decode backslash runs immediately before pipes. */
455
+ function splitVariants(string: string): string[] {
456
+ const translations: string[] = []
457
+ let start = 0
458
+ let part = ''
459
+
460
+ // Each pair produces one literal backslash; an odd remainder escapes the pipe.
461
+ for (const match of string.matchAll(/(\\*)\|/g)) {
462
+ const slashes = match[1]!.length
463
+ part += string.slice(start, match.index) + '\\'.repeat(Math.floor(slashes / 2))
464
+ if (slashes % 2) {
465
+ part += '|'
466
+ } else {
467
+ translations.push(part)
468
+ part = ''
469
+ }
470
+ start = match.index + match[0].length
471
+ }
472
+ translations.push(part + string.slice(start))
473
+ return translations
474
+ }
475
+
476
+ /** Parse balanced placeholders and resolve escapes independently of parameter values. */
477
+ function parseTemplate(template: string): TemplatePart[] {
478
+ const parts: TemplatePart[] = []
479
+ let literal = ''
480
+
481
+ for (let index = 0; index < template.length; index++) {
482
+ if (template[index] !== '{') {
483
+ literal += template[index]
484
+ continue
485
+ }
486
+
487
+ // Balanced braces preserve parameter names such as "x{y}".
488
+ let end = index + 1
489
+ let depth = 1
490
+ for (; end < template.length; end++) {
491
+ if (template[end] === '{') depth++
492
+ if (template[end] === '}' && --depth === 0) break
493
+ }
494
+ if (depth) {
495
+ literal += template.slice(index)
496
+ break
497
+ }
498
+
499
+ // As with pipes, pairs are literal backslashes and an odd remainder escapes.
500
+ let slashes = 0
501
+ while (literal[literal.length - slashes - 1] === '\\') slashes++
502
+ literal = literal.slice(0, literal.length - slashes) + '\\'.repeat(Math.floor(slashes / 2))
503
+
504
+ if (slashes % 2) {
505
+ literal += template.slice(index, end + 1)
506
+ } else {
507
+ if (literal) parts.push(literal)
508
+ parts.push({ param: template.slice(index + 1, end).trim() })
509
+ literal = ''
510
+ }
511
+ index = end
512
+ }
513
+
514
+ if (literal) parts.push(literal)
515
+ return parts
516
+ }
517
+
518
+ /** Select a plural variant, substitute parameters once, and trim the result. */
519
+ function replaceParams(
520
+ template: TranslationTemplate,
521
+ params: TranslationParams,
522
+ format: Intl.NumberFormat,
523
+ ): string {
524
+ // Select the template to use based on the "n" (number) parameter
525
+ const n = typeof params.n === 'string' ? Number(params.n) : params.n
526
+ const formatted = n === 0 ? template.zero :
527
+ n === 1 ? template.singular :
528
+ template.plural
529
+
530
+ return formatted.map((part) => {
531
+ if (typeof part === 'string') return part
532
+ if (! Object.hasOwn(params, part.param)) return `{${part.param}}`
533
+
534
+ const value = params[part.param]
535
+ return typeof value === 'number' ? format.format(value) :
536
+ typeof value === 'string' ? value :
537
+ value ? String(value) : ''
538
+ }).join('').trim()
539
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juit/vue-i18n",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,22 +15,22 @@
15
15
  "test": "vitest run --coverage"
16
16
  },
17
17
  "peerDependencies": {
18
- "vue": "^3.5.42"
18
+ "vue": "^3.5.43"
19
19
  },
20
20
  "devDependencies": {
21
- "@microsoft/api-extractor": "^7.59.1",
22
- "@plugjs/eslint-plugin": "^0.7.9",
21
+ "@microsoft/api-extractor": "^7.59.2",
22
+ "@plugjs/eslint-plugin": "^0.7.10",
23
23
  "@types/node": "<25",
24
- "@vitejs/plugin-vue": "^6.0.8",
25
- "@vitest/coverage-v8": "^5.0.0",
26
- "eslint": "^10.10.0",
24
+ "@vitejs/plugin-vue": "^6.0.9",
25
+ "@vitest/coverage-v8": "^5.0.1",
26
+ "eslint": "^10.11.0",
27
27
  "eslint-plugin-vue": "^10.11.0",
28
- "jsdom": "^30.0.1",
28
+ "jsdom": "^30.1.1",
29
29
  "typescript": "<7",
30
- "unplugin-dts": "^1.1.0",
30
+ "unplugin-dts": "^1.1.1",
31
31
  "vite": "^8.3.0",
32
- "vitest": "^5.0.0",
33
- "vue": "^3.5.42",
32
+ "vitest": "^5.0.1",
33
+ "vue": "^3.5.43",
34
34
  "vue-tsc": "^3.3.11"
35
35
  },
36
36
  "author": "Juit Developers <developers@juit.com>",
@@ -53,6 +53,8 @@
53
53
  "test": "test"
54
54
  },
55
55
  "files": [
56
- "dist"
56
+ "dist",
57
+ "lib",
58
+ "*.md"
57
59
  ]
58
60
  }