From 0bc1804ce5166d6adcfd20b9b1a7e27fbe74b1c8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 29 Apr 2025 22:27:02 +0200 Subject: [PATCH] core-{app,plugin}-api: switch i18n JSX support to no longer requrie explicit format Signed-off-by: Patrik Oldsberg --- .changeset/brave-donuts-sink.md | 2 +- .changeset/cool-bikes-push.md | 2 +- .changeset/wicked-dingos-stand.md | 2 +- docs/plugins/internationalization.md | 19 ++-- .../I18nextTranslationApi.test.tsx | 41 ++++--- .../TranslationApi/I18nextTranslationApi.ts | 103 +++++++++++------- packages/core-plugin-api/report-alpha.api.md | 18 ++- ...ionApi.test.ts => TranslationApi.test.tsx} | 74 +++++++------ .../src/apis/definitions/TranslationApi.ts | 65 +++++------ ...pi.test.ts => MockTranslationApi.test.tsx} | 61 ++++++++--- .../apis/TranslationApi/MockTranslationApi.ts | 7 +- 11 files changed, 237 insertions(+), 157 deletions(-) rename packages/core-plugin-api/src/apis/definitions/{TranslationApi.test.ts => TranslationApi.test.tsx} (79%) rename packages/test-utils/src/testUtils/apis/TranslationApi/{MockTranslationApi.test.ts => MockTranslationApi.test.tsx} (87%) diff --git a/.changeset/brave-donuts-sink.md b/.changeset/brave-donuts-sink.md index 2d79d657f9..47827345a5 100644 --- a/.changeset/brave-donuts-sink.md +++ b/.changeset/brave-donuts-sink.md @@ -2,4 +2,4 @@ '@backstage/test-utils': patch --- -Added support for `jsx` interpolation format for the `MockTranslationApi`. +Added support for interpolating JSX elements with the `MockTranslationApi`. diff --git a/.changeset/cool-bikes-push.md b/.changeset/cool-bikes-push.md index f78510f37e..00825c1ad0 100644 --- a/.changeset/cool-bikes-push.md +++ b/.changeset/cool-bikes-push.md @@ -2,4 +2,4 @@ '@backstage/core-app-api': patch --- -Updated `I18nextTranslationApi` to support interpolation with the new `jsx` format. +Updated `I18nextTranslationApi` to support interpolation of JSX elements. diff --git a/.changeset/wicked-dingos-stand.md b/.changeset/wicked-dingos-stand.md index 39d59aee1d..92d84825d1 100644 --- a/.changeset/wicked-dingos-stand.md +++ b/.changeset/wicked-dingos-stand.md @@ -2,4 +2,4 @@ '@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`. +The `TranslationApi` now supports interpolation of JSX elements by passing them directly as values to the translation function. If any of the provided interpolation values are JSX elements, the translation function will return a JSX element instead of a string. diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 1dfcca17e7..4eb5e864d1 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -147,9 +147,9 @@ export const myPluginTranslationRef = createTranslationRef({ }); ``` -#### React Nodes +#### JSX Elements -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`. +The translation API supports interpolation of JSX elements by passing them directly as values to the translation function. If any of the provided interpolation values are JSX elements, the translation function will return a JSX element instead of a string. For example, you might define the following messages: @@ -158,9 +158,10 @@ 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', + redirect: { + message: 'The entity you are looking for has been moved to {{link}}.', + link: 'new location', + }, }, }, }); @@ -173,16 +174,14 @@ const { t } = useTranslationRef(myPluginTranslationRef); return (
- {t('entityPage.redirect', { - link: {t('entityPage.newLocation')}, + {t('entityPage.redirect.message', { + link: {t('entityPage.redirect.link')}, })}
); ``` -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. +The return type of the outer `t` function will be a `JSX.Element`, with the underlying value being a React fragment of the different parts of the message. ## For an application developer overwrite plugin messages 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 16a83e5463..b6e79b20ed 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 @@ -540,9 +540,13 @@ describe('I18nextTranslationApi', () => { expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps'); }); - it('should support jsx formatting', () => { + it('should support jsx interpolation', () => { const snapshot = snapshotWithMessages({ - jsx: '{{ hello, jsx }}, {{ world, jsx }}!', + jsx: '{{ hello }}, {{ world }}!', + jsxMultiple: '{{ hello }} | {{ hello }}', + jsxNested: '$t(foo), $t(bar)', + foo: 'foo={{ foo }}', + bar: 'bar={{ bar }}', }); expect( @@ -563,25 +567,26 @@ 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', () => { - const snapshot = snapshotWithMessages({ - message: '$t(foo), $t(bar)', - foo: 'foo={{ foo, jsx }}', - bar: 'bar={{ bar, jsx }}', - }); + // Missing value + expect( + render( + snapshot.t('jsx', { + hello:

hello

, + } as any), + ).container.textContent, + ).toBe('hello, {{ world }}!'); expect( render( - snapshot.t('message', { + snapshot.t('jsxMultiple', { + hello:

hello

, + } as any), + ).container.textContent, + ).toBe('hello | hello'); + + expect( + render( + snapshot.t('jsxNested', { foo: (
foo 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 b836991c6e..de21990cb3 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -23,7 +23,13 @@ import { TranslationResource, TranslationSnapshot, } from '@backstage/core-plugin-api/alpha'; -import { createInstance as createI18n, type i18n as I18n } from 'i18next'; +import { + createInstance as createI18n, + FormatFunction, + Interpolator, + TFunction, + type i18n as I18n, +} from 'i18next'; import ObservableImpl from 'zen-observable'; // Internal import to avoid code duplication, this will lead to duplication in build output @@ -39,7 +45,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'; +import { createElement, Fragment, ReactNode, isValidElement } from 'react'; /** @alpha */ export interface I18nextTranslationApiOptions { @@ -139,47 +145,80 @@ class ResourceLoader { } /** - * A helper for implementing the `jsx` format that allows `ReactNode`s to be - * interpolated into translation messages. + * A helper for implementing JSX interpolation */ export class JsxInterpolator { + readonly #setFormatHook: (hook: FormatFunction) => void; readonly #marker: string; readonly #pattern: RegExp; - static create(options?: { marker?: string }) { + static fromI18n(i18n: I18n) { + const interpolator = i18n.services.interpolator as Interpolator & { + format: FormatFunction; + }; + const originalFormat = interpolator.format; + + let formatHook: FormatFunction | undefined; + + // This is the only way to override the format function of the interpolator + // without overriding the default formatters. See the behavior here: + // https://github.com/i18next/i18next/blob/c633121e57e2b6024080142d78027842bf2a6e5e/src/i18next.js#L120-L125 + interpolator.format = (value, format, lng, formatOpts) => { + if (format) { + return originalFormat(value, format, lng, formatOpts); + } + return formatHook?.(value, format, lng, formatOpts) ?? value; + }; + return new JsxInterpolator( - options?.marker ?? Math.random().toString(36).substring(2, 8), + // Using a random marker to ensure it can't be misused + Math.random().toString(36).substring(2, 8), + hook => { + formatHook = hook; + }, ); } - private constructor(marker: string) { + private constructor( + marker: string, + setFormatHook: (hook: FormatFunction) => void, + ) { + this.#setFormatHook = setFormatHook; 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, + originalT: TFunction, ): TranslationFunction { - return ((...args) => { + return ((key, options) => { + let elementsMap: Map | undefined = undefined; + + // There's no way to override the format hook via the translation function + // options, event though types indicate that it might be possible. + // Instead, override the format function hook before every invocation and + // rely on synchronous execution. + this.#setFormatHook(value => { + if (isValidElement(value)) { + if (!elementsMap) { + elementsMap = new Map(); + } + const elementKey = elementsMap.size.toString(); + elementsMap.set(elementKey, value); + + return `$${this.#marker}(${elementKey})`; + } + return value; + }); + // 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) { + const result = originalT(key, options as any) as unknown as string; + if (!elementsMap) { return result; } const split = result.split(this.#pattern); - if (split.length === 1) { - return split[0]; - } return createElement( Fragment, @@ -189,14 +228,7 @@ export class JsxInterpolator { 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}'`, - ); + return elementsMap?.get(part); }) .filter(Boolean), ); @@ -214,6 +246,8 @@ export class I18nextTranslationApi implements TranslationApi { supportedLngs: languages, interpolation: { escapeValue: false, + // Used for the JsxInterpolator format hook + alwaysFormat: true, }, ns: [], defaultNS: false, @@ -228,12 +262,7 @@ 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 interpolator = JsxInterpolator.create(); - i18n.services.formatter.add('jsx', interpolator.format); + const interpolator = JsxInterpolator.fromI18n(i18n); const { language: initialLanguage } = options.languageApi.getLanguage(); if (initialLanguage !== DEFAULT_LANGUAGE) { @@ -380,7 +409,7 @@ export class I18nextTranslationApi implements TranslationApi { } const unwrappedT = this.#i18n.getFixedT(null, internalRef.id); - const t = this.#jsxInterpolator.wrapT(unwrappedT as any); + const t = this.#jsxInterpolator.wrapT(unwrappedT); return { ready: true, diff --git a/packages/core-plugin-api/report-alpha.api.md b/packages/core-plugin-api/report-alpha.api.md index eada7dc982..6243c76cff 100644 --- a/packages/core-plugin-api/report-alpha.api.md +++ b/packages/core-plugin-api/report-alpha.api.md @@ -6,8 +6,8 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { Expand } from '@backstage/types'; import { ExpandRecursive } from '@backstage/types'; +import { JSX as JSX_2 } from 'react'; 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'; @@ -108,11 +108,19 @@ export type TranslationFunction< ...[args]: TranslationFunctionOptions< NestedMessageKeys, PluralKeys, - IMessages + IMessages, + string > - ): HasJsxFormat extends true - ? ReactNode - : IMessages[TKey]; + ): IMessages[TKey]; + ( + key: TKey, + ...[args]: TranslationFunctionOptions< + NestedMessageKeys, + PluralKeys, + IMessages, + string | JSX_2.Element + > + ): JSX_2.Element; } : never; diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.tsx similarity index 79% rename from packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts rename to packages/core-plugin-api/src/apis/definitions/TranslationApi.test.tsx index 490601a9e8..f99001ecf6 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.tsx @@ -14,14 +14,17 @@ * limitations under the License. */ -import { ReactNode } from 'react'; +import { JSX } from 'react'; import { TranslationFunction } from './TranslationApi'; -function unused(..._any: any[]) {} +// This is a weak assertion, don't reuse unless you know the drawbacks +function expectType(value: T) { + return value; +} describe('TranslationFunction', () => { it('should infer plurals', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ key_one: 'one'; key_other: 'other'; thingCount_one: '{{count}} thing'; @@ -30,11 +33,11 @@ describe('TranslationFunction', () => { }>; expect(f).toBeDefined(); - f('foo'); + expectType(f('foo')); // @ts-expect-error f('foo', { count: 1 }); - f('key', { count: 1 }); + expectType(f('key', { count: 1 })); // @ts-expect-error f('key'); // @ts-expect-error @@ -48,7 +51,7 @@ describe('TranslationFunction', () => { // @ts-expect-error f('key_other', { count: 6 }); - f('thingCount', { count: 1 }); + expectType(f('thingCount', { count: 1 })); // @ts-expect-error f('thingCount'); // @ts-expect-error @@ -62,14 +65,13 @@ describe('TranslationFunction', () => { // @ts-expect-error f('thingCount_other', { count: 6 }); - const x1: 'one' | 'other' = f('key', { count: 6 }); + expectType<'one' | 'other'>(f('key', { count: 6 })); // @ts-expect-error - const x2: 'one' = f('key', { count: 6 }); - unused(x1, x2); + expectType<'one'>(f('key', { count: 6 })); }); it('should infer interpolation params', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ none: '='; simple: '= {{bar}}'; multiple: '= {{bar }} {{ baz}}'; @@ -79,7 +81,11 @@ describe('TranslationFunction', () => { // @ts-expect-error f('none', { replace: { unknown: 1 } }); - f('simple', { bar: '' }); + expectType(f('simple', { bar: '' })); + // @ts-expect-error + expectType(f('simple', { bar:
})); + expectType(f('simple', { bar:
})); + expectType(f('simple', { replace: { bar:
} })); // @ts-expect-error f('simple'); // @ts-expect-error @@ -88,7 +94,10 @@ describe('TranslationFunction', () => { f('simple', { replace: {} }); // @ts-expect-error f('simple', { replace: { wrong: '' } }); - f('multiple', { bar: '', baz: '' }); + expectType(f('multiple', { bar: '', baz: '' })); + expectType(f('multiple', { bar:
, baz: '' })); + expectType(f('multiple', { bar: '', baz:
})); + expectType(f('multiple', { bar:
, baz:
})); // @ts-expect-error f('multiple', { bar: '' }); // @ts-expect-error @@ -99,7 +108,12 @@ describe('TranslationFunction', () => { f('multiple', {}); // @ts-expect-error f('multiple', { replace: {} }); - f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }); + expectType( + f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c: '' } } } }), + ); + expectType( + f('deep', { replace: { x: { y: '', z: '' }, a: { b: { c:
} } } }), + ); // @ts-expect-error f('deep'); // @ts-expect-error @@ -115,7 +129,7 @@ describe('TranslationFunction', () => { }); it('should infer interpolation params with count', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ simple_one: '= {{bar}}'; simple_other: '= {{bar}}'; multiple_one: '= {{ bar}} {{baz }}'; @@ -164,7 +178,7 @@ describe('TranslationFunction', () => { }); it('should support formatting', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ none: '{{x}}'; number: '{{x, number}}'; numberOptions: '{{x, number(minimumFractionDigits: 2)}}'; @@ -172,28 +186,24 @@ 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' } }) satisfies string; - f('number', { x: 1 }) satisfies string; + f('none', { replace: { x: 'x' } }); + f('number', { x: 1 }); f('number', { replace: { x: 1 }, formatParams: { x: { minimumFractionDigits: 2 } }, - }) 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('numberOptions', { x: 1 }); + f('currency', { replace: { x: 1 } }); + f('datetime', { x: new Date() }); + f('relativeTimeOptions', { replace: { x: 1 } }); f('relativeTimeOptions', { replace: { x: 1 }, formatParams: { x: { style: 'short' } }, - }) satisfies string; - f('list', { replace: { x: ['a', 'b', 'c'] } }) satisfies string; - f('jsx', { replace: { x: '' } }) satisfies ReactNode; - f('jsxNested', { replace: { x: '' } }) satisfies ReactNode; + }); + f('list', { replace: { x: ['a', 'b', 'c'] } }); // @ts-expect-error f('none', { x: 1 }); // @ts-expect-error @@ -213,14 +223,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', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ simple: '$t(foo)'; nested: '$t(bar)'; nestedCount: '$t(qux)'; @@ -255,7 +261,7 @@ describe('TranslationFunction', () => { }); it('should limit nesting depth', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ a: '$t(b) {{a}}'; b: '$t(c) {{b}}'; c: '$t(d) {{c}}'; diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index c7e280f0da..7c3c3f8e83 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -17,7 +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'; +import { JSX } from 'react'; /** * Base translation options. @@ -65,10 +65,6 @@ type I18nextFormatMap = { type: string[]; options: Intl.ListFormatOptions; }; - jsx: { - type: ReactNode; - options: {}; - }; }; /** @@ -173,12 +169,12 @@ type ReplaceFormatsFromMessage = * * @ignore */ -type ReplaceOptionsFromFormats = { +type ReplaceOptionsFromFormats = { [Key in keyof TFormats]: TFormats[Key] extends keyof I18nextFormatMap ? I18nextFormatMap[TFormats[Key]]['type'] : TFormats[Key] extends {} - ? Expand> - : string; + ? Expand> + : TValueType; }; /** @@ -264,14 +260,17 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( type CollectOptions< TCount extends { count?: number }, TFormats extends {}, + TValueType, > = TCount & // count is special, omit it from the replacements (keyof Omit extends never ? {} : ( - | Expand, 'count'>> + | Expand, 'count'>> | { - replace: Expand, 'count'>>; + replace: Expand< + Omit, 'count'> + >; } ) & { formatParams?: Expand>; @@ -283,7 +282,7 @@ type CollectOptions< * @ignore */ type OptionArgs = keyof TOptions extends never - ? [options?: BaseOptions] + ? [options?: Expand] : [options: Expand]; /** @@ -293,49 +292,51 @@ type TranslationFunctionOptions< TKeys extends keyof TMessages, // All normalized message keys to be considered, i.e. included nested ones TPluralKeys extends keyof TMessages, // All keys in the message map that are pluralized TMessages extends { [key in string]: string }, // Collapsed message map with normalized keys and union values + TValueType, > = OptionArgs< Expand< CollectOptions< TKeys & TPluralKeys extends never ? {} : { count: number }, ExpandRecursive< UnionToIntersection> - > + >, + TValueType > > >; -/** - * @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 }, -> = 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; } ? { + /** + * A translation function that returns a string. + */ ( key: TKey, ...[args]: TranslationFunctionOptions< NestedMessageKeys, PluralKeys, - IMessages + IMessages, + string > - ): HasJsxFormat extends true - ? ReactNode - : IMessages[TKey]; + ): IMessages[TKey]; + /** + * A translation function where at least one JSX.Element has been + * provided as an interpolation value, and will therefore return a + * JSX.Element. + */ + ( + key: TKey, + ...[args]: TranslationFunctionOptions< + NestedMessageKeys, + PluralKeys, + IMessages, + string | JSX.Element + > + ): JSX.Element; } : never; diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.tsx similarity index 87% rename from packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts rename to packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.tsx index a1a1cd8ee2..4bee969a6d 100644 --- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.test.tsx @@ -94,6 +94,52 @@ describe('MockTranslationApi', () => { expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep'); }); + it('should support jsx interpolation', () => { + const snapshot = snapshotWithMessages({ + empty: 'derp', + jsx: '={{ x }}', + jsxNested: '={{ x.y.z }}', + jsxDeep: '<$t(jsx)>', + }); + + expect(snapshot.t('jsx', { x:

