diff --git a/.changeset/cool-bikes-push.md b/.changeset/cool-bikes-push.md new file mode 100644 index 0000000000..f78510f37e --- /dev/null +++ b/.changeset/cool-bikes-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Updated `I18nextTranslationApi` to support interpolation with the new `jsx` format. diff --git a/.changeset/wicked-dingos-stand.md b/.changeset/wicked-dingos-stand.md new file mode 100644 index 0000000000..39d59aee1d --- /dev/null +++ b/.changeset/wicked-dingos-stand.md @@ -0,0 +1,5 @@ +--- +'@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`. diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 1fbf54d94e..1dfcca17e7 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -147,6 +147,43 @@ export const myPluginTranslationRef = createTranslationRef({ }); ``` +#### React Nodes + +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`. + +For example, you might define the following messages: + +```ts title="define the message" +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', + }, + }, +}); +``` + +Which can be used within a component like this: + +```tsx title="use within a component" +const { t } = useTranslationRef(myPluginTranslationRef); + +return ( +
+ {t('entityPage.redirect', { + link: {t('entityPage.newLocation')}, + })} +
+); +``` + +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. + ## For an application developer overwrite plugin messages Step 1: Create translation resources diff --git a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx similarity index 93% rename from packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts rename to packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx index d5b812c5d7..54d84d2aed 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.test.tsx @@ -23,6 +23,7 @@ import { import { Observable } from '@backstage/types'; import { AppLanguageSelector } from '../AppLanguageApi'; import { I18nextTranslationApi } from './I18nextTranslationApi'; +import { render } from '@testing-library/react'; const plainRef = createTranslationRef({ id: 'plain', @@ -538,5 +539,56 @@ describe('I18nextTranslationApi', () => { expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps'); expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps'); }); + + it('should support jsx formatting', () => { + const snapshot = snapshotWithMessages({ + jsx: '{{ hello, jsx }}, {{ world, jsx }}!', + }); + + expect( + render( + snapshot.t('jsx', { + replace: { + hello:

Hello

, + world:
World
, + }, + }), + ).container.textContent, + ).toBe('Hello, World!'); + + expect( + render( + snapshot.t('jsx', { + hello:

world

, + world:
hello
, + }), + ).container.textContent, + ).toBe('world, 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 }}', + }); + + expect( + render( + snapshot.t('message', { + foo: ( +
+ foo +
+ ), + bar: ( +
+ bar +
+ ), + }), + ).container.textContent, + ).toBe('foo=foo, bar=bar'); + }); }); }); 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 d0ef35568f..6f29a440c2 100644 --- a/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts +++ b/packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi.ts @@ -39,6 +39,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'; /** @alpha */ export interface I18nextTranslationApiOptions { @@ -161,6 +162,21 @@ 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 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 { language: initialLanguage } = options.languageApi.getLanguage(); if (initialLanguage !== DEFAULT_LANGUAGE) { i18n.changeLanguage(initialLanguage); @@ -198,6 +214,7 @@ export class I18nextTranslationApi implements TranslationApi { i18n, loader, options.languageApi.getLanguage().language, + elementMarkerPattern, ); options.languageApi.language$().subscribe(({ language }) => { @@ -210,16 +227,23 @@ export class I18nextTranslationApi implements TranslationApi { #i18n: I18n; #loader: ResourceLoader; #language: string; + #elementMarkerPattern: RegExp; /** Keep track of which refs we have registered default resources for */ #registeredRefs = new Set(); /** Notify observers when language changes */ #languageChangeListeners = new Set<() => void>(); - private constructor(i18n: I18n, loader: ResourceLoader, language: string) { + private constructor( + i18n: I18n, + loader: ResourceLoader, + language: string, + elementMarkerPattern: RegExp, + ) { this.#i18n = i18n; this.#loader = loader; this.#language = language; + this.#elementMarkerPattern = elementMarkerPattern; } getTranslation( @@ -297,10 +321,39 @@ export class I18nextTranslationApi implements TranslationApi { return { ready: false }; } - const t = this.#i18n.getFixedT( - null, - internalRef.id, - ) as TranslationFunction; + 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; return { ready: true, diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts index ba5d43e896..490601a9e8 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ReactNode } from 'react'; import { TranslationFunction } from './TranslationApi'; function unused(..._any: any[]) {} @@ -171,24 +172,28 @@ 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' } }); - f('number', { x: 1 }); + f('none', { replace: { x: 'x' } }) satisfies string; + f('number', { x: 1 }) satisfies string; f('number', { replace: { x: 1 }, formatParams: { x: { minimumFractionDigits: 2 } }, - }); - f('numberOptions', { x: 1 }); - f('currency', { replace: { x: 1 } }); - f('datetime', { x: new Date() }); - f('relativeTimeOptions', { replace: { x: 1 } }); + }) 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('relativeTimeOptions', { replace: { x: 1 }, formatParams: { x: { style: 'short' } }, - }); - f('list', { replace: { x: ['a', 'b', 'c'] } }); + }) satisfies string; + f('list', { replace: { x: ['a', 'b', 'c'] } }) satisfies string; + f('jsx', { replace: { x: '' } }) satisfies ReactNode; + f('jsxNested', { replace: { x: '' } }) satisfies ReactNode; // @ts-expect-error f('none', { x: 1 }); // @ts-expect-error @@ -208,6 +213,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', () => { diff --git a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts index dba8753828..c7e280f0da 100644 --- a/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/TranslationApi.ts @@ -17,6 +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'; /** * Base translation options. @@ -64,6 +65,10 @@ type I18nextFormatMap = { type: string[]; options: Intl.ListFormatOptions; }; + jsx: { + type: ReactNode; + options: {}; + }; }; /** @@ -279,7 +284,7 @@ type CollectOptions< */ type OptionArgs = keyof TOptions extends never ? [options?: BaseOptions] - : [options: BaseOptions & TOptions]; + : [options: Expand]; /** * @ignore @@ -299,19 +304,40 @@ type TranslationFunctionOptions< > >; -/** @alpha */ -export interface TranslationFunction< +/** + * @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 }, -> { - >( - key: TKey, - ...[args]: TranslationFunctionOptions< - NestedMessageKeys>, - PluralKeys, - CollapsedMessages - > - ): CollapsedMessages[TKey]; -} +> = 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; + } + ? { + ( + key: TKey, + ...[args]: TranslationFunctionOptions< + NestedMessageKeys, + PluralKeys, + IMessages + > + ): HasJsxFormat extends true + ? ReactNode + : IMessages[TKey]; + } + : never; /** @alpha */ export type TranslationSnapshot =