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..bc524de051 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -0,0 +1,215 @@ +/* + * 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'; + 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 }); + + 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', { unknown: 1 }); + f('simple', { bar: '' }); + // @ts-expect-error + f('simple'); + // @ts-expect-error + f('simple', { 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', {}); + f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } }); + // @ts-expect-error + f('deep'); + // @ts-expect-error + f('deep', { x: { y: '', z: '' }, a: { b: '' } }); + // @ts-expect-error + f('deep', { x: { y: '', z: '' } }); + // @ts-expect-error + f('deep', { 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', { bar: '', count: 1 }); + // @ts-expect-error + f('simple', { bar: '' }); + // @ts-expect-error + f('simple', { count: 1 }); + // @ts-expect-error + f('simple'); + // @ts-expect-error + f('simple', { wrong: '' }); + f('multiple', { count: 2, bar: '', baz: '' }); + // @ts-expect-error + f('multiple', { count: 2 }); + // @ts-expect-error + f('multiple', { bar: '', baz: '' }); + // @ts-expect-error + f('multiple', { baz: '' }); + // @ts-expect-error + f('multiple'); + // @ts-expect-error + f('multiple', {}); + f('deep', { count: 1, 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'); + // @ts-expect-error + f('deep', { x: { y: '', z: '' }, a: { b: '' } }); + // @ts-expect-error + f('deep', { x: { y: '', z: '' } }); + // @ts-expect-error + f('deep', { a: { b: { c: '' } } }); + }); + + it('should ignore formatting', () => { + const f = (() => {}) as TranslationFunction<{ + none: '='; + simple: '= {{bar, number}}'; + multiple: '= {{bar , whatever }} {{ baz, someFormat(pretty: "yes")}}'; + deep: '= {{x.y,derp}} {{ x.z, derp}} {{ a.b.c, derp }}'; + }>; + expect(f).toBeDefined(); + + // @ts-expect-error + f('none', { unknown: 1 }); + f('simple', { bar: '' }); + // @ts-expect-error + f('simple'); + // @ts-expect-error + f('simple', { 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', {}); + f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } }); + // @ts-expect-error + f('deep'); + // @ts-expect-error + f('deep', { x: { y: '', z: '' }, a: { b: '' } }); + // @ts-expect-error + f('deep', { x: { y: '', z: '' } }); + // @ts-expect-error + f('deep', { a: { b: { c: '' } } }); + }); + + 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, qux1: 'qux', qux2: 'qux' }); + f('deep', { count: 1, bar: 'bar', baz: 'baz', qux1: 'qux', qux2: 'qux' }); + // @ts-expect-error + f('deep', { bar: 'bar', baz: 'baz', qux: 'qux' }); + // @ts-expect-error + f('deep', { bar: 'bar', baz: 'baz', count: 1 }); + // @ts-expect-error + f('deep', { bar: 'bar', qux: 'qux', count: 1 }); + // @ts-expect-error + f('deep', { baz: 'baz', qux: 'qux', count: 1 }); + }); + + 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', { a: '', b: '', c: '', d: '' }); + // @ts-expect-error + f('a', { a: '', b: '', c: '' }); + // @ts-expect-error + f('a', { 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..e3b68c5308 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -18,17 +18,199 @@ 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?: {}; } +/** + * All pluralization suffixes supported by i18next + * + * @internal + */ +export type TranslationPlural = + | 'zero' + | 'one' + | 'two' + | 'few' + | 'many' + | 'other'; + +/** + * Extracts all pluralized keys from the message map. + * + * e.g. { foo: 'foo', bar_one: 'bar', bar_other: 'bars' } -> 'bar' + * + * @internal + */ +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. + * + * e.g. { foo_one: 'foo', foo_other: 'foos' } -> { foo: 'foo' | 'foos' } + * + * @internal + */ +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 + * + * @internal + */ +type Trim = T extends ` ${infer U}` + ? Trim + : T extends `${infer U} ` + ? Trim + : T; + +/** + * Extracts all option keys from a message string. + * + * e.g. 'foo {{bar}} {{baz, number}}' -> 'bar' | 'baz' + * + * @internal + */ +type OptionKeysFromMessage = + TMessage extends `${string}{{${infer Key},${string}}}${infer Tail}` // ignore formatting, e.g. {{foo, number}} + ? Trim | OptionKeysFromMessage + : TMessage extends `${string}{{${infer Key}}}${infer Tail}` // no formatting, e.g. {{foo}} + ? Trim | OptionKeysFromMessage + : never; + +/** + * Extracts all nesting keys from a message string. + * + * e.g. 'foo $t(bar) $t(baz)' -> 'bar' | 'baz' + * + * @internal + */ +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. + * + * e.g. <'x', { x: '$t(y) $t(z)', y: 'y', z: '$t(w)', w: 'w', foo: 'foo' }> -> 'x' | 'y' | 'z' | 'w' + * + * @internal + */ +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. + * + * e.g. { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' } + * + * @internal + */ +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +/** + * Extracts all options from a message string. + * + * e.g. 'foo {{bar}} {{x.y.z}}' -> { bar: , x: { y: { z: } } } + * + * @internal + */ +type MessageOptionsFromKeys = UnionToIntersection< + TKeys extends `${infer Prefix}.${infer Rest}` + ? { + [key in Prefix]: MessageOptionsFromKeys; + } + : { + [key in TKeys & string]: string | number | string[]; + } +>; + +/** + * Helper type to only require options argument if needed + * + * @internal + */ +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< + (TKeys & TPluralKeys extends never ? {} : { count: number }) & + ExpandRecursive< + MessageOptionsFromKeys> + > + > +>; + /** @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 =