core-{app,plugin}-api: add support for JSX in translation messages

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-04-29 16:56:57 +02:00
parent 0135299c6e
commit 73f6cc3157
7 changed files with 214 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Updated `I18nextTranslationApi` to support interpolation with the new `jsx` format.
+5
View File
@@ -0,0 +1,5 @@
---
'@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`.
+37
View File
@@ -147,6 +147,43 @@ export const myPluginTranslationRef = createTranslationRef({
});
```
#### React Nodes
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`.
For example, you might define the following messages:
```ts title="define the message"
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',
},
},
});
```
Which can be used within a component like this:
```tsx title="use within a component"
const { t } = useTranslationRef(myPluginTranslationRef);
return (
<div>
{t('entityPage.redirect', {
link: <a href="/new-location">{t('entityPage.newLocation')}</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.
## 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,56 @@ describe('I18nextTranslationApi', () => {
expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
});
it('should support jsx formatting', () => {
const snapshot = snapshotWithMessages({
jsx: '{{ hello, jsx }}, {{ world, jsx }}!',
});
expect(
render(
snapshot.t('jsx', {
replace: {
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!');
});
it('should support jsx formatting with nested interpolations', () => {
const snapshot = snapshotWithMessages({
message: '$t(foo), $t(bar)',
foo: 'foo={{ foo, jsx }}',
bar: 'bar={{ bar, jsx }}',
});
expect(
render(
snapshot.t('message', {
foo: (
<div>
f<span>oo</span>
</div>
),
bar: (
<div>
<b>b</b>a<span>r</span>
</div>
),
}),
).container.textContent,
).toBe('foo=foo, bar=bar');
});
});
});
@@ -39,6 +39,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';
/** @alpha */
export interface I18nextTranslationApiOptions {
@@ -161,6 +162,21 @@ 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 elementMarker = Math.random().toString(36).substring(2, 8);
const elementMarkerPattern = new RegExp(`\\$${elementMarker}\\(([^)]+)\\)`);
i18n.services.formatter.add(
'jsx',
(
_value: ReactNode,
_lng: string | undefined,
formatOptions: { interpolationkey: string },
) => `$${elementMarker}(${btoa(formatOptions.interpolationkey)})`,
);
const { language: initialLanguage } = options.languageApi.getLanguage();
if (initialLanguage !== DEFAULT_LANGUAGE) {
i18n.changeLanguage(initialLanguage);
@@ -198,6 +214,7 @@ export class I18nextTranslationApi implements TranslationApi {
i18n,
loader,
options.languageApi.getLanguage().language,
elementMarkerPattern,
);
options.languageApi.language$().subscribe(({ language }) => {
@@ -210,16 +227,23 @@ export class I18nextTranslationApi implements TranslationApi {
#i18n: I18n;
#loader: ResourceLoader;
#language: string;
#elementMarkerPattern: RegExp;
/** 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,
elementMarkerPattern: RegExp,
) {
this.#i18n = i18n;
this.#loader = loader;
this.#language = language;
this.#elementMarkerPattern = elementMarkerPattern;
}
getTranslation<TMessages extends { [key in string]: string }>(
@@ -297,10 +321,39 @@ 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 = ((key: string, options?: any) => {
// Overriding the return options is not allowed via TranslationFunction,
// so this will always be a string
const result = unwrappedT(key, options) as unknown as string;
const split = result.split(this.#elementMarkerPattern);
if (split.length === 1) {
return split[0];
}
return createElement(
Fragment,
null,
...split
.map((part, index) => {
if (index % 2 === 0) {
return part;
}
const interpolationKey = atob(part);
const container = options.replace ?? options;
if (interpolationKey in container) {
return container[interpolationKey];
}
throw new Error(
`Translation options did not provide a JSX node for interpolation key '${interpolationKey}'`,
);
})
.filter(Boolean),
);
}) as TranslationFunction<TMessages>;
return {
ready: true,
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ReactNode } from 'react';
import { TranslationFunction } from './TranslationApi';
function unused(..._any: any[]) {}
@@ -171,24 +172,28 @@ 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' } });
f('number', { x: 1 });
f('none', { replace: { x: 'x' } }) satisfies string;
f('number', { x: 1 }) satisfies string;
f('number', {
replace: { x: 1 },
formatParams: { x: { minimumFractionDigits: 2 } },
});
f('numberOptions', { x: 1 });
f('currency', { replace: { x: 1 } });
f('datetime', { x: new Date() });
f('relativeTimeOptions', { replace: { x: 1 } });
}) 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('relativeTimeOptions', {
replace: { x: 1 },
formatParams: { x: { style: 'short' } },
});
f('list', { replace: { x: ['a', 'b', 'c'] } });
}) satisfies string;
f('list', { replace: { x: ['a', 'b', 'c'] } }) satisfies string;
f('jsx', { replace: { x: '' } }) satisfies ReactNode;
f('jsxNested', { replace: { x: '' } }) satisfies ReactNode;
// @ts-expect-error
f('none', { x: 1 });
// @ts-expect-error
@@ -208,6 +213,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', () => {
@@ -17,6 +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';
/**
* Base translation options.
@@ -64,6 +65,10 @@ type I18nextFormatMap = {
type: string[];
options: Intl.ListFormatOptions;
};
jsx: {
type: ReactNode;
options: {};
};
};
/**
@@ -279,7 +284,7 @@ type CollectOptions<
*/
type OptionArgs<TOptions extends {}> = keyof TOptions extends never
? [options?: BaseOptions]
: [options: BaseOptions & TOptions];
: [options: Expand<BaseOptions & TOptions>];
/**
* @ignore
@@ -299,19 +304,40 @@ type TranslationFunctionOptions<
>
>;
/** @alpha */
export interface TranslationFunction<
/**
* @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 },
> {
<TKey extends keyof CollapsedMessages<TMessages>>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, CollapsedMessages<TMessages>>,
PluralKeys<TMessages>,
CollapsedMessages<TMessages>
>
): CollapsedMessages<TMessages>[TKey];
}
> = 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;
}
? {
<TKey extends keyof IMessages>(
key: TKey,
...[args]: TranslationFunctionOptions<
NestedMessageKeys<TKey, IMessages>,
PluralKeys<TMessages>,
IMessages
>
): HasJsxFormat<TKey, IMessages> extends true
? ReactNode
: IMessages[TKey];
}
: never;
/** @alpha */
export type TranslationSnapshot<TMessages extends { [key in string]: string }> =