@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.
- package/AGENTS.md +187 -0
- package/NOTICE.md +13 -0
- package/README.md +232 -93
- package/dist/index.d.ts +235 -174
- package/dist/index.js +147 -90
- package/dist/index.js.map +1 -1
- package/lib/index.ts +386 -0
- package/lib/iso-3166.ts +272 -0
- package/lib/iso-4217.ts +176 -0
- package/lib/iso-639.ts +205 -0
- package/lib/translator.ts +539 -0
- package/package.json +14 -12
|
@@ -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.
|
|
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.
|
|
18
|
+
"vue": "^3.5.43"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@microsoft/api-extractor": "^7.59.
|
|
22
|
-
"@plugjs/eslint-plugin": "^0.7.
|
|
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.
|
|
25
|
-
"@vitest/coverage-v8": "^5.0.
|
|
26
|
-
"eslint": "^10.
|
|
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.
|
|
28
|
+
"jsdom": "^30.1.1",
|
|
29
29
|
"typescript": "<7",
|
|
30
|
-
"unplugin-dts": "^1.1.
|
|
30
|
+
"unplugin-dts": "^1.1.1",
|
|
31
31
|
"vite": "^8.3.0",
|
|
32
|
-
"vitest": "^5.0.
|
|
33
|
-
"vue": "^3.5.
|
|
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
|
}
|