From 4cec91a152c04f0377c75ae87461c37cefa05601 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Thu, 1 Jan 2026 23:06:47 +0200 Subject: [PATCH] feat(react): Add a getFormattedMessage method to Localization --- fluent-react/src/localization.ts | 86 +++++++++++--------- fluent-react/src/with_localization.ts | 16 +++- fluent-react/test/with_localization.test.jsx | 37 +++++++++ 3 files changed, 100 insertions(+), 39 deletions(-) diff --git a/fluent-react/src/localization.ts b/fluent-react/src/localization.ts index ce1d3d28..c5006ead 100644 --- a/fluent-react/src/localization.ts +++ b/fluent-react/src/localization.ts @@ -64,8 +64,8 @@ export class ReactLocalization { ): string { const bundle = this.getBundle(id); if (bundle) { - const msg = bundle.getMessage(id); - if (msg && msg.value) { + const msg = bundle.getMessage(id)!; + if (msg.value) { let errors: Array = []; let value = bundle.formatPattern(msg.value, vars, errors); for (let error of errors) { @@ -74,26 +74,51 @@ export class ReactLocalization { return value; } } else { - if (this.areBundlesEmpty()) { - this.reportError( - new Error( - "Attempting to get a string when no localization bundles are " + - "present." - ) - ); - } else { - this.reportError( - new Error( - `The id "${id}" did not match any messages in the localization ` + - "bundles." - ) - ); - } + const msg = this.areBundlesEmpty() + ? "Attempting to get a string when no localization bundles are present." + : `The id "${id}" did not match any messages in the localization bundles.`; + this.reportError(new Error(msg)); } return fallback || id; } + getFormattedMessage( + id: string, + vars?: Record | null + ): { + value: string | null; + attributes?: Record; + } { + const bundle = this.getBundle(id); + if (bundle === null) { + const msg = this.areBundlesEmpty() + ? "Attempting to get a localized message when no localization bundles are present." + : `The id "${id}" did not match any messages in the localization bundles.`; + this.reportError(new Error(msg)); + return { value: null }; + } + + let value: string | null = null; + let attributes: Record | null = null; + const msg = bundle.getMessage(id)!; + let errors: Array = []; + if (msg.value) { + value = bundle.formatPattern(msg.value, vars, errors); + } + if (msg.attributes) { + attributes = Object.create(null) as Record; + for (const [name, pattern] of Object.entries(msg.attributes)) { + attributes[name] = bundle.formatPattern(pattern, vars, errors); + } + } + for (let error of errors) { + this.reportError(error); + } + + return attributes ? { value, attributes } : { value }; + } + getElement( sourceElement: ReactElement, id: string, @@ -105,26 +130,13 @@ export class ReactLocalization { ): ReactElement { const bundle = this.getBundle(id); if (bundle === null) { - if (!id) { - this.reportError( - new Error("No string id was provided when localizing a component.") - ); - } else if (this.areBundlesEmpty()) { - this.reportError( - new Error( - "Attempting to get a localized element when no localization bundles are " + - "present." - ) - ); - } else { - this.reportError( - new Error( - `The id "${id}" did not match any messages in the localization ` + - "bundles." - ) - ); - } - + // eslint-disable-next-line no-nested-ternary + const msg = !id + ? "No string id was provided when localizing a component." + : this.areBundlesEmpty() + ? "Attempting to get a localized element when no localization bundles are present." + : `The id "${id}" did not match any messages in the localization bundles.`; + this.reportError(new Error(msg)); return createElement(Fragment, null, sourceElement); } diff --git a/fluent-react/src/with_localization.ts b/fluent-react/src/with_localization.ts index e8aabd69..7839b7bd 100644 --- a/fluent-react/src/with_localization.ts +++ b/fluent-react/src/with_localization.ts @@ -5,9 +5,16 @@ import { FluentVariable } from "@fluent/bundle"; export interface WithLocalizationProps { getString( id: string, - args?: Record | null, + vars?: Record | null, fallback?: string ): string; + getFormattedMessage( + id: string, + vars?: Record | null + ): { + value: string | null; + attributes?: Record; + }; } export function withLocalization

( @@ -27,7 +34,12 @@ export function withLocalization

( } // Re-bind getString to trigger a re-render of Inner. const getString = l10n.getString.bind(l10n); - return createElement(Inner, { getString, ...props } as P); + const getFormattedMessage = l10n.getFormattedMessage.bind(l10n); + return createElement(Inner, { + getString, + getFormattedMessage, + ...props, + } as P); } WithLocalization.displayName = `WithLocalization(${displayName(Inner)})`; diff --git a/fluent-react/test/with_localization.test.jsx b/fluent-react/test/with_localization.test.jsx index 08fd9857..b0b27447 100644 --- a/fluent-react/test/with_localization.test.jsx +++ b/fluent-react/test/with_localization.test.jsx @@ -188,4 +188,41 @@ bar = BAR {$arg} expect(renderer.toJSON()).toMatchInlineSnapshot(`"BAR"`); }); + + test("getFormattedMessage with access to the l10n context", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const bundle = new FluentBundle("en", { useIsolating: false }); + const EnhancedComponent = withLocalization(DummyComponent); + + bundle.addResource( + new FluentResource(` +foo = FOO + .bar = BAR {$arg} +`) + ); + + const renderer = TestRenderer.create( + + + + ); + + const { getFormattedMessage } = + renderer.root.findByType(DummyComponent).props; + + // Returns the translation. + expect(getFormattedMessage("foo", { arg: "ARG" })).toEqual({ + value: "FOO", + attributes: { bar: "BAR ARG" }, + }); + + // It reports an error on formatting errors, but doesn't throw. + expect(getFormattedMessage("foo", {})).toEqual({ + value: "FOO", + attributes: { bar: "BAR {$arg}" }, + }); + expect(console.warn.mock.calls).toEqual([ + ["[@fluent/react] ReferenceError: Unknown variable: $arg"], + ]); + }); });