diff --git a/.changeset/brave-donuts-sink.md b/.changeset/brave-donuts-sink.md new file mode 100644 index 0000000000..47827345a5 --- /dev/null +++ b/.changeset/brave-donuts-sink.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Added support for interpolating JSX elements with the `MockTranslationApi`. diff --git a/.changeset/cool-bikes-push.md b/.changeset/cool-bikes-push.md new file mode 100644 index 0000000000..00825c1ad0 --- /dev/null +++ b/.changeset/cool-bikes-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Updated `I18nextTranslationApi` to support interpolation of JSX elements. diff --git a/.changeset/wicked-dingos-stand.md b/.changeset/wicked-dingos-stand.md new file mode 100644 index 0000000000..92d84825d1 --- /dev/null +++ b/.changeset/wicked-dingos-stand.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +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 1fbf54d94e..4eb5e864d1 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -147,6 +147,42 @@ export const myPluginTranslationRef = createTranslationRef({ }); ``` +#### JSX Elements + +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: + +```ts title="define the message" +export const myPluginTranslationRef = createTranslationRef({ + id: 'plugin.my-plugin', + messages: { + entityPage: { + redirect: { + message: 'The entity you are looking for has been moved to {{link}}.', + link: '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.message', { + link: {t('entityPage.redirect.link')}, + })} +
+); +``` + +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 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 91% 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..b6e79b20ed 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,67 @@ describe('I18nextTranslationApi', () => { expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps'); expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps'); }); + + it('should support jsx interpolation', () => { + const snapshot = snapshotWithMessages({ + jsx: '{{ hello }}, {{ world }}!', + jsxMultiple: '{{ hello }} | {{ hello }}', + jsxNested: '$t(foo), $t(bar)', + foo: 'foo={{ foo }}', + bar: 'bar={{ bar }}', + }); + + expect( + render( + snapshot.t('jsx', { + hello:

Hello

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

world

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

hello

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

hello

, + } as any), + ).container.textContent, + ).toBe('hello | hello'); + + expect( + render( + snapshot.t('jsxNested', { + 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..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,6 +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, isValidElement } from 'react'; /** @alpha */ export interface I18nextTranslationApiOptions { @@ -137,6 +144,98 @@ class ResourceLoader { } } +/** + * A helper for implementing JSX interpolation + */ +export class JsxInterpolator { + readonly #setFormatHook: (hook: FormatFunction) => void; + readonly #marker: string; + readonly #pattern: RegExp; + + 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( + // 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, + setFormatHook: (hook: FormatFunction) => void, + ) { + this.#setFormatHook = setFormatHook; + this.#marker = marker; + this.#pattern = new RegExp(`\\$${marker}\\(([^)]+)\\)`); + } + + wrapT( + originalT: TFunction, + ): TranslationFunction { + 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(key, options as any) as unknown as string; + if (!elementsMap) { + return result; + } + + const split = result.split(this.#pattern); + + return createElement( + Fragment, + null, + ...split + .map((part, index) => { + if (index % 2 === 0) { + return part; + } + return elementsMap?.get(part); + }) + .filter(Boolean), + ); + }) as TranslationFunction; + } +} + /** @alpha */ export class I18nextTranslationApi implements TranslationApi { static create(options: I18nextTranslationApiOptions) { @@ -147,6 +246,8 @@ export class I18nextTranslationApi implements TranslationApi { supportedLngs: languages, interpolation: { escapeValue: false, + // Used for the JsxInterpolator format hook + alwaysFormat: true, }, ns: [], defaultNS: false, @@ -161,6 +262,8 @@ export class I18nextTranslationApi implements TranslationApi { throw new Error('i18next was unexpectedly not initialized'); } + const interpolator = JsxInterpolator.fromI18n(i18n); + const { language: initialLanguage } = options.languageApi.getLanguage(); if (initialLanguage !== DEFAULT_LANGUAGE) { i18n.changeLanguage(initialLanguage); @@ -198,6 +301,7 @@ export class I18nextTranslationApi implements TranslationApi { i18n, loader, options.languageApi.getLanguage().language, + interpolator, ); options.languageApi.language$().subscribe(({ language }) => { @@ -210,16 +314,23 @@ export class I18nextTranslationApi implements TranslationApi { #i18n: I18n; #loader: ResourceLoader; #language: string; + #jsxInterpolator: JsxInterpolator; /** 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, + jsxInterpolator: JsxInterpolator, + ) { this.#i18n = i18n; this.#loader = loader; this.#language = language; + this.#jsxInterpolator = jsxInterpolator; } getTranslation( @@ -297,10 +408,8 @@ 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 = 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 888c0ffea0..6243c76cff 100644 --- a/packages/core-plugin-api/report-alpha.api.md +++ b/packages/core-plugin-api/report-alpha.api.md @@ -6,6 +6,7 @@ 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 { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha'; import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha'; @@ -94,21 +95,34 @@ 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, + string + > + ): IMessages[TKey]; + ( + key: TKey, + ...[args]: TranslationFunctionOptions< + NestedMessageKeys, + PluralKeys, + IMessages, + string | JSX_2.Element + > + ): JSX_2.Element; + } + : never; // @alpha export interface TranslationMessages< 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 82% 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 ba5d43e896..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,13 +14,17 @@ * limitations under the License. */ +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'; @@ -29,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 @@ -47,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 @@ -61,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}}'; @@ -78,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 @@ -87,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 @@ -98,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 @@ -114,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 }}'; @@ -163,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)}}'; @@ -211,7 +226,7 @@ describe('TranslationFunction', () => { }); it('should support nesting', () => { - const f = (() => {}) as TranslationFunction<{ + const f = (() => {}) as unknown as TranslationFunction<{ simple: '$t(foo)'; nested: '$t(bar)'; nestedCount: '$t(qux)'; @@ -246,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 dba8753828..7c3c3f8e83 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 { JSX } from 'react'; /** * Base translation options. @@ -168,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; }; /** @@ -259,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>; @@ -278,8 +282,8 @@ type CollectOptions< * @ignore */ type OptionArgs = keyof TOptions extends never - ? [options?: BaseOptions] - : [options: BaseOptions & TOptions]; + ? [options?: Expand] + : [options: Expand]; /** * @ignore @@ -288,30 +292,53 @@ 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 > > >; /** @alpha */ -export interface TranslationFunction< - TMessages extends { [key in string]: string }, -> { - >( - key: TKey, - ...[args]: TranslationFunctionOptions< - NestedMessageKeys>, - PluralKeys, - CollapsedMessages - > - ): CollapsedMessages[TKey]; -} +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, + string + > + ): 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; /** @alpha */ export type TranslationSnapshot = 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 84% 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 0cc755ecb5..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 }}', diff --git a/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts b/packages/test-utils/src/testUtils/apis/TranslationApi/MockTranslationApi.ts index 46c5bfd5d3..344679355a 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'; @@ -41,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, @@ -55,14 +58,18 @@ export class MockTranslationApi implements TranslationApi { throw new Error('i18next was unexpectedly not initialized'); } - return new MockTranslationApi(i18n); + const interpolator = JsxInterpolator.fromI18n(i18n); + + 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 +88,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), + ); return { ready: true,