' },
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');
});
});
});
diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
index 49fb12de46..01e75d6df2 100644
--- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
+++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
@@ -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
;
return {
ready: true,
- t: (key, options) => {
- return t(key as string, { ...options });
- },
+ t,
};
}
diff --git a/packages/core-plugin-api/alpha-api-report.md b/packages/core-plugin-api/alpha-api-report.md
index d689184083..bea27cfa5e 100644
--- a/packages/core-plugin-api/alpha-api-report.md
+++ b/packages/core-plugin-api/alpha-api-report.md
@@ -107,14 +107,21 @@ export type TranslationApi = {
export const translationApiRef: ApiRef;
// @alpha (undocumented)
-export type TranslationFunction<
+export interface TranslationFunction<
TMessages extends {
[key in string]: string;
},
-> = (
- key: TKey,
- options?: TranslationOptions,
-) => TMessages[TKey];
+> {
+ // (undocumented)
+ >(
+ key: TKey,
+ ...[args]: TranslationFunctionOptions<
+ NestedMessageKeys>,
+ PluralKeys,
+ CollapsedMessages
+ >
+ ): CollapsedMessages[TKey];
+}
// @alpha
export interface TranslationMessages<
@@ -155,9 +162,6 @@ export interface TranslationMessagesOptions<
ref: TranslationRef_2;
}
-// @alpha (undocumented)
-export interface TranslationOptions {}
-
// @alpha (undocumented)
export interface TranslationRef<
TId extends string = string,
diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts
new file mode 100644
index 0000000000..ba5d43e896
--- /dev/null
+++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts
@@ -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: '' } });
+ });
+});
diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts
index a77d471945..202b2093a0 100644
--- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts
+++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts
@@ -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 = {
+ [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 = {
+ [key in keyof TMessages as key extends `${infer K}_${TranslationPlural}`
+ ? K
+ : key]: TMessages[key];
+};
+
+/**
+ * Helper type that expands type hints
+ *
+ * @ignore
+ */
+type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never;
+
+/**
+ * Helper type that expands type hints recursively
+ *
+ * @ignore
+ */
+type ExpandRecursive = T extends infer O
+ ? { [K in keyof O]: ExpandRecursive }
+ : never;
+
+/**
+ * Trim away whitespace
+ *
+ * @ignore
+ */
+type Trim = T extends ` ${infer U}`
+ ? Trim
+ : T extends `${infer U} `
+ ? Trim
+ : T;
+
+/**
+ * Extracts the key and format from a replacement string.
+ *
+ * @example
+ * ```
+ * '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.
+ *
+ * @example
+ * ```
+ * { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' }
+ * ```
+ *
+ * @ignore
+ */
+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.
+ *
+ * @example
+ * ```
+ * 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' }
+ * ```
+ *
+ * @ignore
+ */
+type ReplaceFormatsFromMessage =
+ TMessage extends `${string}{{${infer Replacement}}}${infer Tail}` // no formatting, e.g. {{foo}}
+ ? ExpandKeys> & ReplaceFormatsFromMessage
+ : {};
+
+/**
+ * Generates the replace options structure
+ *
+ * @ignore
+ */
+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
+ *
+ * @ignore
+ */
+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.
+ *
+ * @example
+ * ```
+ * 'foo $t(bar) $t(baz)' -> 'bar' | 'baz'
+ * ```
+ *
+ * @ignore
+ */
+type NestingKeysFromMessage =
+ TMessage extends `${string}$t(${infer Key})${infer Tail}` // nesting options are not supported
+ ? Trim | NestingKeysFromMessage
+ : 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, 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, TMessages>;
+// Only support 3 levels of nesting
+type NestedMessageKeys3<
+ TKey extends keyof TMessages,
+ TMessages extends { [key in string]: string },
+> = TKey | NestingKeysFromMessage;
+
+/**
+ * Converts a union type to an intersection type.
+ *
+ * @example
+ * ```
+ * { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' }
+ * ```
+ *
+ * @ignore
+ */
+type UnionToIntersection = (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 extends never
+ ? {}
+ : (
+ | Expand, 'count'>>
+ | {
+ replace: Expand, 'count'>>;
+ }
+ ) & {
+ formatParams?: Expand>;
+ });
+
+/**
+ * Helper type to only require options argument if needed
+ *
+ * @ignore
+ */
+type OptionArgs = 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>
+ >
+ >
+ >
+>;
+
/** @alpha */
-export type TranslationFunction =
- (
+export interface TranslationFunction<
+ TMessages extends { [key in string]: string },
+> {
+ >(
key: TKey,
- options?: TranslationOptions,
- ) => TMessages[TKey];
+ ...[args]: TranslationFunctionOptions<
+ NestedMessageKeys>,
+ PluralKeys,
+ CollapsedMessages
+ >
+ ): CollapsedMessages[TKey];
+}
/** @alpha */
export type TranslationSnapshot =
diff --git a/packages/core-plugin-api/src/apis/definitions/alpha.ts b/packages/core-plugin-api/src/apis/definitions/alpha.ts
index e3179fd4b2..e7f6f13fef 100644
--- a/packages/core-plugin-api/src/apis/definitions/alpha.ts
+++ b/packages/core-plugin-api/src/apis/definitions/alpha.ts
@@ -17,7 +17,6 @@ export {
translationApiRef,
type TranslationApi,
type TranslationFunction,
- type TranslationOptions,
type TranslationSnapshot,
} from './TranslationApi';
export { appLanguageApiRef, type AppLanguageApi } from './AppLanguageApi';
diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts
index 6b6ef2eb13..45d23d9db2 100644
--- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts
+++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts
@@ -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: '' })).toBe('Foo
');
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');
});
});
diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts
index 0ffea6a1bd..608b634cc4 100644
--- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts
+++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts
@@ -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 {
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;
+
return {
ready: true,
- t: (key, options) => {
- return t(key as string, { ...options });
- },
+ t,
};
}