From c607eda310f0ae4f84a5847d73c93660578df215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Marie=20De=20Mey?= Date: Sun, 23 Jun 2024 11:04:51 +0300 Subject: [PATCH] cached natives --- CHANGELOG.md | 4 +++ docs/client.md | 17 ++++++++++++ src/client/client.ts | 42 ++++++++++++++++++++++++++++++ src/client/interpolation.ts | 52 ++++++++++++++++++------------------- src/client/types.ts | 7 +++++ test/static.test.ts | 6 ++--- 6 files changed, 99 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 842f5a7..c25708e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 1.1.17 +## Modified + +- `Intl.*****Format` are now cached + ## Added - Missing key/translation nuance based on fallback diff --git a/docs/client.md b/docs/client.md index 3c4c197..470e35d 100644 --- a/docs/client.md +++ b/docs/client.md @@ -65,3 +65,20 @@ This partial answer can be conveyed in the answer with the action' results (espe ## Overriding `interpolate` `I18nClient.interpolate` is called on _each_ translation, and can be used to add a transformation or have a list of "last 20 translations" in the translator's UI + +## Native `Intl` helpers + +Cached (taking care of locale change) + +```ts +class I18nClient { + ... + + numberFormat(options: Intl.NumberFormatOptions): Intl.NumberFormat + listFormat(options: Intl.ListFormatOptions): Intl.ListFormat + pluralRules(options: Intl.PluralRulesOptions): Intl.PluralRules + relativeTimeFormat(options: Intl.RelativeTimeFormatOptions): Intl.RelativeTimeFormat + displayNames(options: Intl.DisplayNamesOptions): Intl.DisplayNames + dateTimeFormat(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat +} +``` \ No newline at end of file diff --git a/src/client/client.ts b/src/client/client.ts index a6e404f..e030ee9 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -32,6 +32,14 @@ export function removeDuplicates(arr: Locale[]) { return arr.filter((k) => !done.has(k) && done.add(k)) } +const options2uniqueString = (options: any) => + Object.entries(options) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([k, v]) => `${k}:${v}`) + .join('|') + +type IntlConstructor = new (locale: string, options?: any) => T + export default class I18nClient implements OmnI18nClient { internals: Internals = {} dictionary: ClientDictionary = {} @@ -127,6 +135,7 @@ export default class I18nClient implements OmnI18nClient { this.locales.every((locale, i) => locale == locales[i]) ) return + if (this.locales[0] !== locales[0]) this.nativeIntl = {} this.locales = locales this.toLoadZones = this.loadedZones this.loadedZones = new Set() @@ -161,6 +170,8 @@ export default class I18nClient implements OmnI18nClient { return interpolate({ client: this, key }, text, args) } + //#region Reports + missing(key: string, fallback?: Translation): string { this.report(key, fallback !== undefined ? 'Missing translation' : 'Missing key') return fallback ?? `[${key}]` @@ -172,6 +183,37 @@ export default class I18nClient implements OmnI18nClient { report(key: string, error: string, spec?: object): void { // To be overridden } + + //#endregion + //#region Natives + + private nativeIntl: Record> = {} + private cachedNative(ctor: IntlConstructor, options: any): T { + const key = ctor.name + if (!this.nativeIntl[key]) this.nativeIntl[key] = {} + const optionsString = options2uniqueString(options || {}) + return (this.nativeIntl[key][optionsString] ??= new ctor(this.locales[0], options)) + } + numberFormat(options: Intl.NumberFormatOptions) { + return this.cachedNative(Intl.NumberFormat, options) + } + listFormat(options: Intl.ListFormatOptions) { + return this.cachedNative(Intl.ListFormat, options) + } + pluralRules(options: Intl.PluralRulesOptions) { + return this.cachedNative(Intl.PluralRules, options) + } + relativeTimeFormat(options: Intl.RelativeTimeFormatOptions) { + return this.cachedNative(Intl.RelativeTimeFormat, options) + } + displayNames(options: Intl.DisplayNamesOptions) { + return this.cachedNative(Intl.DisplayNames, options) + } + dateTimeFormat(options: Intl.DateTimeFormatOptions) { + return this.cachedNative(Intl.DateTimeFormat, options) + } + + //#endregion } export function getContext(translator: Translator): TContext { diff --git a/src/client/interpolation.ts b/src/client/interpolation.ts index c4097a4..6ca1dd2 100644 --- a/src/client/interpolation.ts +++ b/src/client/interpolation.ts @@ -45,15 +45,16 @@ export const processors: Record string> = { if (!client.internals.ordinals) return client.missing('internals.ordinals') const num = parseInt(str) if (isNaN(num)) return client.error(key, 'NaN', { str }) - return client.internals.ordinals[ - new Intl.PluralRules(client.locales[0], { type: 'ordinal' }).select(num) - ].replace('$', str) + return client.internals.ordinals[client.pluralRules({ type: 'ordinal' }).select(num)].replace( + '$', + str + ) }, plural(this: TContext, str: string, designation: string, plural?: string) { const num = parseInt(str), { client, key } = this if (isNaN(num)) return client.error(key, 'NaN', { str }) - const rule = new Intl.PluralRules(client.locales[0], { type: 'cardinal' }).select(num) + const rule = client.pluralRules({ type: 'cardinal' }).select(num) const rules: string | Record = plural ? { one: designation, other: plural } : designation @@ -80,7 +81,7 @@ export const processors: Record string> = { currency: this.client.currency, ...options } - return num.toLocaleString(client.locales, options) + return client.numberFormat(options).format(num) }, date(this: TContext, str: string, options?: any) { const nbr = parseInt(str), @@ -96,7 +97,7 @@ export const processors: Record string> = { timeZone: client.timeZone, ...options } - return date.toLocaleString(client.locales, options) + return client.dateTimeFormat(options).format(date) }, relative(this: TContext, str: string, options?: any) { const content = /(-?\d+)\s*(\w+)/.exec(str), @@ -114,32 +115,29 @@ export const processors: Record string> = { return client.error(key, 'Invalid date options', { options }) options = formats.date[options] } - return new Intl.RelativeTimeFormat(client.locales[0], options).format( - nbr, - unit - ) + return client.relativeTimeFormat(options).format(nbr, unit) }, region(this: TContext, str: string) { return ( - new Intl.DisplayNames(this.client.locales[0], { type: 'region' }).of(str) || + this.client.displayNames({ type: 'region' }).of(str) || this.client.error(this.key, 'Invalid region', { str }) ) }, language(this: TContext, str: string) { return ( - new Intl.DisplayNames(this.client.locales[0], { type: 'language' }).of(str) || + this.client.displayNames({ type: 'language' }).of(str) || this.client.error(this.key, 'Invalid language', { str }) ) }, script(this: TContext, str: string) { return ( - new Intl.DisplayNames(this.client.locales[0], { type: 'script' }).of(str) || + this.client.displayNames({ type: 'script' }).of(str) || this.client.error(this.key, 'Invalid script', { str }) ) }, currency(this: TContext, str: string) { return ( - new Intl.DisplayNames(this.client.locales[0], { type: 'currency' }).of(str) || + this.client.displayNames({ type: 'currency' }).of(str) || this.client.error(this.key, 'Invalid currency', { str }) ) }, @@ -155,9 +153,7 @@ export const processors: Record string> = { args.push(opts) opts = {} } - return new Intl.ListFormat(this.client.locales[0], opts).format( - args.map((arg) => makeArray(arg)).flat() - ) + return this.client.listFormat(opts).format(args.map((arg) => makeArray(arg)).flat()) }, duration(this: TContext, duration: DurationDescription, options?: DurationOptions) { const { client, key } = this @@ -214,16 +210,20 @@ export const processors: Record string> = { if (!parts.length) return empty || client.error(key, 'Empty duration', { duration: cappedDuration }) const translatedParts = parts.map(([value, unit]) => - new Intl.NumberFormat(this.client.locales[0], { - style: 'unit', - unit, - unitDisplay: style - }).format(value) + this.client + .numberFormat({ + style: 'unit', + unit, + unitDisplay: style + }) + .format(value) ) - return new Intl.ListFormat(this.client.locales[0], { - style, - type: style === 'narrow' ? 'unit' : 'conjunction' - }).format(translatedParts) + return this.client + .listFormat({ + style, + type: style === 'narrow' ? 'unit' : 'conjunction' + }) + .format(translatedParts) } } diff --git a/src/client/types.ts b/src/client/types.ts index be77260..a2acb8c 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -31,6 +31,13 @@ export interface OmnI18nClient { onModification?: OnModification missing(key: string, fallback?: Translation): string error(key: string, error: string, spec: object): string + + numberFormat(options: Intl.NumberFormatOptions): Intl.NumberFormat + listFormat(options: Intl.ListFormatOptions): Intl.ListFormat + pluralRules(options: Intl.PluralRulesOptions): Intl.PluralRules + relativeTimeFormat(options: Intl.RelativeTimeFormatOptions): Intl.RelativeTimeFormat + displayNames(options: Intl.DisplayNamesOptions): Intl.DisplayNames + dateTimeFormat(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat } export interface TContext { diff --git a/test/static.test.ts b/test/static.test.ts index 9be0475..426f801 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -43,7 +43,7 @@ beforeAll(async () => { 'format.number': { '': '{number::$1}' }, 'format.number.engineering': { '': '{number::$1|engineering}' }, 'format.price': { '': '{number::$2|style: currency, currency: $1}' }, - 'format.dateTime': { '': '{date::$1}' }, + 'format.dateTime': { '': '{date::$1|dateStyle: short, timeStyle: short}' }, 'format.medium': { '': '{date::$1|dateStyle: medium}' }, 'format.date': { '': '{date::$1|date}' }, 'format.time': { '': '{date::$1|time}' }, @@ -211,8 +211,8 @@ describe('formatting', () => { const date = new Date('2021-05-01T12:34:56.789Z') expect(T.en.format.date(date)).toBe('5/1/21') expect(T.be.format.date(date)).toBe('1/05/21') - expect(T.en.format.dateTime(date)).toBe('5/1/2021, 12:34:56 PM') - expect(T.be.format.dateTime(date)).toBe('01/05/2021 14:34:56') + expect(T.en.format.dateTime(date)).toBe('5/1/21, 12:34 PM') + expect(T.be.format.dateTime(date)).toBe('1/05/21 14:34') expect(T.en.format.medium(date)).toBe('May 1, 2021') expect(T.be.format.medium(date)).toBe('1 mai 2021') expect(T.en.format.time(date)).toBe('12:34 PM')