core-plugin-api: initial translation function types

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-15 15:15:33 +02:00
parent f31de6be7c
commit d43a9bbe1a
2 changed files with 404 additions and 7 deletions
@@ -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: '' });
});
});
@@ -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<TMessages extends { [key in string]: string }> = {
[Key in keyof TMessages]: Key extends `${infer K}_${TranslationPlural}`
? K
: never;
}[keyof TMessages];
/**
* Collapses a message map into normalized keys with union values.
*
* e.g. { foo_one: 'foo', foo_other: 'foos' } -> { foo: 'foo' | 'foos' }
*
* @internal
*/
type CollapsedMessages<TMessages extends { [key in string]: string }> = {
[key in keyof TMessages as key extends `${infer K}_${TranslationPlural}`
? K
: key]: TMessages[key];
};
/**
* Helper type that expands type hints
* @ignore
*/
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
/**
* Helper type that expands type hints recursively
* @ignore
*/
type ExpandRecursive<T> = T extends infer O
? { [K in keyof O]: ExpandRecursive<O[K]> }
: never;
/**
* Trim away whitespace
*
* @internal
*/
type Trim<T> = T extends ` ${infer U}`
? Trim<U>
: T extends `${infer U} `
? Trim<U>
: T;
/**
* Extracts all option keys from a message string.
*
* e.g. 'foo {{bar}} {{baz, number}}' -> 'bar' | 'baz'
*
* @internal
*/
type OptionKeysFromMessage<TMessage> =
TMessage extends `${string}{{${infer Key},${string}}}${infer Tail}` // ignore formatting, e.g. {{foo, number}}
? Trim<Key> | OptionKeysFromMessage<Tail>
: TMessage extends `${string}{{${infer Key}}}${infer Tail}` // no formatting, e.g. {{foo}}
? Trim<Key> | OptionKeysFromMessage<Tail>
: never;
/**
* Extracts all nesting keys from a message string.
*
* e.g. 'foo $t(bar) $t(baz)' -> 'bar' | 'baz'
*
* @internal
*/
type NestingKeysFromMessage<TMessage extends string> =
TMessage extends `${string}$t(${infer Key})${infer Tail}` // nesting options are not supported
? Trim<Key> | NestingKeysFromMessage<Tail>
: never;
/**
* Find all referenced keys, given a starting key and the full set of messages.
*
* This will only discover keys up to 3 levels deep.
*
* 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<NestingKeysFromMessage<TMessages[TKey]>, TMessages>;
// Can't recursively reference ourself, so instead we got this beauty
type NestedMessageKeys2<
TKey extends keyof TMessages,
TMessages extends { [key in string]: string },
> =
| TKey
| NestedMessageKeys3<NestingKeysFromMessage<TMessages[TKey]>, TMessages>;
// Only support 3 levels of nesting
type NestedMessageKeys3<
TKey extends keyof TMessages,
TMessages extends { [key in string]: string },
> = TKey | NestingKeysFromMessage<TMessages[TKey]>;
/**
* Converts a union type to an intersection type.
*
* e.g. { foo: 'foo' } | { bar: 'bar' } -> { foo: 'foo' } & { bar: 'bar' }
*
* @internal
*/
type UnionToIntersection<U> = (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: <value>, x: { y: { z: <value> } } }
*
* @internal
*/
type MessageOptionsFromKeys<TKeys extends string> = UnionToIntersection<
TKeys extends `${infer Prefix}.${infer Rest}`
? {
[key in Prefix]: MessageOptionsFromKeys<Rest>;
}
: {
[key in TKeys & string]: string | number | string[];
}
>;
/**
* Helper type to only require options argument if needed
*
* @internal
*/
type OptionArgs<TOptions extends {}> = keyof TOptions extends never
? [options?: BaseOptions]
: [options: BaseOptions & TOptions];
/** @ignore */
type TranslationFunctionOptions<
TKeys extends keyof TMessages, // All normalized message keys to be considered, i.e. included nested ones
TPluralKeys extends keyof TMessages, // All keys in the message map that are pluralized
TMessages extends { [key in string]: string }, // Collapsed message map with normalized keys and union values
> = OptionArgs<
Expand<
(TKeys & TPluralKeys extends never ? {} : { count: number }) &
ExpandRecursive<
MessageOptionsFromKeys<OptionKeysFromMessage<TMessages[TKeys]>>
>
>
>;
/** @alpha */
export type TranslationFunction<TMessages extends { [key in string]: string }> =
<TKey extends keyof TMessages>(
export interface TranslationFunction<
TMessages extends { [key in string]: string },
> {
<TKey extends keyof CollapsedMessages<TMessages>>(
key: TKey,
options?: TranslationOptions,
) => TMessages[TKey];
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, CollapsedMessages<TMessages>>,
PluralKeys<TMessages>,
CollapsedMessages<TMessages>
>
): CollapsedMessages<TMessages>[TKey];
}
/** @alpha */
export type TranslationSnapshot<TMessages extends { [key in string]: string }> =