core-{app,plugin}-api: switch i18n JSX support to no longer requrie explicit format
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Added support for `jsx` interpolation format for the `MockTranslationApi`.
|
||||
Added support for interpolating JSX elements with the `MockTranslationApi`.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Updated `I18nextTranslationApi` to support interpolation with the new `jsx` format.
|
||||
Updated `I18nextTranslationApi` to support interpolation of JSX elements.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Added a new `jsx` interpolation format to `TranslationsApi`. If any of the interpolations in the default translation message uses the `jsx` format, the translation function will always return a `ReactNode`.
|
||||
The `TranslationApi` now supports interpolation of JSX elements by passing them directly as values to the translation function. If any of the provided interpolation values are JSX elements, the translation function will return a JSX element instead of a string.
|
||||
|
||||
@@ -147,9 +147,9 @@ export const myPluginTranslationRef = createTranslationRef({
|
||||
});
|
||||
```
|
||||
|
||||
#### React Nodes
|
||||
#### JSX Elements
|
||||
|
||||
In addition to the default formats that `i18next` supports, you can also use the `jsx` format to specify that an interpolated value is a `ReactNode`.
|
||||
The translation API supports interpolation of JSX elements by passing them directly as values to the translation function. If any of the provided interpolation values are JSX elements, the translation function will return a JSX element instead of a string.
|
||||
|
||||
For example, you might define the following messages:
|
||||
|
||||
@@ -158,9 +158,10 @@ export const myPluginTranslationRef = createTranslationRef({
|
||||
id: 'plugin.my-plugin',
|
||||
messages: {
|
||||
entityPage: {
|
||||
redirect:
|
||||
'The entity you are looking for has been moved to {{link, jsx}}.',
|
||||
newLocation: 'new location',
|
||||
redirect: {
|
||||
message: 'The entity you are looking for has been moved to {{link}}.',
|
||||
link: 'new location',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -173,16 +174,14 @@ const { t } = useTranslationRef(myPluginTranslationRef);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{t('entityPage.redirect', {
|
||||
link: <a href="/new-location">{t('entityPage.newLocation')}</a>,
|
||||
{t('entityPage.redirect.message', {
|
||||
link: <a href="/new-location">{t('entityPage.redirect.link')}</a>,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
Note that whenever you use the `jsx` format in a message, the return value from the `t` function will be a `ReactNode`.
|
||||
|
||||
When overriding a message you must always keep the `jsx` format for any interpolated values that use it in the original message.
|
||||
The return type of the outer `t` function will be a `JSX.Element`, with the underlying value being a React fragment of the different parts of the message.
|
||||
|
||||
## For an application developer overwrite plugin messages
|
||||
|
||||
|
||||
+23
-18
@@ -540,9 +540,13 @@ describe('I18nextTranslationApi', () => {
|
||||
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
|
||||
});
|
||||
|
||||
it('should support jsx formatting', () => {
|
||||
it('should support jsx interpolation', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
jsx: '{{ hello, jsx }}, {{ world, jsx }}!',
|
||||
jsx: '{{ hello }}, {{ world }}!',
|
||||
jsxMultiple: '{{ hello }} | {{ hello }}',
|
||||
jsxNested: '$t(foo), $t(bar)',
|
||||
foo: 'foo={{ foo }}',
|
||||
bar: 'bar={{ bar }}',
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -563,25 +567,26 @@ describe('I18nextTranslationApi', () => {
|
||||
).container.textContent,
|
||||
).toBe('world, hello!');
|
||||
|
||||
expect(() =>
|
||||
snapshot.t('jsx', {
|
||||
world: <h6>World</h6>,
|
||||
} as any),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Translation options did not provide a JSX node for interpolation key 'hello'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support jsx formatting with nested interpolations', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
message: '$t(foo), $t(bar)',
|
||||
foo: 'foo={{ foo, jsx }}',
|
||||
bar: 'bar={{ bar, jsx }}',
|
||||
});
|
||||
// Missing value
|
||||
expect(
|
||||
render(
|
||||
snapshot.t('jsx', {
|
||||
hello: <h1>hello</h1>,
|
||||
} as any),
|
||||
).container.textContent,
|
||||
).toBe('hello, {{ world }}!');
|
||||
|
||||
expect(
|
||||
render(
|
||||
snapshot.t('message', {
|
||||
snapshot.t('jsxMultiple', {
|
||||
hello: <h1>hello</h1>,
|
||||
} as any),
|
||||
).container.textContent,
|
||||
).toBe('hello | hello');
|
||||
|
||||
expect(
|
||||
render(
|
||||
snapshot.t('jsxNested', {
|
||||
foo: (
|
||||
<div>
|
||||
f<span>oo</span>
|
||||
|
||||
+66
-37
@@ -23,7 +23,13 @@ import {
|
||||
TranslationResource,
|
||||
TranslationSnapshot,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { createInstance as createI18n, type i18n as I18n } from 'i18next';
|
||||
import {
|
||||
createInstance as createI18n,
|
||||
FormatFunction,
|
||||
Interpolator,
|
||||
TFunction,
|
||||
type i18n as I18n,
|
||||
} from 'i18next';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
// Internal import to avoid code duplication, this will lead to duplication in build output
|
||||
@@ -39,7 +45,7 @@ import {
|
||||
} from '../../../../../core-plugin-api/src/translation/TranslationRef';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { DEFAULT_LANGUAGE } from '../AppLanguageApi/AppLanguageSelector';
|
||||
import { createElement, Fragment, ReactNode } from 'react';
|
||||
import { createElement, Fragment, ReactNode, isValidElement } from 'react';
|
||||
|
||||
/** @alpha */
|
||||
export interface I18nextTranslationApiOptions {
|
||||
@@ -139,47 +145,80 @@ class ResourceLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper for implementing the `jsx` format that allows `ReactNode`s to be
|
||||
* interpolated into translation messages.
|
||||
* A helper for implementing JSX interpolation
|
||||
*/
|
||||
export class JsxInterpolator {
|
||||
readonly #setFormatHook: (hook: FormatFunction) => void;
|
||||
readonly #marker: string;
|
||||
readonly #pattern: RegExp;
|
||||
|
||||
static create(options?: { marker?: string }) {
|
||||
static fromI18n(i18n: I18n) {
|
||||
const interpolator = i18n.services.interpolator as Interpolator & {
|
||||
format: FormatFunction;
|
||||
};
|
||||
const originalFormat = interpolator.format;
|
||||
|
||||
let formatHook: FormatFunction | undefined;
|
||||
|
||||
// This is the only way to override the format function of the interpolator
|
||||
// without overriding the default formatters. See the behavior here:
|
||||
// https://github.com/i18next/i18next/blob/c633121e57e2b6024080142d78027842bf2a6e5e/src/i18next.js#L120-L125
|
||||
interpolator.format = (value, format, lng, formatOpts) => {
|
||||
if (format) {
|
||||
return originalFormat(value, format, lng, formatOpts);
|
||||
}
|
||||
return formatHook?.(value, format, lng, formatOpts) ?? value;
|
||||
};
|
||||
|
||||
return new JsxInterpolator(
|
||||
options?.marker ?? Math.random().toString(36).substring(2, 8),
|
||||
// Using a random marker to ensure it can't be misused
|
||||
Math.random().toString(36).substring(2, 8),
|
||||
hook => {
|
||||
formatHook = hook;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(marker: string) {
|
||||
private constructor(
|
||||
marker: string,
|
||||
setFormatHook: (hook: FormatFunction) => void,
|
||||
) {
|
||||
this.#setFormatHook = setFormatHook;
|
||||
this.#marker = marker;
|
||||
this.#pattern = new RegExp(`\\$${marker}\\(([^)]+)\\)`);
|
||||
}
|
||||
|
||||
format = (
|
||||
_value: unknown,
|
||||
_lng: string | undefined,
|
||||
formatOptions: { interpolationkey: string },
|
||||
) => `$${this.#marker}(${btoa(formatOptions.interpolationkey)})`;
|
||||
|
||||
wrapT<TMessages extends { [key in string]: string }>(
|
||||
originalT: TranslationFunction<TMessages>,
|
||||
originalT: TFunction,
|
||||
): TranslationFunction<TMessages> {
|
||||
return ((...args) => {
|
||||
return ((key, options) => {
|
||||
let elementsMap: Map<string, ReactNode> | undefined = undefined;
|
||||
|
||||
// There's no way to override the format hook via the translation function
|
||||
// options, event though types indicate that it might be possible.
|
||||
// Instead, override the format function hook before every invocation and
|
||||
// rely on synchronous execution.
|
||||
this.#setFormatHook(value => {
|
||||
if (isValidElement(value)) {
|
||||
if (!elementsMap) {
|
||||
elementsMap = new Map();
|
||||
}
|
||||
const elementKey = elementsMap.size.toString();
|
||||
elementsMap.set(elementKey, value);
|
||||
|
||||
return `$${this.#marker}(${elementKey})`;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
// Overriding the return options is not allowed via TranslationFunction,
|
||||
// so this will always be a string
|
||||
const result = originalT(...args);
|
||||
|
||||
const options = args[1];
|
||||
if (!options) {
|
||||
const result = originalT(key, options as any) as unknown as string;
|
||||
if (!elementsMap) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const split = result.split(this.#pattern);
|
||||
if (split.length === 1) {
|
||||
return split[0];
|
||||
}
|
||||
|
||||
return createElement(
|
||||
Fragment,
|
||||
@@ -189,14 +228,7 @@ export class JsxInterpolator {
|
||||
if (index % 2 === 0) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const interpolationKey = atob(part);
|
||||
if (interpolationKey in options) {
|
||||
return (options as any)[interpolationKey] as ReactNode;
|
||||
}
|
||||
throw new Error(
|
||||
`Translation options did not provide a JSX node for interpolation key '${interpolationKey}'`,
|
||||
);
|
||||
return elementsMap?.get(part);
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
@@ -214,6 +246,8 @@ export class I18nextTranslationApi implements TranslationApi {
|
||||
supportedLngs: languages,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
// Used for the JsxInterpolator format hook
|
||||
alwaysFormat: true,
|
||||
},
|
||||
ns: [],
|
||||
defaultNS: false,
|
||||
@@ -228,12 +262,7 @@ export class I18nextTranslationApi implements TranslationApi {
|
||||
throw new Error('i18next was unexpectedly not initialized');
|
||||
}
|
||||
|
||||
if (!i18n.services.formatter) {
|
||||
throw new Error('i18next was unexpectedly missing formatter');
|
||||
}
|
||||
|
||||
const interpolator = JsxInterpolator.create();
|
||||
i18n.services.formatter.add('jsx', interpolator.format);
|
||||
const interpolator = JsxInterpolator.fromI18n(i18n);
|
||||
|
||||
const { language: initialLanguage } = options.languageApi.getLanguage();
|
||||
if (initialLanguage !== DEFAULT_LANGUAGE) {
|
||||
@@ -380,7 +409,7 @@ export class I18nextTranslationApi implements TranslationApi {
|
||||
}
|
||||
|
||||
const unwrappedT = this.#i18n.getFixedT(null, internalRef.id);
|
||||
const t = this.#jsxInterpolator.wrapT<TMessages>(unwrappedT as any);
|
||||
const t = this.#jsxInterpolator.wrapT<TMessages>(unwrappedT);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { Expand } from '@backstage/types';
|
||||
import { ExpandRecursive } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { ReactNode } from 'react';
|
||||
import { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha';
|
||||
import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
@@ -108,11 +108,19 @@ export type TranslationFunction<
|
||||
...[args]: TranslationFunctionOptions<
|
||||
NestedMessageKeys<TKey, IMessages>,
|
||||
PluralKeys<TMessages>,
|
||||
IMessages
|
||||
IMessages,
|
||||
string
|
||||
>
|
||||
): HasJsxFormat<TKey, IMessages> extends true
|
||||
? ReactNode
|
||||
: IMessages[TKey];
|
||||
): IMessages[TKey];
|
||||
<TKey extends keyof IMessages>(
|
||||
key: TKey,
|
||||
...[args]: TranslationFunctionOptions<
|
||||
NestedMessageKeys<TKey, IMessages>,
|
||||
PluralKeys<TMessages>,
|
||||
IMessages,
|
||||
string | JSX_2.Element
|
||||
>
|
||||
): JSX_2.Element;
|
||||
}
|
||||
: never;
|
||||
|
||||
|
||||
+40
-34
@@ -14,14 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { JSX } from 'react';
|
||||
import { TranslationFunction } from './TranslationApi';
|
||||
|
||||
function unused(..._any: any[]) {}
|
||||
// This is a weak assertion, don't reuse unless you know the drawbacks
|
||||
function expectType<T>(value: T) {
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('TranslationFunction', () => {
|
||||
it('should infer plurals', () => {
|
||||
const f = (() => {}) as TranslationFunction<{
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
key_one: 'one';
|
||||
key_other: 'other';
|
||||
thingCount_one: '{{count}} thing';
|
||||
@@ -30,11 +33,11 @@ describe('TranslationFunction', () => {
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
f('foo');
|
||||
expectType<string>(f('foo'));
|
||||
// @ts-expect-error
|
||||
f('foo', { count: 1 });
|
||||
|
||||
f('key', { count: 1 });
|
||||
expectType<string>(f('key', { count: 1 }));
|
||||
// @ts-expect-error
|
||||
f('key');
|
||||
// @ts-expect-error
|
||||
@@ -48,7 +51,7 @@ describe('TranslationFunction', () => {
|
||||
// @ts-expect-error
|
||||
f('key_other', { count: 6 });
|
||||
|
||||
f('thingCount', { count: 1 });
|
||||
expectType<string>(f('thingCount', { count: 1 }));
|
||||
// @ts-expect-error
|
||||
f('thingCount');
|
||||
// @ts-expect-error
|
||||
@@ -62,14 +65,13 @@ describe('TranslationFunction', () => {
|
||||
// @ts-expect-error
|
||||
f('thingCount_other', { count: 6 });
|
||||
|
||||
const x1: 'one' | 'other' = f('key', { count: 6 });
|
||||
expectType<'one' | 'other'>(f('key', { count: 6 }));
|
||||
// @ts-expect-error
|
||||
const x2: 'one' = f('key', { count: 6 });
|
||||
unused(x1, x2);
|
||||
expectType<'one'>(f('key', { count: 6 }));
|
||||
});
|
||||
|
||||
it('should infer interpolation params', () => {
|
||||
const f = (() => {}) as TranslationFunction<{
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
none: '=';
|
||||
simple: '= {{bar}}';
|
||||
multiple: '= {{bar }} {{ baz}}';
|
||||
@@ -79,7 +81,11 @@ describe('TranslationFunction', () => {
|
||||
|
||||
// @ts-expect-error
|
||||
f('none', { replace: { unknown: 1 } });
|
||||
f('simple', { bar: '' });
|
||||
expectType<string>(f('simple', { bar: '' }));
|
||||
// @ts-expect-error
|
||||
expectType<string>(f('simple', { bar: <div /> }));
|
||||
expectType<JSX.Element>(f('simple', { bar: <div /> }));
|
||||
expectType<JSX.Element>(f('simple', { replace: { bar: <div /> } }));
|
||||
// @ts-expect-error
|
||||
f('simple');
|
||||
// @ts-expect-error
|
||||
@@ -88,7 +94,10 @@ describe('TranslationFunction', () => {
|
||||
f('simple', { replace: {} });
|
||||
// @ts-expect-error
|
||||
f('simple', { replace: { wrong: '' } });
|
||||
f('multiple', { bar: '', baz: '' });
|
||||
expectType<string>(f('multiple', { bar: '', baz: '' }));
|
||||
expectType<JSX.Element>(f('multiple', { bar: <div />, baz: '' }));
|
||||
expectType<JSX.Element>(f('multiple', { bar: '', baz: <div /> }));
|
||||
expectType<JSX.Element>(f('multiple', { bar: <div />, baz: <div /> }));
|
||||
// @ts-expect-error
|
||||
f('multiple', { bar: '' });
|
||||
// @ts-expect-error
|
||||
@@ -99,7 +108,12 @@ describe('TranslationFunction', () => {
|
||||
f('multiple', {});
|
||||
// @ts-expect-error
|
||||
f('multiple', { replace: {} });
|
||||
f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } });
|
||||
expectType<string>(
|
||||
f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }),
|
||||
);
|
||||
expectType<JSX.Element>(
|
||||
f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: <div /> } } } }),
|
||||
);
|
||||
// @ts-expect-error
|
||||
f('deep');
|
||||
// @ts-expect-error
|
||||
@@ -115,7 +129,7 @@ describe('TranslationFunction', () => {
|
||||
});
|
||||
|
||||
it('should infer interpolation params with count', () => {
|
||||
const f = (() => {}) as TranslationFunction<{
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
simple_one: '= {{bar}}';
|
||||
simple_other: '= {{bar}}';
|
||||
multiple_one: '= {{ bar}} {{baz }}';
|
||||
@@ -164,7 +178,7 @@ describe('TranslationFunction', () => {
|
||||
});
|
||||
|
||||
it('should support formatting', () => {
|
||||
const f = (() => {}) as TranslationFunction<{
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
none: '{{x}}';
|
||||
number: '{{x, number}}';
|
||||
numberOptions: '{{x, number(minimumFractionDigits: 2)}}';
|
||||
@@ -172,28 +186,24 @@ describe('TranslationFunction', () => {
|
||||
datetime: '{{x, dateTime}}';
|
||||
relativeTimeOptions: '{{x, relativeTime(quarter)}}';
|
||||
list: '{{x, list}}';
|
||||
jsx: '{{x, jsx}}';
|
||||
jsxNested: '$t(jsx)';
|
||||
}>;
|
||||
expect(f).toBeDefined();
|
||||
|
||||
f('none', { replace: { x: 'x' } }) satisfies string;
|
||||
f('number', { x: 1 }) satisfies string;
|
||||
f('none', { replace: { x: 'x' } });
|
||||
f('number', { x: 1 });
|
||||
f('number', {
|
||||
replace: { x: 1 },
|
||||
formatParams: { x: { minimumFractionDigits: 2 } },
|
||||
}) satisfies string;
|
||||
f('numberOptions', { x: 1 }) satisfies string;
|
||||
f('currency', { replace: { x: 1 } }) satisfies string;
|
||||
f('datetime', { x: new Date() }) satisfies string;
|
||||
f('relativeTimeOptions', { replace: { x: 1 } }) satisfies string;
|
||||
});
|
||||
f('numberOptions', { x: 1 });
|
||||
f('currency', { replace: { x: 1 } });
|
||||
f('datetime', { x: new Date() });
|
||||
f('relativeTimeOptions', { replace: { x: 1 } });
|
||||
f('relativeTimeOptions', {
|
||||
replace: { x: 1 },
|
||||
formatParams: { x: { style: 'short' } },
|
||||
}) satisfies string;
|
||||
f('list', { replace: { x: ['a', 'b', 'c'] } }) satisfies string;
|
||||
f('jsx', { replace: { x: '' } }) satisfies ReactNode;
|
||||
f('jsxNested', { replace: { x: '' } }) satisfies ReactNode;
|
||||
});
|
||||
f('list', { replace: { x: ['a', 'b', 'c'] } });
|
||||
// @ts-expect-error
|
||||
f('none', { x: 1 });
|
||||
// @ts-expect-error
|
||||
@@ -213,14 +223,10 @@ describe('TranslationFunction', () => {
|
||||
});
|
||||
// @ts-expect-error
|
||||
f('list', { x: [1, 2, 3] });
|
||||
// @ts-expect-error
|
||||
f('jsx', { x: Symbol('not-a-node') });
|
||||
// @ts-expect-error
|
||||
f('jsxNested', { x: Symbol('not-a-node') });
|
||||
});
|
||||
|
||||
it('should support nesting', () => {
|
||||
const f = (() => {}) as TranslationFunction<{
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
simple: '$t(foo)';
|
||||
nested: '$t(bar)';
|
||||
nestedCount: '$t(qux)';
|
||||
@@ -255,7 +261,7 @@ describe('TranslationFunction', () => {
|
||||
});
|
||||
|
||||
it('should limit nesting depth', () => {
|
||||
const f = (() => {}) as TranslationFunction<{
|
||||
const f = (() => {}) as unknown as TranslationFunction<{
|
||||
a: '$t(b) {{a}}';
|
||||
b: '$t(c) {{b}}';
|
||||
c: '$t(d) {{c}}';
|
||||
@@ -17,7 +17,7 @@
|
||||
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Expand, ExpandRecursive, Observable } from '@backstage/types';
|
||||
import { TranslationRef } from '../../translation';
|
||||
import { ReactNode } from 'react';
|
||||
import { JSX } from 'react';
|
||||
|
||||
/**
|
||||
* Base translation options.
|
||||
@@ -65,10 +65,6 @@ type I18nextFormatMap = {
|
||||
type: string[];
|
||||
options: Intl.ListFormatOptions;
|
||||
};
|
||||
jsx: {
|
||||
type: ReactNode;
|
||||
options: {};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -173,12 +169,12 @@ type ReplaceFormatsFromMessage<TMessage> =
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
type ReplaceOptionsFromFormats<TFormats extends {}> = {
|
||||
type ReplaceOptionsFromFormats<TFormats extends {}, TValueType> = {
|
||||
[Key in keyof TFormats]: TFormats[Key] extends keyof I18nextFormatMap
|
||||
? I18nextFormatMap[TFormats[Key]]['type']
|
||||
: TFormats[Key] extends {}
|
||||
? Expand<ReplaceOptionsFromFormats<TFormats[Key]>>
|
||||
: string;
|
||||
? Expand<ReplaceOptionsFromFormats<TFormats[Key], TValueType>>
|
||||
: TValueType;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -264,14 +260,17 @@ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||
type CollectOptions<
|
||||
TCount extends { count?: number },
|
||||
TFormats extends {},
|
||||
TValueType,
|
||||
> = TCount &
|
||||
// count is special, omit it from the replacements
|
||||
(keyof Omit<TFormats, 'count'> extends never
|
||||
? {}
|
||||
: (
|
||||
| Expand<Omit<ReplaceOptionsFromFormats<TFormats>, 'count'>>
|
||||
| Expand<Omit<ReplaceOptionsFromFormats<TFormats, TValueType>, 'count'>>
|
||||
| {
|
||||
replace: Expand<Omit<ReplaceOptionsFromFormats<TFormats>, 'count'>>;
|
||||
replace: Expand<
|
||||
Omit<ReplaceOptionsFromFormats<TFormats, TValueType>, 'count'>
|
||||
>;
|
||||
}
|
||||
) & {
|
||||
formatParams?: Expand<ReplaceFormatParamsFromFormats<TFormats>>;
|
||||
@@ -283,7 +282,7 @@ type CollectOptions<
|
||||
* @ignore
|
||||
*/
|
||||
type OptionArgs<TOptions extends {}> = keyof TOptions extends never
|
||||
? [options?: BaseOptions]
|
||||
? [options?: Expand<BaseOptions>]
|
||||
: [options: Expand<BaseOptions & TOptions>];
|
||||
|
||||
/**
|
||||
@@ -293,49 +292,51 @@ 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
|
||||
TValueType,
|
||||
> = OptionArgs<
|
||||
Expand<
|
||||
CollectOptions<
|
||||
TKeys & TPluralKeys extends never ? {} : { count: number },
|
||||
ExpandRecursive<
|
||||
UnionToIntersection<ReplaceFormatsFromMessage<TMessages[TKeys]>>
|
||||
>
|
||||
>,
|
||||
TValueType
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* Evaluates to `true` if any of the replacements for the given key in the
|
||||
* provided set of messages uses the `jsx` format.
|
||||
*/
|
||||
type HasJsxFormat<
|
||||
TKey extends keyof TMessages,
|
||||
TMessages extends { [key in string]: string },
|
||||
> = UnionToIntersection<
|
||||
ReplaceFormatsFromMessage<TMessages[NestedMessageKeys<TKey, TMessages>]>
|
||||
> extends infer IFormatMap
|
||||
? 'jsx' extends IFormatMap[keyof IFormatMap]
|
||||
? true
|
||||
: false
|
||||
: never;
|
||||
|
||||
/** @alpha */
|
||||
export type TranslationFunction<TMessages extends { [key in string]: string }> =
|
||||
CollapsedMessages<TMessages> extends infer IMessages extends {
|
||||
[key in string]: string;
|
||||
}
|
||||
? {
|
||||
/**
|
||||
* A translation function that returns a string.
|
||||
*/
|
||||
<TKey extends keyof IMessages>(
|
||||
key: TKey,
|
||||
...[args]: TranslationFunctionOptions<
|
||||
NestedMessageKeys<TKey, IMessages>,
|
||||
PluralKeys<TMessages>,
|
||||
IMessages
|
||||
IMessages,
|
||||
string
|
||||
>
|
||||
): HasJsxFormat<TKey, IMessages> extends true
|
||||
? ReactNode
|
||||
: IMessages[TKey];
|
||||
): IMessages[TKey];
|
||||
/**
|
||||
* A translation function where at least one JSX.Element has been
|
||||
* provided as an interpolation value, and will therefore return a
|
||||
* JSX.Element.
|
||||
*/
|
||||
<TKey extends keyof IMessages>(
|
||||
key: TKey,
|
||||
...[args]: TranslationFunctionOptions<
|
||||
NestedMessageKeys<TKey, IMessages>,
|
||||
PluralKeys<TMessages>,
|
||||
IMessages,
|
||||
string | JSX.Element
|
||||
>
|
||||
): JSX.Element;
|
||||
}
|
||||
: never;
|
||||
|
||||
|
||||
+46
-15
@@ -94,6 +94,52 @@ describe('MockTranslationApi', () => {
|
||||
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
|
||||
});
|
||||
|
||||
it('should support jsx interpolation', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
empty: 'derp',
|
||||
jsx: '={{ x }}',
|
||||
jsxNested: '={{ x.y.z }}',
|
||||
jsxDeep: '<$t(jsx)>',
|
||||
});
|
||||
|
||||
expect(snapshot.t('jsx', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(snapshot.t('jsx', { replace: { x: <h1>hello</h1> } }))
|
||||
.toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(
|
||||
snapshot.t('jsxNested', { replace: { x: { y: { z: <h1>hello</h1> } } } }),
|
||||
).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(snapshot.t('jsxDeep', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
<=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
>
|
||||
</React.Fragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should support formatting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
plain: '= {{ x }}',
|
||||
@@ -104,8 +150,6 @@ describe('MockTranslationApi', () => {
|
||||
relativeSecondsShort:
|
||||
'= {{ x, relativeTime(range: second; style: short) }}',
|
||||
list: '= {{ x, list }}',
|
||||
jsx: '={{ x, jsx }}',
|
||||
nestedJsx: '<$t(jsx)>',
|
||||
});
|
||||
|
||||
expect(snapshot.t('plain', { x: '5' })).toBe('= 5');
|
||||
@@ -148,19 +192,6 @@ describe('MockTranslationApi', () => {
|
||||
expect(snapshot.t('list', { x: ['a'] })).toBe('= a');
|
||||
expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b');
|
||||
expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c');
|
||||
expect(snapshot.t('jsx', { x: 'hello' })).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
hello
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(snapshot.t('nestedJsx', { x: 'hello' })).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
<=
|
||||
hello
|
||||
>
|
||||
</React.Fragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should support plurals', () => {
|
||||
@@ -42,6 +42,8 @@ export class MockTranslationApi implements TranslationApi {
|
||||
supportedLngs: [DEFAULT_LANGUAGE],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
// Used for the JsxInterpolator format hook
|
||||
alwaysFormat: true,
|
||||
},
|
||||
ns: [],
|
||||
defaultNS: false,
|
||||
@@ -56,8 +58,7 @@ export class MockTranslationApi implements TranslationApi {
|
||||
throw new Error('i18next was unexpectedly not initialized');
|
||||
}
|
||||
|
||||
const interpolator = JsxInterpolator.create({ marker: '123456' });
|
||||
i18n.services.formatter?.add('jsx', interpolator.format);
|
||||
const interpolator = JsxInterpolator.fromI18n(i18n);
|
||||
|
||||
return new MockTranslationApi(i18n, interpolator);
|
||||
}
|
||||
@@ -88,7 +89,7 @@ export class MockTranslationApi implements TranslationApi {
|
||||
}
|
||||
|
||||
const t = this.#interpolator.wrapT<TMessages>(
|
||||
this.#i18n.getFixedT(null, internalRef.id) as any,
|
||||
this.#i18n.getFixedT(null, internalRef.id),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user