diff --git a/.changeset/brave-donuts-sink.md b/.changeset/brave-donuts-sink.md
new file mode 100644
index 0000000000..2d79d657f9
--- /dev/null
+++ b/.changeset/brave-donuts-sink.md
@@ -0,0 +1,5 @@
+---
+'@backstage/test-utils': patch
+---
+
+Added support for `jsx` interpolation format for the `MockTranslationApi`.
diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx
index 54d84d2aed..16a83e5463 100644
--- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx
+++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx
@@ -548,10 +548,8 @@ describe('I18nextTranslationApi', () => {
expect(
render(
snapshot.t('jsx', {
- replace: {
- hello:
Hello
,
- world: World
,
- },
+ hello: Hello
,
+ world: World
,
}),
).container.textContent,
).toBe('Hello, World!');
@@ -564,6 +562,14 @@ describe('I18nextTranslationApi', () => {
}),
).container.textContent,
).toBe('world, hello!');
+
+ expect(() =>
+ snapshot.t('jsx', {
+ world: World
,
+ } as any),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Translation options did not provide a JSX node for interpolation key 'hello'"`,
+ );
});
it('should support jsx formatting with nested interpolations', () => {
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 6f29a440c2..b836991c6e 100644
--- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
+++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts
@@ -138,6 +138,72 @@ class ResourceLoader {
}
}
+/**
+ * A helper for implementing the `jsx` format that allows `ReactNode`s to be
+ * interpolated into translation messages.
+ */
+export class JsxInterpolator {
+ readonly #marker: string;
+ readonly #pattern: RegExp;
+
+ static create(options?: { marker?: string }) {
+ return new JsxInterpolator(
+ options?.marker ?? Math.random().toString(36).substring(2, 8),
+ );
+ }
+
+ private constructor(marker: string) {
+ this.#marker = marker;
+ this.#pattern = new RegExp(`\\$${marker}\\(([^)]+)\\)`);
+ }
+
+ format = (
+ _value: unknown,
+ _lng: string | undefined,
+ formatOptions: { interpolationkey: string },
+ ) => `$${this.#marker}(${btoa(formatOptions.interpolationkey)})`;
+
+ wrapT(
+ originalT: TranslationFunction,
+ ): TranslationFunction {
+ return ((...args) => {
+ // 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) {
+ return result;
+ }
+
+ const split = result.split(this.#pattern);
+ 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);
+ 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}'`,
+ );
+ })
+ .filter(Boolean),
+ );
+ }) as TranslationFunction;
+ }
+}
+
/** @alpha */
export class I18nextTranslationApi implements TranslationApi {
static create(options: I18nextTranslationApiOptions) {
@@ -166,16 +232,8 @@ export class I18nextTranslationApi implements TranslationApi {
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 interpolator = JsxInterpolator.create();
+ i18n.services.formatter.add('jsx', interpolator.format);
const { language: initialLanguage } = options.languageApi.getLanguage();
if (initialLanguage !== DEFAULT_LANGUAGE) {
@@ -214,7 +272,7 @@ export class I18nextTranslationApi implements TranslationApi {
i18n,
loader,
options.languageApi.getLanguage().language,
- elementMarkerPattern,
+ interpolator,
);
options.languageApi.language$().subscribe(({ language }) => {
@@ -227,7 +285,7 @@ export class I18nextTranslationApi implements TranslationApi {
#i18n: I18n;
#loader: ResourceLoader;
#language: string;
- #elementMarkerPattern: RegExp;
+ #jsxInterpolator: JsxInterpolator;
/** Keep track of which refs we have registered default resources for */
#registeredRefs = new Set();
@@ -238,12 +296,12 @@ export class I18nextTranslationApi implements TranslationApi {
i18n: I18n,
loader: ResourceLoader,
language: string,
- elementMarkerPattern: RegExp,
+ jsxInterpolator: JsxInterpolator,
) {
this.#i18n = i18n;
this.#loader = loader;
this.#language = language;
- this.#elementMarkerPattern = elementMarkerPattern;
+ this.#jsxInterpolator = jsxInterpolator;
}
getTranslation(
@@ -322,38 +380,7 @@ export class I18nextTranslationApi implements TranslationApi {
}
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;
+ const t = this.#jsxInterpolator.wrapT(unwrappedT as any);
return {
ready: true,
diff --git a/packages/core-plugin-api/report-alpha.api.md b/packages/core-plugin-api/report-alpha.api.md
index 888c0ffea0..eada7dc982 100644
--- a/packages/core-plugin-api/report-alpha.api.md
+++ b/packages/core-plugin-api/report-alpha.api.md
@@ -7,6 +7,7 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { Expand } from '@backstage/types';
import { ExpandRecursive } from '@backstage/types';
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';
@@ -94,21 +95,26 @@ export type TranslationApi = {
export const translationApiRef: ApiRef;
// @alpha (undocumented)
-export interface TranslationFunction<
+export type TranslationFunction<
TMessages extends {
[key in string]: string;
},
-> {
- // (undocumented)
- >(
- key: TKey,
- ...[args]: TranslationFunctionOptions<
- NestedMessageKeys>,
- PluralKeys,
- CollapsedMessages
- >
- ): CollapsedMessages[TKey];
+> = 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 interface TranslationMessages<
diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts
index 0cc755ecb5..a1a1cd8ee2 100644
--- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts
+++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts
@@ -104,6 +104,8 @@ 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');
@@ -146,6 +148,19 @@ 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(`
+
+ =
+ hello
+
+ `);
+ expect(snapshot.t('nestedJsx', { x: 'hello' })).toMatchInlineSnapshot(`
+
+ <=
+ hello
+ >
+
+ `);
});
it('should support plurals', () => {
diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts
index 46c5bfd5d3..d2f53bacf0 100644
--- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts
+++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts
@@ -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';
@@ -55,14 +56,19 @@ export class MockTranslationApi implements TranslationApi {
throw new Error('i18next was unexpectedly not initialized');
}
- return new MockTranslationApi(i18n);
+ const interpolator = JsxInterpolator.create({ marker: '123456' });
+ i18n.services.formatter?.add('jsx', interpolator.format);
+
+ return new MockTranslationApi(i18n, interpolator);
}
#i18n: I18n;
+ #interpolator: JsxInterpolator;
#registeredRefs = new Set();
- private constructor(i18n: I18n) {
+ private constructor(i18n: I18n, interpolator: JsxInterpolator) {
this.#i18n = i18n;
+ this.#interpolator = interpolator;
}
getTranslation(
@@ -81,10 +87,9 @@ export class MockTranslationApi implements TranslationApi {
);
}
- const t = this.#i18n.getFixedT(
- null,
- internalRef.id,
- ) as TranslationFunction;
+ const t = this.#interpolator.wrapT(
+ this.#i18n.getFixedT(null, internalRef.id) as any,
+ );
return {
ready: true,