From b93080a533fa3ce8104dcdee0e37de7b3cafb591 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 16:49:02 +0200 Subject: [PATCH] core-plugin-api: implemented typed formatting options for TranslationFunction Signed-off-by: Patrik Oldsberg --- .../apis/definitions/TranslationApi.test.ts | 62 ++++---- .../src/apis/definitions/TranslationApi.ts | 149 +++++++++++++----- 2 files changed, 143 insertions(+), 68 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts index 5aa61b9373..b4233c9215 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -145,44 +145,52 @@ describe('TranslationFunction', () => { f('deep', { replace: { a: { b: { c: '' } } } }); }); - it('should ignore formatting', () => { + it('should support formatting', () => { const f = (() => {}) as TranslationFunction<{ - none: '='; - simple: '= {{bar, number}}'; - multiple: '= {{bar , whatever }} {{ baz, someFormat(pretty: "yes")}}'; - deep: '= {{x.y,derp}} {{ x.z, derp}} {{ a.b.c, derp }}'; + none: '{{x}}'; + number: '{{x, number}}'; + numberOptions: '{{x, number(minimumFractionDigits: 2)}}'; + currency: '{{x, currency}}'; + datetime: '{{x, dateTime}}'; + relativeTimeOptions: '{{x, relativeTime(quarter)}}'; + list: '{{x, list}}'; }>; expect(f).toBeDefined(); + f('none', { replace: { x: 'x' } }); + f('number', { replace: { x: 1 } }); + f('number', { + replace: { x: 1 }, + formatParams: { x: { minimumFractionDigits: 2 } }, + }); + f('numberOptions', { replace: { x: 1 } }); + f('currency', { replace: { x: 1 } }); + f('datetime', { replace: { x: new Date() } }); + f('relativeTimeOptions', { replace: { x: 1 } }); + f('relativeTimeOptions', { + replace: { x: 1 }, + formatParams: { x: { style: 'short' } }, + }); + f('list', { replace: { x: ['a', 'b', 'c'] } }); // @ts-expect-error - f('none', { replace: { unknown: 1 } }); - f('simple', { replace: { bar: '' } }); + f('none', { replace: { x: 1 } }); // @ts-expect-error - f('simple'); + f('number', { replace: { x: '1' } }); // @ts-expect-error - f('simple', { replace: {} }); + f('numberOptions', { replace: { x: '1' } }); // @ts-expect-error - f('simple', { replace: { wrong: '' } }); - f('multiple', { replace: { bar: '', baz: '' } }); + f('currency', { replace: { x: '1' } }); // @ts-expect-error - f('multiple', { replace: { bar: '' } }); + f('datetime', { replace: { x: '1' } }); // @ts-expect-error - f('multiple', { replace: { baz: '' } }); + f('relativeTimeOptions', { replace: { x: '1' } }); + f('relativeTimeOptions', { + replace: { x: 1 }, + // @ts-expect-error + formatParams: { x: { minimumFractionDigits: 2 } }, + }); // @ts-expect-error - f('multiple'); - // @ts-expect-error - f('multiple', {}); - // @ts-expect-error - f('multiple', { replace: {} }); - f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }); - // @ts-expect-error - f('deep'); - // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); - // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' } } }); - // @ts-expect-error - f('deep', { replace: { a: { b: { c: '' } } } }); + f('list', { replace: { x: [1, 2, 3] } }); }); it('should support nesting', () => { diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index ae23947390..60fff9b75e 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -24,7 +24,10 @@ import { TranslationRef } from '../../translation'; * @alpha */ interface BaseOptions { - interpolation?: {}; + interpolation?: { + /** Whether to HTML escape provided values, defaults to false */ + escapeValue?: boolean; + }; } /** @@ -32,13 +35,34 @@ interface BaseOptions { * * @internal */ -export type TranslationPlural = - | 'zero' - | 'one' - | 'two' - | 'few' - | 'many' - | 'other'; +type TranslationPlural = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +/** + * A mapping of i18n formatting types to their corresponding types and options. + * @internal + */ +type I18nextFormatMap = { + number: { + type: number; + options: Intl.NumberFormatOptions; + }; + currency: { + type: number; + options: Intl.NumberFormatOptions; + }; + datetime: { + type: Date; + options: Intl.DateTimeFormatOptions; + }; + relativetime: { + type: number; + options: Intl.RelativeTimeFormatOptions; + }; + list: { + type: string[]; + options: Intl.ListFormatOptions; + }; +}; /** * Extracts all pluralized keys from the message map. @@ -92,18 +116,75 @@ type Trim = T extends ` ${infer U}` : T; /** - * Extracts all option keys from a message string. + * Extracts the key and format from a replacement string. * - * e.g. 'foo {{bar}} {{baz, number}}' -> 'bar' | 'baz' + * e.g. 'foo, number' -> { foo: number }, 'foo' -> { foo: undefined } + */ +type ExtractFormat = + Replacement extends `${infer Key},${infer FullFormat}` + ? { + [key in Trim]: Lowercase< + Trim< + FullFormat extends `${infer Format}(${string})${string}` + ? Format + : FullFormat + > + >; + } + : { [key in Trim]: undefined }; + +/** + * Expand the keys in a flat map to nested objects. + * + * e.g. { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' } * * @internal */ -type OptionKeysFromMessage = - TMessage extends `${string}{{${infer Key},${string}}}${infer Tail}` // ignore formatting, e.g. {{foo, number}} - ? Trim | OptionKeysFromMessage - : TMessage extends `${string}{{${infer Key}}}${infer Tail}` // no formatting, e.g. {{foo}} - ? Trim | OptionKeysFromMessage - : never; +type ExpandKeys = { + [Key in keyof TMap as Key extends `${infer Prefix}.${string}` + ? Prefix + : Key]: Key extends `${string}.${infer Rest}` + ? ExpandKeys<{ [key in Rest]: TMap[Key] }> + : TMap[Key]; +}; + +/** + * Extracts all option keys and their format from a message string. + * + * e.g. 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' } + * + * @internal + */ +type ReplaceFormatsFromMessage = + TMessage extends `${string}{{${infer Replacement}}}${infer Tail}` // no formatting, e.g. {{foo}} + ? ExpandKeys> & ReplaceFormatsFromMessage + : {}; + +/** + * Generates the replace options structure + * + * @internal + */ +type ReplaceOptionsFromFormats = { + [Key in keyof TFormats]: TFormats[Key] extends keyof I18nextFormatMap + ? I18nextFormatMap[TFormats[Key]]['type'] + : TFormats[Key] extends {} + ? Expand> + : string; +}; + +/** + * Generates the formatParams options structure + * + * @internal + */ +type ReplaceFormatParamsFromFormats = { + [Key in keyof TFormats]?: TFormats[Key] extends keyof I18nextFormatMap + ? I18nextFormatMap[TFormats[Key]]['options'] + : TFormats[Key] extends {} + ? Expand> + : undefined; +}; /** * Extracts all nesting keys from a message string. @@ -158,23 +239,6 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( ? I : never; -/** - * Extracts all options from a message string. - * - * e.g. 'foo {{bar}} {{x.y.z}}' -> { bar: , x: { y: { z: } } } - * - * @internal - */ -type ReplaceOptionsFromKeys = UnionToIntersection< - TKeys extends `${infer Prefix}.${infer Rest}` - ? { - [key in Prefix]: ReplaceOptionsFromKeys; - } - : { - [key in TKeys & string]: string | number | string[]; - } ->; - /** * Collects different types of options into a single object * @@ -182,9 +246,14 @@ type ReplaceOptionsFromKeys = UnionToIntersection< */ type CollectOptions< TCount extends { count?: number }, - TReplaceOptions extends {}, + TFormats extends {}, > = TCount & - (keyof TReplaceOptions extends never ? {} : { replace: TReplaceOptions }); + (keyof TFormats extends never + ? {} + : { + replace: Expand>; + formatParams?: Expand>; + }); /** * Helper type to only require options argument if needed @@ -201,12 +270,10 @@ type TranslationFunctionOptions< TPluralKeys extends keyof TMessages, // All keys in the message map that are pluralized TMessages extends { [key in string]: string }, // Collapsed message map with normalized keys and union values > = OptionArgs< - Expand< - CollectOptions< - TKeys & TPluralKeys extends never ? {} : { count: number }, - ExpandRecursive< - ReplaceOptionsFromKeys> - > + CollectOptions< + TKeys & TPluralKeys extends never ? {} : { count: number }, + ExpandRecursive< + UnionToIntersection> > > >;