diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts index 2b96f4cc4f..0605cb6f16 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts @@ -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: '
' })).toBe('Foo
'); + expect(snapshot.t('foo', { replace: { foo: '
' } })).toBe( + 'Foo
', + ); expect( snapshot.t('foo', { - foo: '
', + replace: { foo: '
' }, 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, }; }