Merge pull request #29786 from backstage/rugvip/i18n-jsx

core-{app,plugin}-api: add support for JSX in translation messages
This commit is contained in:
Patrik Oldsberg
2025-05-02 15:27:53 +02:00
committed by GitHub
11 changed files with 391 additions and 60 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': patch
---
Added support for interpolating JSX elements with the `MockTranslationApi`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Updated `I18nextTranslationApi` to support interpolation of JSX elements.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
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.
+36
View File
@@ -147,6 +147,42 @@ export const myPluginTranslationRef = createTranslationRef({
});
```
#### JSX Elements
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:
```ts title="define the message"
export const myPluginTranslationRef = createTranslationRef({
id: 'plugin.my-plugin',
messages: {
entityPage: {
redirect: {
message: 'The entity you are looking for has been moved to {{link}}.',
link: 'new location',
},
},
},
});
```
Which can be used within a component like this:
```tsx title="use within a component"
const { t } = useTranslationRef(myPluginTranslationRef);
return (
<div>
{t('entityPage.redirect.message', {
link: <a href="/new-location">{t('entityPage.redirect.link')}</a>,
})}
</div>
);
```
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
Step 1: Create translation resources
@@ -23,6 +23,7 @@ import {
import { Observable } from '@backstage/types';
import { AppLanguageSelector } from '../AppLanguageApi';
import { I18nextTranslationApi } from './I18nextTranslationApi';
import { render } from '@testing-library/react';
const plainRef = createTranslationRef({
id: 'plain',
@@ -538,5 +539,67 @@ describe('I18nextTranslationApi', () => {
expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
});
it('should support jsx interpolation', () => {
const snapshot = snapshotWithMessages({
jsx: '{{ hello }}, {{ world }}!',
jsxMultiple: '{{ hello }} | {{ hello }}',
jsxNested: '$t(foo), $t(bar)',
foo: 'foo={{ foo }}',
bar: 'bar={{ bar }}',
});
expect(
render(
snapshot.t('jsx', {
hello: <h1>Hello</h1>,
world: <h6>World</h6>,
}),
).container.textContent,
).toBe('Hello, World!');
expect(
render(
snapshot.t('jsx', {
hello: <h1>world</h1>,
world: <h6>hello</h6>,
}),
).container.textContent,
).toBe('world, hello!');
// Missing value
expect(
render(
snapshot.t('jsx', {
hello: <h1>hello</h1>,
} as any),
).container.textContent,
).toBe('hello, {{ world }}!');
expect(
render(
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>
</div>
),
bar: (
<div>
<b>b</b>a<span>r</span>
</div>
),
}),
).container.textContent,
).toBe('foo=foo, bar=bar');
});
});
});
@@ -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,6 +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, isValidElement } from 'react';
/** @alpha */
export interface I18nextTranslationApiOptions {
@@ -137,6 +144,98 @@ class ResourceLoader {
}
}
/**
* A helper for implementing JSX interpolation
*/
export class JsxInterpolator {
readonly #setFormatHook: (hook: FormatFunction) => void;
readonly #marker: string;
readonly #pattern: RegExp;
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(
// 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,
setFormatHook: (hook: FormatFunction) => void,
) {
this.#setFormatHook = setFormatHook;
this.#marker = marker;
this.#pattern = new RegExp(`\\$${marker}\\(([^)]+)\\)`);
}
wrapT<TMessages extends { [key in string]: string }>(
originalT: TFunction,
): TranslationFunction<TMessages> {
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(key, options as any) as unknown as string;
if (!elementsMap) {
return result;
}
const split = result.split(this.#pattern);
return createElement(
Fragment,
null,
...split
.map((part, index) => {
if (index % 2 === 0) {
return part;
}
return elementsMap?.get(part);
})
.filter(Boolean),
);
}) as TranslationFunction<TMessages>;
}
}
/** @alpha */
export class I18nextTranslationApi implements TranslationApi {
static create(options: I18nextTranslationApiOptions) {
@@ -147,6 +246,8 @@ export class I18nextTranslationApi implements TranslationApi {
supportedLngs: languages,
interpolation: {
escapeValue: false,
// Used for the JsxInterpolator format hook
alwaysFormat: true,
},
ns: [],
defaultNS: false,
@@ -161,6 +262,8 @@ export class I18nextTranslationApi implements TranslationApi {
throw new Error('i18next was unexpectedly not initialized');
}
const interpolator = JsxInterpolator.fromI18n(i18n);
const { language: initialLanguage } = options.languageApi.getLanguage();
if (initialLanguage !== DEFAULT_LANGUAGE) {
i18n.changeLanguage(initialLanguage);
@@ -198,6 +301,7 @@ export class I18nextTranslationApi implements TranslationApi {
i18n,
loader,
options.languageApi.getLanguage().language,
interpolator,
);
options.languageApi.language$().subscribe(({ language }) => {
@@ -210,16 +314,23 @@ export class I18nextTranslationApi implements TranslationApi {
#i18n: I18n;
#loader: ResourceLoader;
#language: string;
#jsxInterpolator: JsxInterpolator;
/** Keep track of which refs we have registered default resources for */
#registeredRefs = new Set<string>();
/** Notify observers when language changes */
#languageChangeListeners = new Set<() => void>();
private constructor(i18n: I18n, loader: ResourceLoader, language: string) {
private constructor(
i18n: I18n,
loader: ResourceLoader,
language: string,
jsxInterpolator: JsxInterpolator,
) {
this.#i18n = i18n;
this.#loader = loader;
this.#language = language;
this.#jsxInterpolator = jsxInterpolator;
}
getTranslation<TMessages extends { [key in string]: string }>(
@@ -297,10 +408,8 @@ export class I18nextTranslationApi implements TranslationApi {
return { ready: false };
}
const t = this.#i18n.getFixedT(
null,
internalRef.id,
) as TranslationFunction<TMessages>;
const unwrappedT = this.#i18n.getFixedT(null, internalRef.id);
const t = this.#jsxInterpolator.wrapT<TMessages>(unwrappedT);
return {
ready: true,
+25 -11
View File
@@ -6,6 +6,7 @@
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 { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha';
import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha';
@@ -94,21 +95,34 @@ export type TranslationApi = {
export const translationApiRef: ApiRef<TranslationApi>;
// @alpha (undocumented)
export interface TranslationFunction<
export type TranslationFunction<
TMessages extends {
[key in string]: string;
},
> {
// (undocumented)
<TKey extends keyof CollapsedMessages<TMessages>>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, CollapsedMessages<TMessages>>,
PluralKeys<TMessages>,
CollapsedMessages<TMessages>
>
): CollapsedMessages<TMessages>[TKey];
> = CollapsedMessages<TMessages> extends infer IMessages extends {
[key in string]: string;
}
? {
<TKey extends keyof IMessages>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, IMessages>,
PluralKeys<TMessages>,
IMessages,
string
>
): IMessages[TKey];
<TKey extends keyof IMessages>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, IMessages>,
PluralKeys<TMessages>,
IMessages,
string | JSX_2.Element
>
): JSX_2.Element;
}
: never;
// @alpha
export interface TranslationMessages<
@@ -14,13 +14,17 @@
* limitations under the License.
*/
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';
@@ -29,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
@@ -47,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
@@ -61,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}}';
@@ -78,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
@@ -87,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
@@ -98,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
@@ -114,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 }}';
@@ -163,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)}}';
@@ -211,7 +226,7 @@ describe('TranslationFunction', () => {
});
it('should support nesting', () => {
const f = (() => {}) as TranslationFunction<{
const f = (() => {}) as unknown as TranslationFunction<{
simple: '$t(foo)';
nested: '$t(bar)';
nestedCount: '$t(qux)';
@@ -246,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,6 +17,7 @@
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
import { Expand, ExpandRecursive, Observable } from '@backstage/types';
import { TranslationRef } from '../../translation';
import { JSX } from 'react';
/**
* Base translation options.
@@ -168,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;
};
/**
@@ -259,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>>;
@@ -278,8 +282,8 @@ type CollectOptions<
* @ignore
*/
type OptionArgs<TOptions extends {}> = keyof TOptions extends never
? [options?: BaseOptions]
: [options: BaseOptions & TOptions];
? [options?: Expand<BaseOptions>]
: [options: Expand<BaseOptions & TOptions>];
/**
* @ignore
@@ -288,30 +292,53 @@ 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
>
>
>;
/** @alpha */
export interface TranslationFunction<
TMessages extends { [key in string]: string },
> {
<TKey extends keyof CollapsedMessages<TMessages>>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, CollapsedMessages<TMessages>>,
PluralKeys<TMessages>,
CollapsedMessages<TMessages>
>
): CollapsedMessages<TMessages>[TKey];
}
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,
string
>
): 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;
/** @alpha */
export type TranslationSnapshot<TMessages extends { [key in string]: string }> =
@@ -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>
&lt;=
<h1>
hello
</h1>
&gt;
</React.Fragment>
`);
});
it('should support formatting', () => {
const snapshot = snapshotWithMessages({
plain: '= {{ x }}',
@@ -16,7 +16,6 @@
import {
TranslationApi,
TranslationFunction,
TranslationRef,
TranslationSnapshot,
} from '@backstage/core-plugin-api/alpha';
@@ -27,6 +26,8 @@ import { Observable } from '@backstage/types';
// Internal import to avoid code duplication, this will lead to duplication in build output
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { JsxInterpolator } from '../../../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
const DEFAULT_LANGUAGE = 'en';
@@ -41,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,
@@ -55,14 +58,18 @@ export class MockTranslationApi implements TranslationApi {
throw new Error('i18next was unexpectedly not initialized');
}
return new MockTranslationApi(i18n);
const interpolator = JsxInterpolator.fromI18n(i18n);
return new MockTranslationApi(i18n, interpolator);
}
#i18n: I18n;
#interpolator: JsxInterpolator;
#registeredRefs = new Set<string>();
private constructor(i18n: I18n) {
private constructor(i18n: I18n, interpolator: JsxInterpolator) {
this.#i18n = i18n;
this.#interpolator = interpolator;
}
getTranslation<TMessages extends { [key in string]: string }>(
@@ -81,10 +88,9 @@ export class MockTranslationApi implements TranslationApi {
);
}
const t = this.#i18n.getFixedT(
null,
internalRef.id,
) as TranslationFunction<TMessages>;
const t = this.#interpolator.wrapT<TMessages>(
this.#i18n.getFixedT(null, internalRef.id),
);
return {
ready: true,