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,