core-plugin-api: implemented typed formatting options for TranslationFunction

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-15 16:49:02 +02:00
parent eb96cadf2c
commit b93080a533
2 changed files with 143 additions and 68 deletions
@@ -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', () => {
@@ -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> = 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 string> =
Replacement extends `${infer Key},${infer FullFormat}`
? {
[key in Trim<Key>]: Lowercase<
Trim<
FullFormat extends `${infer Format}(${string})${string}`
? Format
: FullFormat
>
>;
}
: { [key in Trim<Replacement>]: 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> =
TMessage extends `${string}{{${infer Key},${string}}}${infer Tail}` // ignore formatting, e.g. {{foo, number}}
? Trim<Key> | OptionKeysFromMessage<Tail>
: TMessage extends `${string}{{${infer Key}}}${infer Tail}` // no formatting, e.g. {{foo}}
? Trim<Key> | OptionKeysFromMessage<Tail>
: never;
type ExpandKeys<TMap extends {}> = {
[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> =
TMessage extends `${string}{{${infer Replacement}}}${infer Tail}` // no formatting, e.g. {{foo}}
? ExpandKeys<ExtractFormat<Replacement>> & ReplaceFormatsFromMessage<Tail>
: {};
/**
* Generates the replace options structure
*
* @internal
*/
type ReplaceOptionsFromFormats<TFormats extends {}> = {
[Key in keyof TFormats]: TFormats[Key] extends keyof I18nextFormatMap
? I18nextFormatMap[TFormats[Key]]['type']
: TFormats[Key] extends {}
? Expand<ReplaceOptionsFromFormats<TFormats[Key]>>
: string;
};
/**
* Generates the formatParams options structure
*
* @internal
*/
type ReplaceFormatParamsFromFormats<TFormats extends {}> = {
[Key in keyof TFormats]?: TFormats[Key] extends keyof I18nextFormatMap
? I18nextFormatMap[TFormats[Key]]['options']
: TFormats[Key] extends {}
? Expand<ReplaceFormatParamsFromFormats<TFormats[Key]>>
: undefined;
};
/**
* Extracts all nesting keys from a message string.
@@ -158,23 +239,6 @@ type UnionToIntersection<U> = (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: <value>, x: { y: { z: <value> } } }
*
* @internal
*/
type ReplaceOptionsFromKeys<TKeys extends string> = UnionToIntersection<
TKeys extends `${infer Prefix}.${infer Rest}`
? {
[key in Prefix]: ReplaceOptionsFromKeys<Rest>;
}
: {
[key in TKeys & string]: string | number | string[];
}
>;
/**
* Collects different types of options into a single object
*
@@ -182,9 +246,14 @@ type ReplaceOptionsFromKeys<TKeys extends string> = UnionToIntersection<
*/
type CollectOptions<
TCount extends { count?: number },
TReplaceOptions extends {},
TFormats extends {},
> = TCount &
(keyof TReplaceOptions extends never ? {} : { replace: TReplaceOptions });
(keyof TFormats extends never
? {}
: {
replace: Expand<ReplaceOptionsFromFormats<TFormats>>;
formatParams?: Expand<ReplaceFormatParamsFromFormats<TFormats>>;
});
/**
* 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<OptionKeysFromMessage<TMessages[TKeys]>>
>
CollectOptions<
TKeys & TPluralKeys extends never ? {} : { count: number },
ExpandRecursive<
UnionToIntersection<ReplaceFormatsFromMessage<TMessages[TKeys]>>
>
>
>;