diff --git a/.changeset/cool-bikes-push.md b/.changeset/cool-bikes-push.md
new file mode 100644
index 0000000000..f78510f37e
--- /dev/null
+++ b/.changeset/cool-bikes-push.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-app-api': patch
+---
+
+Updated `I18nextTranslationApi` to support interpolation with the new `jsx` format.
diff --git a/.changeset/wicked-dingos-stand.md b/.changeset/wicked-dingos-stand.md
new file mode 100644
index 0000000000..39d59aee1d
--- /dev/null
+++ b/.changeset/wicked-dingos-stand.md
@@ -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`.
diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md
index 1fbf54d94e..1dfcca17e7 100644
--- a/docs/plugins/internationalization.md
+++ b/docs/plugins/internationalization.md
@@ -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 (
+
+);
+```
+
+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
diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx
similarity index 93%
rename from packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts
rename to packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx
index d5b812c5d7..54d84d2aed 100644
--- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts
+++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx
@@ -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: Hello
,
+ world: World
,
+ },
+ }),
+ ).container.textContent,
+ ).toBe('Hello, World!');
+
+ expect(
+ render(
+ snapshot.t('jsx', {
+ hello: world
,
+ world: hello
,
+ }),
+ ).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: (
+
+ foo
+
+ ),
+ bar: (
+
+ bar
+
+ ),
+ }),
+ ).container.textContent,
+ ).toBe('foo=foo, bar=bar');
+ });
});
});
diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
index d0ef35568f..6f29a440c2 100644
--- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
+++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
@@ -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();
/** 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(
@@ -297,10 +321,39 @@ export class I18nextTranslationApi implements TranslationApi {
return { ready: false };
}
- const t = this.#i18n.getFixedT(
- null,
- internalRef.id,
- ) as TranslationFunction;
+ 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;
return {
ready: true,
diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts
index ba5d43e896..490601a9e8 100644
--- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts
+++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts
@@ -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', () => {
diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts
index dba8753828..c7e280f0da 100644
--- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts
+++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts
@@ -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 = keyof TOptions extends never
? [options?: BaseOptions]
- : [options: BaseOptions & TOptions];
+ : [options: Expand];
/**
* @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 },
-> {
- >(
- key: TKey,
- ...[args]: TranslationFunctionOptions<
- NestedMessageKeys>,
- PluralKeys,
- CollapsedMessages
- >
- ): CollapsedMessages[TKey];
-}
+> = UnionToIntersection<
+ ReplaceFormatsFromMessage]>
+> extends infer IFormatMap
+ ? 'jsx' extends IFormatMap[keyof IFormatMap]
+ ? true
+ : false
+ : never;
+
+/** @alpha */
+export type TranslationFunction =
+ CollapsedMessages extends infer IMessages extends {
+ [key in string]: string;
+ }
+ ? {
+ (
+ key: TKey,
+ ...[args]: TranslationFunctionOptions<
+ NestedMessageKeys,
+ PluralKeys,
+ IMessages
+ >
+ ): HasJsxFormat extends true
+ ? ReactNode
+ : IMessages[TKey];
+ }
+ : never;
/** @alpha */
export type TranslationSnapshot =