Merge pull request #19968 from backstage/rugvip/i18n-types

core-plugin-api: type inference of translations message options
This commit is contained in:
Patrik Oldsberg
2023-09-18 17:46:40 +02:00
committed by GitHub
8 changed files with 694 additions and 82 deletions
@@ -398,7 +398,7 @@ describe('I18nextTranslationApi', () => {
foo: 'Foo',
bar: 'Bar',
baz: 'Baz',
});
} as const);
expect(snapshot.t('foo')).toBe('Foo');
expect(snapshot.t('bar')).toBe('Bar');
@@ -410,31 +410,43 @@ describe('I18nextTranslationApi', () => {
shallow: 'Foo {{ bar }}',
multiple: 'Foo {{ bar }} {{ baz }}',
deep: 'Foo {{ bar.baz }}',
});
} as const);
// @ts-expect-error
expect(snapshot.t('shallow')).toBe('Foo {{ bar }}');
expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar');
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}');
expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe(
'Foo Bar Baz',
expect(snapshot.t('shallow', { replace: { bar: 'Bar' } })).toBe(
'Foo Bar',
);
// @ts-expect-error
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
// @ts-expect-error
expect(snapshot.t('multiple', { replace: { bar: 'Bar' } })).toBe(
'Foo Bar {{ baz }}',
);
expect(
snapshot.t('multiple', { replace: { bar: 'Bar', baz: 'Baz' } }),
).toBe('Foo Bar Baz');
// @ts-expect-error
expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}');
expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz');
expect(snapshot.t('deep', { replace: { bar: { baz: 'Baz' } } })).toBe(
'Foo Baz',
);
});
// Escaping isn't as useful in React, since we don't need to escape HTML in strings
it('should not escape by default', () => {
const snapshot = snapshotWithMessages({
foo: 'Foo {{ foo }}',
});
} as const);
expect(snapshot.t('foo', { foo: '<div>' })).toBe('Foo <div>');
expect(snapshot.t('foo', { replace: { foo: '<div>' } })).toBe(
'Foo <div>',
);
expect(
snapshot.t('foo', {
foo: '<div>',
replace: { foo: '<div>' },
interpolation: { escapeValue: true },
}),
).toBe('Foo &lt;div&gt;');
@@ -445,7 +457,7 @@ describe('I18nextTranslationApi', () => {
foo: 'Foo $t(bar) $t(baz)',
bar: 'Nested',
baz: 'Baz {{ qux }}',
});
} as const);
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
});
@@ -460,24 +472,40 @@ describe('I18nextTranslationApi', () => {
relativeSecondsShort:
'= {{ x, relativeTime(range: second; style: short) }}',
list: '= {{ x, list }}',
});
} as const);
expect(snapshot.t('plain', { x: 5 })).toBe('= 5');
expect(snapshot.t('number', { x: 5 })).toBe('= 5');
expect(snapshot.t('number', { x: 5, minimumFractionDigits: 1 })).toBe(
'= 5.0',
);
expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
expect(snapshot.t('plain', { replace: { x: '5' } })).toBe('= 5');
expect(snapshot.t('number', { replace: { x: 5 } })).toBe('= 5');
expect(
snapshot.t('numberFixed', { x: 5, minimumFractionDigits: 3 }),
snapshot.t('number', {
replace: { x: 5 },
formatParams: { x: { minimumFractionDigits: 1 } },
}),
).toBe('= 5.0');
expect(snapshot.t('numberFixed', { replace: { x: 5 } })).toBe('= 5.00');
expect(
snapshot.t('numberFixed', {
replace: { x: 5 },
formatParams: { x: { minimumFractionDigits: 3 } },
}),
).toBe('= 5.000');
expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
expect(snapshot.t('relativeTime', { x: 15, range: 'weeks' })).toBe(
'= in 15 weeks',
expect(snapshot.t('relativeTime', { replace: { x: 3 } })).toBe(
'= in 3 days',
);
expect(snapshot.t('relativeTime', { replace: { x: -3 } })).toBe(
'= 3 days ago',
);
expect(
snapshot.t('relativeTime', { x: 15, range: 'weeks', style: 'short' }),
snapshot.t('relativeTime', {
replace: { x: 15 },
formatParams: { x: { range: 'weeks' } },
}),
).toBe('= in 15 weeks');
expect(
snapshot.t('relativeTime', {
replace: { x: 15 },
formatParams: { x: { range: 'weeks', style: 'short' } },
}),
).toBe('= in 15 wk.');
expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
@@ -500,15 +528,14 @@ describe('I18nextTranslationApi', () => {
derp_other: 'derps',
derpWithCount_one: '{{ count }} derp',
derpWithCount_other: '{{ count }} derps',
});
} as const);
// TODO(Rugvip): Support plural keys
expect(snapshot.t('derp' as any, { count: 1 })).toBe('derp');
expect(snapshot.t('derp' as any, { count: 2 })).toBe('derps');
expect(snapshot.t('derp' as any, { count: 0 })).toBe('derps');
expect(snapshot.t('derpWithCount' as any, { count: 1 })).toBe('1 derp');
expect(snapshot.t('derpWithCount' as any, { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount' as any, { count: 0 })).toBe('0 derps');
expect(snapshot.t('derp', { count: 1 })).toBe('derp');
expect(snapshot.t('derp', { count: 2 })).toBe('derps');
expect(snapshot.t('derp', { count: 0 })).toBe('derps');
expect(snapshot.t('derpWithCount', { count: 1 })).toBe('1 derp');
expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
});
});
});
@@ -17,6 +17,7 @@
import {
AppLanguageApi,
TranslationApi,
TranslationFunction,
TranslationMessages,
TranslationRef,
TranslationResource,
@@ -301,13 +302,14 @@ export class I18nextTranslationApi implements TranslationApi {
return { ready: false };
}
const t = this.#i18n.getFixedT(null, internalRef.id);
const t = this.#i18n.getFixedT(
null,
internalRef.id,
) as TranslationFunction<TMessages>;
return {
ready: true,
t: (key, options) => {
return t(key as string, { ...options });
},
t,
};
}
+12 -8
View File
@@ -107,14 +107,21 @@ export type TranslationApi = {
export const translationApiRef: ApiRef<TranslationApi>;
// @alpha (undocumented)
export type TranslationFunction<
export interface TranslationFunction<
TMessages extends {
[key in string]: string;
},
> = <TKey extends keyof TMessages>(
key: TKey,
options?: TranslationOptions,
) => TMessages[TKey];
> {
// (undocumented)
<TKey extends keyof CollapsedMessages<TMessages>>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, CollapsedMessages<TMessages>>,
PluralKeys<TMessages>,
CollapsedMessages<TMessages>
>
): CollapsedMessages<TMessages>[TKey];
}
// @alpha
export interface TranslationMessages<
@@ -155,9 +162,6 @@ export interface TranslationMessagesOptions<
ref: TranslationRef_2<TId, TMessages>;
}
// @alpha (undocumented)
export interface TranslationOptions {}
// @alpha (undocumented)
export interface TranslationRef<
TId extends string = string,
@@ -0,0 +1,264 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TranslationFunction } from './TranslationApi';
function unused(..._any: any[]) {}
describe('TranslationFunction', () => {
it('should infer plurals', () => {
const f = (() => {}) as TranslationFunction<{
key_one: 'one';
key_other: 'other';
thingCount_one: '{{count}} thing';
thingCount_other: '{{count}} things';
foo: 'foo';
}>;
expect(f).toBeDefined();
f('foo');
// @ts-expect-error
f('foo', { count: 1 });
f('key', { count: 1 });
// @ts-expect-error
f('key');
// @ts-expect-error
f('key', { notCount: 1 });
// @ts-expect-error
f('key_one');
// @ts-expect-error
f('key_one', { count: 1 });
// @ts-expect-error
f('key_other');
// @ts-expect-error
f('key_other', { count: 6 });
f('thingCount', { count: 1 });
// @ts-expect-error
f('thingCount');
// @ts-expect-error
f('thingCount', { notCount: 1 });
// @ts-expect-error
f('thingCount_one');
// @ts-expect-error
f('thingCount_one', { count: 1 });
// @ts-expect-error
f('thingCount_other');
// @ts-expect-error
f('thingCount_other', { count: 6 });
const x1: 'one' | 'other' = f('key', { count: 6 });
// @ts-expect-error
const x2: 'one' = f('key', { count: 6 });
unused(x1, x2);
});
it('should infer interpolation params', () => {
const f = (() => {}) as TranslationFunction<{
none: '=';
simple: '= {{bar}}';
multiple: '= {{bar }} {{ baz}}';
deep: '= {{x.y}} {{ x.z }} {{ a.b.c }}';
}>;
expect(f).toBeDefined();
// @ts-expect-error
f('none', { replace: { unknown: 1 } });
f('simple', { bar: '' });
// @ts-expect-error
f('simple');
// @ts-expect-error
f('simple', {});
// @ts-expect-error
f('simple', { replace: {} });
// @ts-expect-error
f('simple', { replace: { wrong: '' } });
f('multiple', { bar: '', baz: '' });
// @ts-expect-error
f('multiple', { bar: '' });
// @ts-expect-error
f('multiple', { baz: '' });
// @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', {});
// @ts-expect-error
f('deep', { replace: {} });
// @ts-expect-error
f('deep', { x: { y: '', z: '' }, a: { b: '' } });
// @ts-expect-error
f('deep', { x: { y: '', z: '' } });
// @ts-expect-error
f('deep', { replace: { a: { b: { c: '' } } } });
});
it('should infer interpolation params with count', () => {
const f = (() => {}) as TranslationFunction<{
simple_one: '= {{bar}}';
simple_other: '= {{bar}}';
multiple_one: '= {{ bar}} {{baz }}';
multiple_other: '= {{bar }} {{ baz}}';
deep_one: '= {{ x.y }}';
deep_other: '= {{ x.z }} {{ a.b.c }}';
}>;
expect(f).toBeDefined();
f('simple', { count: 1, bar: '' });
// @ts-expect-error
f('simple', { bar: '' });
// @ts-expect-error
f('simple', { count: 1, replace: {} });
// @ts-expect-error
f('simple', { replace: {} });
// @ts-expect-error
f('simple', { replace: { wrong: '' } });
f('multiple', { count: 2, replace: { bar: '', baz: '' } });
// @ts-expect-error
f('multiple', { count: 2, replace: {} });
// @ts-expect-error
f('multiple', { replace: { bar: '', baz: '' } });
// @ts-expect-error
f('multiple', { replace: { baz: '' } });
// @ts-expect-error
f('multiple', { replace: {} });
// @ts-expect-error
f('multiple', {});
f('deep', {
count: 1,
replace: { x: { y: '', z: '' }, a: { b: { c: '' } } },
});
// @ts-expect-error
f('deep', { count: 1 });
// @ts-expect-error
f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } });
// @ts-expect-error
f('deep', { replace: {} });
// @ts-expect-error
f('deep', { x: { y: '', z: '' }, a: { b: '' } });
// @ts-expect-error
f('deep', { x: { y: '', z: '' } });
// @ts-expect-error
f('deep', { replace: { a: { b: { c: '' } } } });
});
it('should support formatting', () => {
const f = (() => {}) as TranslationFunction<{
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', { x: 1 });
f('number', {
replace: { x: 1 },
formatParams: { x: { minimumFractionDigits: 2 } },
});
f('numberOptions', { x: 1 });
f('currency', { replace: { x: 1 } });
f('datetime', { 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', { x: 1 });
// @ts-expect-error
f('number', { replace: { x: '1' } });
// @ts-expect-error
f('numberOptions', { x: '1' });
// @ts-expect-error
f('currency', { x: '1' });
// @ts-expect-error
f('datetime', { replace: { x: '1' } });
// @ts-expect-error
f('relativeTimeOptions', { x: '1' });
f('relativeTimeOptions', {
replace: { x: 1 },
// @ts-expect-error
formatParams: { x: { minimumFractionDigits: 2 } },
});
// @ts-expect-error
f('list', { x: [1, 2, 3] });
});
it('should support nesting', () => {
const f = (() => {}) as TranslationFunction<{
simple: '$t(foo)';
nested: '$t(bar)';
nestedCount: '$t(qux)';
deep: '$t(baz) $t(qux)';
foo: 'foo';
bar: '{{ bar }}';
baz: '$t(bar) {{ baz }}';
qux_one: '{{ qux1 }}';
qux_other: '{{ qux2 }}';
}>;
expect(f).toBeDefined();
f('simple');
f('nested', { bar: 'bar' });
f('nestedCount', { count: 1, replace: { qux1: 'qux', qux2: 'qux' } });
f('deep', {
count: 1,
replace: { bar: 'bar', baz: 'baz', qux1: 'qux', qux2: 'qux' },
});
// @ts-expect-error
f('deep', { count: 1, baz: 'baz', qux1: 'qux', qux2: 'qux' });
// @ts-expect-error
f('deep', { count: 1, replace: { bar: 'bar', qux1: 'qux', qux2: 'qux' } });
// @ts-expect-error
f('deep', { count: 1, bar: 'bar', baz: 'baz', qux2: 'qux' });
// @ts-expect-error
f('deep', { count: 1, bar: 'bar', baz: 'baz', qux1: 'qux' });
// @ts-expect-error
f('deep', {
replace: { bar: 'bar', baz: 'baz', qux1: 'qux', qux2: 'qux' },
});
});
it('should limit nesting depth', () => {
const f = (() => {}) as TranslationFunction<{
a: '$t(b) {{a}}';
b: '$t(c) {{b}}';
c: '$t(d) {{c}}';
d: '$t(e) {{d}}';
e: '$t(f) {{e}}';
}>;
expect(f).toBeDefined();
f('a', { replace: { a: '', b: '', c: '', d: '' } });
// @ts-expect-error
f('a', { a: '', b: '', c: '' });
// @ts-expect-error
f('a', { replace: { a: '', b: '', c: '', d: '', e: '' } });
});
});
@@ -18,17 +18,316 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { TranslationRef } from '../../translation';
/** @alpha */
export interface TranslationOptions {
/* no options supported for now */
/**
* Base translation options.
*
* @alpha
*/
interface BaseOptions {
interpolation?: {
/** Whether to HTML escape provided values, defaults to false */
escapeValue?: boolean;
};
}
/**
* All pluralization suffixes supported by i18next
*
* @ignore
*/
type TranslationPlural = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
/**
* A mapping of i18n formatting types to their corresponding types and options.
* @ignore
*/
type I18nextFormatMap = {
number: {
type: number;
options: Intl.NumberFormatOptions;
};
currency: {
type: number;
options: Intl.NumberFormatOptions;
};
datetime: {
type: Date;
options: Intl.DateTimeFormatOptions;
};
relativetime: {
type: number;
options: {
range?: Intl.RelativeTimeFormatUnit;
} & Intl.RelativeTimeFormatOptions;
};
list: {
type: string[];
options: Intl.ListFormatOptions;
};
};
/**
* Extracts all pluralized keys from the message map.
*
* @example
* ```
* { foo: 'foo', bar_one: 'bar', bar_other: 'bars' } -> 'bar'
* ```
*
* @ignore
*/
type PluralKeys<TMessages extends { [key in string]: string }> = {
[Key in keyof TMessages]: Key extends `${infer K}_${TranslationPlural}`
? K
: never;
}[keyof TMessages];
/**
* Collapses a message map into normalized keys with union values.
*
* @example
* ```
* { foo_one: 'foo', foo_other: 'foos' } -> { foo: 'foo' | 'foos' }
* ```
*
* @ignore
*/
type CollapsedMessages<TMessages extends { [key in string]: string }> = {
[key in keyof TMessages as key extends `${infer K}_${TranslationPlural}`
? K
: key]: TMessages[key];
};
/**
* Helper type that expands type hints
*
* @ignore
*/
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
/**
* Helper type that expands type hints recursively
*
* @ignore
*/
type ExpandRecursive<T> = T extends infer O
? { [K in keyof O]: ExpandRecursive<O[K]> }
: never;
/**
* Trim away whitespace
*
* @ignore
*/
type Trim<T> = T extends ` ${infer U}`
? Trim<U>
: T extends `${infer U} `
? Trim<U>
: T;
/**
* Extracts the key and format from a replacement string.
*
* @example
* ```
* '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.
*
* @example
* ```
* { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' }
* ```
*
* @ignore
*/
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.
*
* @example
* ```
* 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' }
* ```
*
* @ignore
*/
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
*
* @ignore
*/
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
*
* @ignore
*/
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.
*
* @example
* ```
* 'foo $t(bar) $t(baz)' -> 'bar' | 'baz'
* ```
*
* @ignore
*/
type NestingKeysFromMessage<TMessage extends string> =
TMessage extends `${string}$t(${infer Key})${infer Tail}` // nesting options are not supported
? Trim<Key> | NestingKeysFromMessage<Tail>
: never;
/**
* Find all referenced keys, given a starting key and the full set of messages.
*
* This will only discover keys up to 3 levels deep.
*
* @example
* ```
* <'x', { x: '$t(y) $t(z)', y: 'y', z: '$t(w)', w: 'w', foo: 'foo' }> -> 'x' | 'y' | 'z' | 'w'
* ```
*
* @ignore
*/
type NestedMessageKeys<
TKey extends keyof TMessages,
TMessages extends { [key in string]: string },
> =
| TKey
| NestedMessageKeys2<NestingKeysFromMessage<TMessages[TKey]>, TMessages>;
// Can't recursively reference ourself, so instead we got this beauty
type NestedMessageKeys2<
TKey extends keyof TMessages,
TMessages extends { [key in string]: string },
> =
| TKey
| NestedMessageKeys3<NestingKeysFromMessage<TMessages[TKey]>, TMessages>;
// Only support 3 levels of nesting
type NestedMessageKeys3<
TKey extends keyof TMessages,
TMessages extends { [key in string]: string },
> = TKey | NestingKeysFromMessage<TMessages[TKey]>;
/**
* Converts a union type to an intersection type.
*
* @example
* ```
* { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' }
* ```
*
* @ignore
*/
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
/**
* Collects different types of options into a single object
*
* @ignore
*/
type CollectOptions<
TCount extends { count?: number },
TFormats extends {},
> = TCount &
// count is special, omit it from the replacements
(keyof Omit<TFormats, 'count'> extends never
? {}
: (
| Expand<Omit<ReplaceOptionsFromFormats<TFormats>, 'count'>>
| {
replace: Expand<Omit<ReplaceOptionsFromFormats<TFormats>, 'count'>>;
}
) & {
formatParams?: Expand<ReplaceFormatParamsFromFormats<TFormats>>;
});
/**
* Helper type to only require options argument if needed
*
* @ignore
*/
type OptionArgs<TOptions extends {}> = keyof TOptions extends never
? [options?: BaseOptions]
: [options: BaseOptions & TOptions];
/**
* @ignore
*/
type TranslationFunctionOptions<
TKeys extends keyof TMessages, // All normalized message keys to be considered, i.e. included nested ones
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<
UnionToIntersection<ReplaceFormatsFromMessage<TMessages[TKeys]>>
>
>
>
>;
/** @alpha */
export type TranslationFunction<TMessages extends { [key in string]: string }> =
<TKey extends keyof TMessages>(
export interface TranslationFunction<
TMessages extends { [key in string]: string },
> {
<TKey extends keyof CollapsedMessages<TMessages>>(
key: TKey,
options?: TranslationOptions,
) => TMessages[TKey];
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, CollapsedMessages<TMessages>>,
PluralKeys<TMessages>,
CollapsedMessages<TMessages>
>
): CollapsedMessages<TMessages>[TKey];
}
/** @alpha */
export type TranslationSnapshot<TMessages extends { [key in string]: string }> =
@@ -17,7 +17,6 @@ export {
translationApiRef,
type TranslationApi,
type TranslationFunction,
type TranslationOptions,
type TranslationSnapshot,
} from './TranslationApi';
export { appLanguageApiRef, type AppLanguageApi } from './AppLanguageApi';
@@ -50,17 +50,21 @@ describe('MockTranslationApi', () => {
shallow: 'Foo {{ bar }}',
multiple: 'Foo {{ bar }} {{ baz }}',
deep: 'Foo {{ bar.baz }}',
});
} as const);
// @ts-expect-error
expect(snapshot.t('shallow')).toBe('Foo {{ bar }}');
expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar');
// @ts-expect-error
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
// @ts-expect-error
expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}');
expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe(
'Foo Bar Baz',
);
// @ts-expect-error
expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}');
expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz');
});
@@ -69,7 +73,7 @@ describe('MockTranslationApi', () => {
it('should not escape by default', () => {
const snapshot = snapshotWithMessages({
foo: 'Foo {{ foo }}',
});
} as const);
expect(snapshot.t('foo', { foo: '<div>' })).toBe('Foo <div>');
expect(
@@ -85,7 +89,7 @@ describe('MockTranslationApi', () => {
foo: 'Foo $t(bar) $t(baz)',
bar: 'Nested',
baz: 'Baz {{ qux }}',
});
} as const);
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
});
@@ -100,24 +104,36 @@ describe('MockTranslationApi', () => {
relativeSecondsShort:
'= {{ x, relativeTime(range: second; style: short) }}',
list: '= {{ x, list }}',
});
} as const);
expect(snapshot.t('plain', { x: 5 })).toBe('= 5');
expect(snapshot.t('plain', { x: '5' })).toBe('= 5');
expect(snapshot.t('number', { x: 5 })).toBe('= 5');
expect(snapshot.t('number', { x: 5, minimumFractionDigits: 1 })).toBe(
'= 5.0',
);
expect(
snapshot.t('number', {
x: 5,
formatParams: { x: { minimumFractionDigits: 1 } },
}),
).toBe('= 5.0');
expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
expect(snapshot.t('numberFixed', { x: 5, minimumFractionDigits: 3 })).toBe(
'= 5.000',
);
expect(
snapshot.t('numberFixed', {
x: 5,
formatParams: { x: { minimumFractionDigits: 3 } },
}),
).toBe('= 5.000');
expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
expect(snapshot.t('relativeTime', { x: 15, range: 'weeks' })).toBe(
'= in 15 weeks',
);
expect(
snapshot.t('relativeTime', { x: 15, range: 'weeks', style: 'short' }),
snapshot.t('relativeTime', {
x: 15,
formatParams: { x: { range: 'weeks' } },
}),
).toBe('= in 15 weeks');
expect(
snapshot.t('relativeTime', {
x: 15,
formatParams: { x: { range: 'weeks', style: 'short' } },
}),
).toBe('= in 15 wk.');
expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
@@ -138,14 +154,13 @@ describe('MockTranslationApi', () => {
derp_other: 'derps',
derpWithCount_one: '{{ count }} derp',
derpWithCount_other: '{{ count }} derps',
});
} as const);
// TODO(Rugvip): Support plural keys
expect(snapshot.t('derp' as any, { count: 1 })).toBe('derp');
expect(snapshot.t('derp' as any, { count: 2 })).toBe('derps');
expect(snapshot.t('derp' as any, { count: 0 })).toBe('derps');
expect(snapshot.t('derpWithCount' as any, { count: 1 })).toBe('1 derp');
expect(snapshot.t('derpWithCount' as any, { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount' as any, { count: 0 })).toBe('0 derps');
expect(snapshot.t('derp', { count: 1 })).toBe('derp');
expect(snapshot.t('derp', { count: 2 })).toBe('derps');
expect(snapshot.t('derp', { count: 0 })).toBe('derps');
expect(snapshot.t('derpWithCount', { count: 1 })).toBe('1 derp');
expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
});
});
@@ -16,6 +16,7 @@
import {
TranslationApi,
TranslationFunction,
TranslationRef,
TranslationSnapshot,
} from '@backstage/core-plugin-api/alpha';
@@ -66,8 +67,6 @@ export class MockTranslationApi implements TranslationApi {
): TranslationSnapshot<TMessages> {
const internalRef = toInternalTranslationRef(translationRef);
const t = this.#i18n.getFixedT(null, internalRef.id);
if (!this.#registeredRefs.has(internalRef.id)) {
this.#registeredRefs.add(internalRef.id);
this.#i18n.addResourceBundle(
@@ -79,11 +78,14 @@ export class MockTranslationApi implements TranslationApi {
);
}
const t = this.#i18n.getFixedT(
null,
internalRef.id,
) as TranslationFunction<TMessages>;
return {
ready: true,
t: (key, options) => {
return t(key as string, { ...options });
},
t,
};
}