core-app-api,test-utils: refactor i18n jsx interpolation to re-use in mock implementation

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-04-29 17:59:59 +02:00
parent 73f6cc3157
commit b5733414ab
6 changed files with 132 additions and 68 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': patch
---
Added support for `jsx` interpolation format for the `MockTranslationApi`.
@@ -548,10 +548,8 @@ describe('I18nextTranslationApi', () => {
expect(
render(
snapshot.t('jsx', {
replace: {
hello: <h1>Hello</h1>,
world: <h6>World</h6>,
},
hello: <h1>Hello</h1>,
world: <h6>World</h6>,
}),
).container.textContent,
).toBe('Hello, World!');
@@ -564,6 +562,14 @@ 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', () => {
@@ -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<TMessages extends { [key in string]: string }>(
originalT: TranslationFunction<TMessages>,
): TranslationFunction<TMessages> {
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<TMessages>;
}
}
/** @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<string>();
@@ -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<TMessages extends { [key in string]: string }>(
@@ -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<TMessages>;
const t = this.#jsxInterpolator.wrapT<TMessages>(unwrappedT as any);
return {
ready: true,
+17 -11
View File
@@ -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<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
>
): HasJsxFormat<TKey, IMessages> extends true
? ReactNode
: IMessages[TKey];
}
: never;
// @alpha
export interface TranslationMessages<
@@ -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(`
<React.Fragment>
=
hello
</React.Fragment>
`);
expect(snapshot.t('nestedJsx', { x: 'hello' })).toMatchInlineSnapshot(`
<React.Fragment>
&lt;=
hello
&gt;
</React.Fragment>
`);
});
it('should support plurals', () => {
@@ -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<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 +87,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) as any,
);
return {
ready: true,