core-plugin-api: initial translation function types
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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 }> =
|
||||
|
||||
Reference in New Issue
Block a user