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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Added support for `jsx` interpolation format for the `MockTranslationApi`.
|
||||
+10
-4
@@ -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', () => {
|
||||
|
||||
+73
-46
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
<=
|
||||
hello
|
||||
>
|
||||
</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,
|
||||
|
||||
Reference in New Issue
Block a user