Merge pull request #19968 from backstage/rugvip/i18n-types
core-plugin-api: type inference of translations message options
This commit is contained in:
+61
-34
@@ -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 <div>');
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-4
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user