hello

})).toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect(snapshot.t('jsx', { replace: { x:

hello

} })) + .toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect( + snapshot.t('jsxNested', { replace: { x: { y: { z:

hello

} } } }), + ).toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect(snapshot.t('jsxDeep', { x:

hello

})).toMatchInlineSnapshot(` + + <= +

+ hello +

+ > +
+ `); + }); + it('should support formatting', () => { const snapshot = snapshotWithMessages({ plain: '= {{ x }}', @@ -104,8 +150,6 @@ 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'); @@ -148,19 +192,6 @@ 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 d2f53bacf0..344679355a 100644 --- a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts +++ b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts @@ -42,6 +42,8 @@ export class MockTranslationApi implements TranslationApi { supportedLngs: [DEFAULT_LANGUAGE], interpolation: { escapeValue: false, + // Used for the JsxInterpolator format hook + alwaysFormat: true, }, ns: [], defaultNS: false, @@ -56,8 +58,7 @@ export class MockTranslationApi implements TranslationApi { throw new Error('i18next was unexpectedly not initialized'); } - const interpolator = JsxInterpolator.create({ marker: '123456' }); - i18n.services.formatter?.add('jsx', interpolator.format); + const interpolator = JsxInterpolator.fromI18n(i18n); return new MockTranslationApi(i18n, interpolator); } @@ -88,7 +89,7 @@ export class MockTranslationApi implements TranslationApi { } const t = this.#interpolator.wrapT( - this.#i18n.getFixedT(null, internalRef.id) as any, + this.#i18n.getFixedT(null, internalRef.id), ); return {