From d43a9bbe1aeaad864a818c747681239c334525ad Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 15:15:33 +0200 Subject: [PATCH 01/10] core-plugin-api: initial translation function types Signed-off-by: Patrik Oldsberg --- .../apis/definitions/TranslationApi.test.ts | 215 ++++++++++++++++++ .../src/apis/definitions/TranslationApi.ts | 196 +++++++++++++++- 2 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts 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 = From eb96cadf2cf33cc61b4cddd66d6e5978fee5f229 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 15:44:23 +0200 Subject: [PATCH 02/10] core-plugin-api: update TranslationFunction to use explicit replace options Signed-off-by: Patrik Oldsberg --- .../apis/definitions/TranslationApi.test.ts | 116 +++++++++++------- .../src/apis/definitions/TranslationApi.ts | 21 +++- 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts index bc524de051..5aa61b9373 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -60,30 +60,40 @@ describe('TranslationFunction', () => { expect(f).toBeDefined(); // @ts-expect-error - f('none', { unknown: 1 }); - f('simple', { bar: '' }); + f('none', { replace: { unknown: 1 } }); + f('simple', { replace: { bar: '' } }); // @ts-expect-error f('simple'); // @ts-expect-error - f('simple', { wrong: '' }); - f('multiple', { bar: '', baz: '' }); + f('simple', {}); // @ts-expect-error - f('multiple', { bar: '' }); + f('simple', { replace: {} }); // @ts-expect-error - f('multiple', { baz: '' }); + f('simple', { replace: { wrong: '' } }); + f('multiple', { replace: { bar: '', baz: '' } }); + // @ts-expect-error + f('multiple', { replace: { bar: '' } }); + // @ts-expect-error + f('multiple', { replace: { baz: '' } }); // @ts-expect-error f('multiple'); // @ts-expect-error f('multiple', {}); - f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } }); + // @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', { x: { y: '', z: '' }, a: { b: '' } }); + f('deep', {}); // @ts-expect-error - f('deep', { x: { y: '', z: '' } }); + f('deep', { replace: {} }); // @ts-expect-error - f('deep', { a: { b: { c: '' } } }); + f('deep', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); + // @ts-expect-error + f('deep', { replace: { x: { y: '', z: '' } } }); + // @ts-expect-error + f('deep', { replace: { a: { b: { c: '' } } } }); }); it('should infer interpolation params with count', () => { @@ -97,39 +107,42 @@ describe('TranslationFunction', () => { }>; expect(f).toBeDefined(); - f('simple', { bar: '', count: 1 }); + f('simple', { count: 1, replace: { bar: '' } }); // @ts-expect-error - f('simple', { bar: '' }); + f('simple', { replace: { bar: '' } }); // @ts-expect-error - f('simple', { count: 1 }); + f('simple', { count: 1, replace: {} }); // @ts-expect-error - f('simple'); + f('simple', { replace: {} }); // @ts-expect-error - f('simple', { wrong: '' }); - f('multiple', { count: 2, bar: '', baz: '' }); + f('simple', { replace: { wrong: '' } }); + f('multiple', { count: 2, replace: { bar: '', baz: '' } }); // @ts-expect-error - f('multiple', { count: 2 }); + f('multiple', { count: 2, replace: {} }); // @ts-expect-error - f('multiple', { bar: '', baz: '' }); + f('multiple', { replace: { bar: '', baz: '' } }); // @ts-expect-error - f('multiple', { baz: '' }); + f('multiple', { replace: { baz: '' } }); // @ts-expect-error - f('multiple'); + f('multiple', { replace: {} }); // @ts-expect-error f('multiple', {}); - f('deep', { count: 1, x: { y: '', z: '' }, a: { b: { c: '' } } }); + 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: '' } } }); + f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }); // @ts-expect-error - f('deep'); + f('deep', { replace: {} }); // @ts-expect-error - f('deep', { x: { y: '', z: '' }, a: { b: '' } }); + f('deep', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); // @ts-expect-error - f('deep', { x: { y: '', z: '' } }); + f('deep', { replace: { x: { y: '', z: '' } } }); // @ts-expect-error - f('deep', { a: { b: { c: '' } } }); + f('deep', { replace: { a: { b: { c: '' } } } }); }); it('should ignore formatting', () => { @@ -142,30 +155,34 @@ describe('TranslationFunction', () => { expect(f).toBeDefined(); // @ts-expect-error - f('none', { unknown: 1 }); - f('simple', { bar: '' }); + f('none', { replace: { unknown: 1 } }); + f('simple', { replace: { bar: '' } }); // @ts-expect-error f('simple'); // @ts-expect-error - f('simple', { wrong: '' }); - f('multiple', { bar: '', baz: '' }); + f('simple', { replace: {} }); // @ts-expect-error - f('multiple', { bar: '' }); + f('simple', { replace: { wrong: '' } }); + f('multiple', { replace: { bar: '', baz: '' } }); // @ts-expect-error - f('multiple', { baz: '' }); + f('multiple', { replace: { bar: '' } }); + // @ts-expect-error + f('multiple', { replace: { baz: '' } }); // @ts-expect-error f('multiple'); // @ts-expect-error f('multiple', {}); - f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } }); + // @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', { x: { y: '', z: '' }, a: { b: '' } }); + f('deep', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); // @ts-expect-error - f('deep', { x: { y: '', z: '' } }); + f('deep', { replace: { x: { y: '', z: '' } } }); // @ts-expect-error - f('deep', { a: { b: { c: '' } } }); + f('deep', { replace: { a: { b: { c: '' } } } }); }); it('should support nesting', () => { @@ -183,17 +200,24 @@ describe('TranslationFunction', () => { 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' }); + f('nested', { replace: { 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', { bar: 'bar', baz: 'baz', qux: 'qux' }); + f('deep', { count: 1, replace: { baz: 'baz', qux1: 'qux', qux2: 'qux' } }); // @ts-expect-error - f('deep', { bar: 'bar', baz: 'baz', count: 1 }); + f('deep', { count: 1, replace: { bar: 'bar', qux1: 'qux', qux2: 'qux' } }); // @ts-expect-error - f('deep', { bar: 'bar', qux: 'qux', count: 1 }); + f('deep', { count: 1, replace: { bar: 'bar', baz: 'baz', qux2: 'qux' } }); // @ts-expect-error - f('deep', { baz: 'baz', qux: 'qux', count: 1 }); + f('deep', { count: 1, replace: { 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', () => { @@ -206,10 +230,10 @@ describe('TranslationFunction', () => { }>; expect(f).toBeDefined(); - f('a', { a: '', b: '', c: '', d: '' }); + f('a', { replace: { a: '', b: '', c: '', d: '' } }); // @ts-expect-error - f('a', { a: '', b: '', c: '' }); + f('a', { replace: { a: '', b: '', c: '' } }); // @ts-expect-error - f('a', { a: '', b: '', c: '', d: '', e: '' }); + 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 e3b68c5308..ae23947390 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -165,16 +165,27 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( * * @internal */ -type MessageOptionsFromKeys = UnionToIntersection< +type ReplaceOptionsFromKeys = UnionToIntersection< TKeys extends `${infer Prefix}.${infer Rest}` ? { - [key in Prefix]: MessageOptionsFromKeys; + [key in Prefix]: ReplaceOptionsFromKeys; } : { [key in TKeys & string]: string | number | string[]; } >; +/** + * Collects different types of options into a single object + * + * @internal + */ +type CollectOptions< + TCount extends { count?: number }, + TReplaceOptions extends {}, +> = TCount & + (keyof TReplaceOptions extends never ? {} : { replace: TReplaceOptions }); + /** * Helper type to only require options argument if needed * @@ -191,10 +202,12 @@ type TranslationFunctionOptions< TMessages extends { [key in string]: string }, // Collapsed message map with normalized keys and union values > = OptionArgs< Expand< - (TKeys & TPluralKeys extends never ? {} : { count: number }) & + CollectOptions< + TKeys & TPluralKeys extends never ? {} : { count: number }, ExpandRecursive< - MessageOptionsFromKeys> + ReplaceOptionsFromKeys> > + > > >; From b93080a533fa3ce8104dcdee0e37de7b3cafb591 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 16:49:02 +0200 Subject: [PATCH 03/10] core-plugin-api: implemented typed formatting options for TranslationFunction Signed-off-by: Patrik Oldsberg --- .../apis/definitions/TranslationApi.test.ts | 62 ++++---- .../src/apis/definitions/TranslationApi.ts | 149 +++++++++++++----- 2 files changed, 143 insertions(+), 68 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts index 5aa61b9373..b4233c9215 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -145,44 +145,52 @@ describe('TranslationFunction', () => { f('deep', { replace: { a: { b: { c: '' } } } }); }); - it('should ignore formatting', () => { + it('should support 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 }}'; + 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', { replace: { x: 1 } }); + f('number', { + replace: { x: 1 }, + formatParams: { x: { minimumFractionDigits: 2 } }, + }); + f('numberOptions', { replace: { x: 1 } }); + f('currency', { replace: { x: 1 } }); + f('datetime', { replace: { 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', { replace: { unknown: 1 } }); - f('simple', { replace: { bar: '' } }); + f('none', { replace: { x: 1 } }); // @ts-expect-error - f('simple'); + f('number', { replace: { x: '1' } }); // @ts-expect-error - f('simple', { replace: {} }); + f('numberOptions', { replace: { x: '1' } }); // @ts-expect-error - f('simple', { replace: { wrong: '' } }); - f('multiple', { replace: { bar: '', baz: '' } }); + f('currency', { replace: { x: '1' } }); // @ts-expect-error - f('multiple', { replace: { bar: '' } }); + f('datetime', { replace: { x: '1' } }); // @ts-expect-error - f('multiple', { replace: { baz: '' } }); + f('relativeTimeOptions', { replace: { x: '1' } }); + f('relativeTimeOptions', { + replace: { x: 1 }, + // @ts-expect-error + formatParams: { x: { minimumFractionDigits: 2 } }, + }); // @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', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); - // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' } } }); - // @ts-expect-error - f('deep', { replace: { a: { b: { c: '' } } } }); + f('list', { replace: { x: [1, 2, 3] } }); }); it('should support nesting', () => { diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index ae23947390..60fff9b75e 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -24,7 +24,10 @@ import { TranslationRef } from '../../translation'; * @alpha */ interface BaseOptions { - interpolation?: {}; + interpolation?: { + /** Whether to HTML escape provided values, defaults to false */ + escapeValue?: boolean; + }; } /** @@ -32,13 +35,34 @@ interface BaseOptions { * * @internal */ -export type TranslationPlural = - | 'zero' - | 'one' - | 'two' - | 'few' - | 'many' - | 'other'; +type TranslationPlural = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +/** + * A mapping of i18n formatting types to their corresponding types and options. + * @internal + */ +type I18nextFormatMap = { + number: { + type: number; + options: Intl.NumberFormatOptions; + }; + currency: { + type: number; + options: Intl.NumberFormatOptions; + }; + datetime: { + type: Date; + options: Intl.DateTimeFormatOptions; + }; + relativetime: { + type: number; + options: Intl.RelativeTimeFormatOptions; + }; + list: { + type: string[]; + options: Intl.ListFormatOptions; + }; +}; /** * Extracts all pluralized keys from the message map. @@ -92,18 +116,75 @@ type Trim = T extends ` ${infer U}` : T; /** - * Extracts all option keys from a message string. + * Extracts the key and format from a replacement string. * - * e.g. 'foo {{bar}} {{baz, number}}' -> 'bar' | 'baz' + * e.g. '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. + * + * e.g. { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' } * * @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; +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. + * + * e.g. 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' } + * + * @internal + */ +type ReplaceFormatsFromMessage = + TMessage extends `${string}{{${infer Replacement}}}${infer Tail}` // no formatting, e.g. {{foo}} + ? ExpandKeys> & ReplaceFormatsFromMessage + : {}; + +/** + * Generates the replace options structure + * + * @internal + */ +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 + * + * @internal + */ +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. @@ -158,23 +239,6 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( ? I : never; -/** - * Extracts all options from a message string. - * - * e.g. 'foo {{bar}} {{x.y.z}}' -> { bar: , x: { y: { z: } } } - * - * @internal - */ -type ReplaceOptionsFromKeys = UnionToIntersection< - TKeys extends `${infer Prefix}.${infer Rest}` - ? { - [key in Prefix]: ReplaceOptionsFromKeys; - } - : { - [key in TKeys & string]: string | number | string[]; - } ->; - /** * Collects different types of options into a single object * @@ -182,9 +246,14 @@ type ReplaceOptionsFromKeys = UnionToIntersection< */ type CollectOptions< TCount extends { count?: number }, - TReplaceOptions extends {}, + TFormats extends {}, > = TCount & - (keyof TReplaceOptions extends never ? {} : { replace: TReplaceOptions }); + (keyof TFormats extends never + ? {} + : { + replace: Expand>; + formatParams?: Expand>; + }); /** * Helper type to only require options argument if needed @@ -201,12 +270,10 @@ type TranslationFunctionOptions< 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< - ReplaceOptionsFromKeys> - > + CollectOptions< + TKeys & TPluralKeys extends never ? {} : { count: number }, + ExpandRecursive< + UnionToIntersection> > > >; From 0f83314498603508693979d34e76ca5bd8508f50 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 16:56:54 +0200 Subject: [PATCH 04/10] core-plugin-api: stop forcing use of replace for TranslationFunction Signed-off-by: Patrik Oldsberg --- .../apis/definitions/TranslationApi.test.ts | 48 +++++++++---------- .../src/apis/definitions/TranslationApi.ts | 6 ++- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts index b4233c9215..e231659e38 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -61,7 +61,7 @@ describe('TranslationFunction', () => { // @ts-expect-error f('none', { replace: { unknown: 1 } }); - f('simple', { replace: { bar: '' } }); + f('simple', { bar: '' }); // @ts-expect-error f('simple'); // @ts-expect-error @@ -70,11 +70,11 @@ describe('TranslationFunction', () => { f('simple', { replace: {} }); // @ts-expect-error f('simple', { replace: { wrong: '' } }); - f('multiple', { replace: { bar: '', baz: '' } }); + f('multiple', { bar: '', baz: '' }); // @ts-expect-error - f('multiple', { replace: { bar: '' } }); + f('multiple', { bar: '' }); // @ts-expect-error - f('multiple', { replace: { baz: '' } }); + f('multiple', { baz: '' }); // @ts-expect-error f('multiple'); // @ts-expect-error @@ -89,9 +89,9 @@ describe('TranslationFunction', () => { // @ts-expect-error f('deep', { replace: {} }); // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); + f('deep', { x: { y: '', z: '' }, a: { b: '' } }); // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' } } }); + f('deep', { x: { y: '', z: '' } }); // @ts-expect-error f('deep', { replace: { a: { b: { c: '' } } } }); }); @@ -107,9 +107,9 @@ describe('TranslationFunction', () => { }>; expect(f).toBeDefined(); - f('simple', { count: 1, replace: { bar: '' } }); + f('simple', { count: 1, bar: '' }); // @ts-expect-error - f('simple', { replace: { bar: '' } }); + f('simple', { bar: '' }); // @ts-expect-error f('simple', { count: 1, replace: {} }); // @ts-expect-error @@ -134,13 +134,13 @@ describe('TranslationFunction', () => { // @ts-expect-error f('deep', { count: 1 }); // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }); + f('deep', { x: { y: '', z: '' }, a: { b: { c: '' } } }); // @ts-expect-error f('deep', { replace: {} }); // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' }, a: { b: '' } } }); + f('deep', { x: { y: '', z: '' }, a: { b: '' } }); // @ts-expect-error - f('deep', { replace: { x: { y: '', z: '' } } }); + f('deep', { x: { y: '', z: '' } }); // @ts-expect-error f('deep', { replace: { a: { b: { c: '' } } } }); }); @@ -158,14 +158,14 @@ describe('TranslationFunction', () => { expect(f).toBeDefined(); f('none', { replace: { x: 'x' } }); - f('number', { replace: { x: 1 } }); + f('number', { x: 1 }); f('number', { replace: { x: 1 }, formatParams: { x: { minimumFractionDigits: 2 } }, }); - f('numberOptions', { replace: { x: 1 } }); + f('numberOptions', { x: 1 }); f('currency', { replace: { x: 1 } }); - f('datetime', { replace: { x: new Date() } }); + f('datetime', { x: new Date() }); f('relativeTimeOptions', { replace: { x: 1 } }); f('relativeTimeOptions', { replace: { x: 1 }, @@ -173,24 +173,24 @@ describe('TranslationFunction', () => { }); f('list', { replace: { x: ['a', 'b', 'c'] } }); // @ts-expect-error - f('none', { replace: { x: 1 } }); + f('none', { x: 1 }); // @ts-expect-error f('number', { replace: { x: '1' } }); // @ts-expect-error - f('numberOptions', { replace: { x: '1' } }); + f('numberOptions', { x: '1' }); // @ts-expect-error - f('currency', { replace: { x: '1' } }); + f('currency', { x: '1' }); // @ts-expect-error f('datetime', { replace: { x: '1' } }); // @ts-expect-error - f('relativeTimeOptions', { replace: { x: '1' } }); + f('relativeTimeOptions', { x: '1' }); f('relativeTimeOptions', { replace: { x: 1 }, // @ts-expect-error formatParams: { x: { minimumFractionDigits: 2 } }, }); // @ts-expect-error - f('list', { replace: { x: [1, 2, 3] } }); + f('list', { x: [1, 2, 3] }); }); it('should support nesting', () => { @@ -208,20 +208,20 @@ describe('TranslationFunction', () => { expect(f).toBeDefined(); f('simple'); - f('nested', { replace: { bar: 'bar' } }); + 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, replace: { baz: 'baz', qux1: 'qux', qux2: 'qux' } }); + 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, replace: { bar: 'bar', baz: 'baz', qux2: 'qux' } }); + f('deep', { count: 1, bar: 'bar', baz: 'baz', qux2: 'qux' }); // @ts-expect-error - f('deep', { count: 1, replace: { bar: 'bar', baz: 'baz', qux1: 'qux' } }); + f('deep', { count: 1, bar: 'bar', baz: 'baz', qux1: 'qux' }); // @ts-expect-error f('deep', { replace: { bar: 'bar', baz: 'baz', qux1: 'qux', qux2: 'qux' }, @@ -240,7 +240,7 @@ describe('TranslationFunction', () => { f('a', { replace: { a: '', b: '', c: '', d: '' } }); // @ts-expect-error - f('a', { replace: { a: '', b: '', c: '' } }); + 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 60fff9b75e..a8aa03ef72 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -250,8 +250,10 @@ type CollectOptions< > = TCount & (keyof TFormats extends never ? {} - : { - replace: Expand>; + : ( + | Expand> + | { replace: Expand> } + ) & { formatParams?: Expand>; }); From e43544013cfd634b666c0950cd95f8d6df6c2976 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 17:06:05 +0200 Subject: [PATCH 05/10] core-plugin-api: special handling of count for TranslationFunction Signed-off-by: Patrik Oldsberg --- .../src/apis/definitions/TranslationApi.test.ts | 17 +++++++++++++++++ .../src/apis/definitions/TranslationApi.ts | 9 ++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts index e231659e38..ba5d43e896 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -23,6 +23,8 @@ describe('TranslationFunction', () => { const f = (() => {}) as TranslationFunction<{ key_one: 'one'; key_other: 'other'; + thingCount_one: '{{count}} thing'; + thingCount_other: '{{count}} things'; foo: 'foo'; }>; expect(f).toBeDefined(); @@ -30,6 +32,7 @@ describe('TranslationFunction', () => { f('foo'); // @ts-expect-error f('foo', { count: 1 }); + f('key', { count: 1 }); // @ts-expect-error f('key'); @@ -44,6 +47,20 @@ describe('TranslationFunction', () => { // @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 }); diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index a8aa03ef72..3c5402e684 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -248,11 +248,14 @@ type CollectOptions< TCount extends { count?: number }, TFormats extends {}, > = TCount & - (keyof TFormats extends never + // count is special, omit it from the replacements + (keyof Omit extends never ? {} : ( - | Expand> - | { replace: Expand> } + | Expand, 'count'>> + | { + replace: Expand, 'count'>>; + } ) & { formatParams?: Expand>; }); From 0e7e06952841952851466629d99eb665cbcec963 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 17:12:26 +0200 Subject: [PATCH 06/10] core-plugin-api: add range to relativetime formatting options for TranslationFunction Signed-off-by: Patrik Oldsberg --- .../core-plugin-api/src/apis/definitions/TranslationApi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index 3c5402e684..d96f37633b 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -56,7 +56,9 @@ type I18nextFormatMap = { }; relativetime: { type: number; - options: Intl.RelativeTimeFormatOptions; + options: { + range?: Intl.RelativeTimeFormatUnit; + } & Intl.RelativeTimeFormatOptions; }; list: { type: string[]; From 20293c57b25351ef176c979e194bf7a08e33861f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 17:13:01 +0200 Subject: [PATCH 07/10] core-app-api: update to use new TranslationFunction type Signed-off-by: Patrik Oldsberg --- .../I18nextTranslationApi.test.ts | 95 ++++++++++++------- .../TranslationApi/I18nextTranslationApi.ts | 10 +- 2 files changed, 67 insertions(+), 38 deletions(-) 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, }; } From aa53609ecd7dfa462acae5acfb4196dcdcebde93 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 18:00:18 +0200 Subject: [PATCH 08/10] core-plugin-api: TranslationApi export and API cleanup Signed-off-by: Patrik Oldsberg --- .../src/apis/definitions/TranslationApi.ts | 84 +++++++++++++------ .../src/apis/definitions/alpha.ts | 1 - 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index d96f37633b..202b2093a0 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -33,13 +33,13 @@ interface BaseOptions { /** * All pluralization suffixes supported by i18next * - * @internal + * @ignore */ type TranslationPlural = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; /** * A mapping of i18n formatting types to their corresponding types and options. - * @internal + * @ignore */ type I18nextFormatMap = { number: { @@ -69,9 +69,12 @@ type I18nextFormatMap = { /** * Extracts all pluralized keys from the message map. * - * e.g. { foo: 'foo', bar_one: 'bar', bar_other: 'bars' } -> 'bar' + * @example + * ``` + * { foo: 'foo', bar_one: 'bar', bar_other: 'bars' } -> 'bar' + * ``` * - * @internal + * @ignore */ type PluralKeys = { [Key in keyof TMessages]: Key extends `${infer K}_${TranslationPlural}` @@ -82,9 +85,12 @@ type PluralKeys = { /** * Collapses a message map into normalized keys with union values. * - * e.g. { foo_one: 'foo', foo_other: 'foos' } -> { foo: 'foo' | 'foos' } + * @example + * ``` + * { foo_one: 'foo', foo_other: 'foos' } -> { foo: 'foo' | 'foos' } + * ``` * - * @internal + * @ignore */ type CollapsedMessages = { [key in keyof TMessages as key extends `${infer K}_${TranslationPlural}` @@ -94,12 +100,14 @@ type CollapsedMessages = { /** * 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 @@ -109,7 +117,7 @@ type ExpandRecursive = T extends infer O /** * Trim away whitespace * - * @internal + * @ignore */ type Trim = T extends ` ${infer U}` ? Trim @@ -120,7 +128,10 @@ type Trim = T extends ` ${infer U}` /** * Extracts the key and format from a replacement string. * - * e.g. 'foo, number' -> { foo: number }, 'foo' -> { foo: undefined } + * @example + * ``` + * 'foo, number' -> { foo: number }, 'foo' -> { foo: undefined } + * ``` */ type ExtractFormat = Replacement extends `${infer Key},${infer FullFormat}` @@ -138,9 +149,12 @@ type ExtractFormat = /** * Expand the keys in a flat map to nested objects. * - * e.g. { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' } + * @example + * ``` + * { 'a.b': 'foo', 'a.c': 'bar' } -> { a: { b: 'foo', c: 'bar' } + * ``` * - * @internal + * @ignore */ type ExpandKeys = { [Key in keyof TMap as Key extends `${infer Prefix}.${string}` @@ -153,9 +167,12 @@ type ExpandKeys = { /** * Extracts all option keys and their format from a message string. * - * e.g. 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' } + * @example + * ``` + * 'foo {{bar}} {{baz, number}}' -> { 'bar': undefined, 'baz': 'number' } + * ``` * - * @internal + * @ignore */ type ReplaceFormatsFromMessage = TMessage extends `${string}{{${infer Replacement}}}${infer Tail}` // no formatting, e.g. {{foo}} @@ -165,7 +182,7 @@ type ReplaceFormatsFromMessage = /** * Generates the replace options structure * - * @internal + * @ignore */ type ReplaceOptionsFromFormats = { [Key in keyof TFormats]: TFormats[Key] extends keyof I18nextFormatMap @@ -178,7 +195,7 @@ type ReplaceOptionsFromFormats = { /** * Generates the formatParams options structure * - * @internal + * @ignore */ type ReplaceFormatParamsFromFormats = { [Key in keyof TFormats]?: TFormats[Key] extends keyof I18nextFormatMap @@ -191,9 +208,12 @@ type ReplaceFormatParamsFromFormats = { /** * Extracts all nesting keys from a message string. * - * e.g. 'foo $t(bar) $t(baz)' -> 'bar' | 'baz' + * @example + * ``` + * 'foo $t(bar) $t(baz)' -> 'bar' | 'baz' + * ``` * - * @internal + * @ignore */ type NestingKeysFromMessage = TMessage extends `${string}$t(${infer Key})${infer Tail}` // nesting options are not supported @@ -205,9 +225,12 @@ type NestingKeysFromMessage = * * 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' + * @example + * ``` + * <'x', { x: '$t(y) $t(z)', y: 'y', z: '$t(w)', w: 'w', foo: 'foo' }> -> 'x' | 'y' | 'z' | 'w' + * ``` * - * @internal + * @ignore */ type NestedMessageKeys< TKey extends keyof TMessages, @@ -231,9 +254,12 @@ type NestedMessageKeys3< /** * Converts a union type to an intersection type. * - * e.g. { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' } + * @example + * ``` + * { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' } + * ``` * - * @internal + * @ignore */ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( k: infer I, @@ -244,7 +270,7 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( /** * Collects different types of options into a single object * - * @internal + * @ignore */ type CollectOptions< TCount extends { count?: number }, @@ -265,22 +291,26 @@ type CollectOptions< /** * Helper type to only require options argument if needed * - * @internal + * @ignore */ type OptionArgs = keyof TOptions extends never ? [options?: BaseOptions] : [options: BaseOptions & TOptions]; -/** @ignore */ +/** + * @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< - CollectOptions< - TKeys & TPluralKeys extends never ? {} : { count: number }, - ExpandRecursive< - UnionToIntersection> + Expand< + CollectOptions< + TKeys & TPluralKeys extends never ? {} : { count: number }, + ExpandRecursive< + UnionToIntersection> + > > > >; 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'; From 3079cc8930684a33e59d5f4933edd280fdeb1b84 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 18:01:01 +0200 Subject: [PATCH 09/10] test-utils: TranslationApi type updates Signed-off-by: Patrik Oldsberg --- .../TranslationApi/MockTranslationApi.test.ts | 61 ++++++++++++------- .../apis/TranslationApi/MockTranslationApi.ts | 12 ++-- 2 files changed, 45 insertions(+), 28 deletions(-) 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, }; } From 43ed18e9d988c17a04d7dbc1ae9a3cd9638a76ca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 Sep 2023 18:03:10 +0200 Subject: [PATCH 10/10] core-plugin-api: API report update for TranslationFunction Signed-off-by: Patrik Oldsberg --- packages/core-plugin-api/alpha-api-report.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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,