11import { FluentBundle , FluentVariable } from "@fluent/bundle" ;
22import { mapBundleSync } from "@fluent/sequence" ;
3+ import {
4+ Fragment ,
5+ ReactElement ,
6+ createElement ,
7+ isValidElement ,
8+ cloneElement ,
9+ } from "react" ;
310import { CachedSyncIterable } from "cached-iterable" ;
411import { createParseMarkup , MarkupParser } from "./markup.js" ;
12+ import voidElementTags from "../vendor/voidElementTags.js" ;
13+
14+ // Match the opening angle bracket (<) in HTML tags, and HTML entities like
15+ // &, &, &.
16+ const reMarkup = / < | & # ? \w + ; / ;
517
618/*
719 * `ReactLocalization` handles translation formatting and fallback.
@@ -38,15 +50,15 @@ export class ReactLocalization {
3850
3951 getString (
4052 id : string ,
41- args ?: Record < string , FluentVariable > | null ,
53+ vars ?: Record < string , FluentVariable > | null ,
4254 fallback ?: string
4355 ) : string {
4456 const bundle = this . getBundle ( id ) ;
4557 if ( bundle ) {
4658 const msg = bundle . getMessage ( id ) ;
4759 if ( msg && msg . value ) {
4860 let errors : Array < Error > = [ ] ;
49- let value = bundle . formatPattern ( msg . value , args , errors ) ;
61+ let value = bundle . formatPattern ( msg . value , vars , errors ) ;
5062 for ( let error of errors ) {
5163 this . reportError ( error ) ;
5264 }
@@ -73,6 +85,149 @@ export class ReactLocalization {
7385 return fallback || id ;
7486 }
7587
88+ getElement (
89+ sourceElement : ReactElement ,
90+ id : string ,
91+ args : {
92+ vars ?: Record < string , FluentVariable > ;
93+ elems ?: Record < string , ReactElement > ;
94+ attrs ?: Record < string , boolean > ;
95+ } = { }
96+ ) : ReactElement {
97+ const bundle = this . getBundle ( id ) ;
98+ if ( bundle === null ) {
99+ if ( ! id ) {
100+ this . reportError (
101+ new Error ( "No string id was provided when localizing a component." )
102+ ) ;
103+ } else if ( this . areBundlesEmpty ( ) ) {
104+ this . reportError (
105+ new Error (
106+ "Attempting to get a localized element when no localization bundles are " +
107+ "present."
108+ )
109+ ) ;
110+ } else {
111+ this . reportError (
112+ new Error (
113+ `The id "${ id } " did not match any messages in the localization ` +
114+ "bundles."
115+ )
116+ ) ;
117+ }
118+
119+ return createElement ( Fragment , null , sourceElement ) ;
120+ }
121+
122+ // this.getBundle makes the bundle.hasMessage check which ensures that
123+ // bundle.getMessage returns an existing message.
124+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
125+ const msg = bundle . getMessage ( id ) ! ;
126+
127+ let errors : Array < Error > = [ ] ;
128+
129+ let localizedProps : Record < string , string > | undefined ;
130+ // The default is to forbid all message attributes. If the attrs prop exists
131+ // on the Localized instance, only set message attributes which have been
132+ // explicitly allowed by the developer.
133+ if ( args . attrs && msg . attributes ) {
134+ localizedProps = { } ;
135+ errors = [ ] ;
136+ for ( const [ name , allowed ] of Object . entries ( args . attrs ) ) {
137+ if ( allowed && name in msg . attributes ) {
138+ localizedProps [ name ] = bundle . formatPattern (
139+ msg . attributes [ name ] ,
140+ args . vars ,
141+ errors
142+ ) ;
143+ }
144+ }
145+ for ( let error of errors ) {
146+ this . reportError ( error ) ;
147+ }
148+ }
149+
150+ // If the component to render is a known void element, explicitly dismiss the
151+ // message value and do not pass it to cloneElement in order to avoid the
152+ // "void element tags must neither have `children` nor use
153+ // `dangerouslySetInnerHTML`" error.
154+ if (
155+ typeof sourceElement . type === "string" &&
156+ sourceElement . type in voidElementTags
157+ ) {
158+ return cloneElement ( sourceElement , localizedProps ) ;
159+ }
160+
161+ // If the message has a null value, we're only interested in its attributes.
162+ // Do not pass the null value to cloneElement as it would nuke all children
163+ // of the wrapped component.
164+ if ( msg . value === null ) {
165+ return cloneElement ( sourceElement , localizedProps ) ;
166+ }
167+
168+ errors = [ ] ;
169+ const messageValue = bundle . formatPattern ( msg . value , args . vars , errors ) ;
170+ for ( let error of errors ) {
171+ this . reportError ( error ) ;
172+ }
173+
174+ // If the message value doesn't contain any markup nor any HTML entities,
175+ // insert it as the only child of the component to render.
176+ if ( ! reMarkup . test ( messageValue ) || this . parseMarkup === null ) {
177+ return cloneElement ( sourceElement , localizedProps , messageValue ) ;
178+ }
179+
180+ let elemsLower : Map < string , ReactElement > ;
181+ if ( args . elems ) {
182+ elemsLower = new Map ( ) ;
183+ for ( let [ name , elem ] of Object . entries ( args . elems ) ) {
184+ // Ignore elems which are not valid React elements.
185+ if ( ! isValidElement ( elem ) ) {
186+ continue ;
187+ }
188+ elemsLower . set ( name . toLowerCase ( ) , elem ) ;
189+ }
190+ }
191+
192+ // If the message contains markup, parse it and try to match the children
193+ // found in the translation with the args passed to this function.
194+ const translationNodes = this . parseMarkup ( messageValue ) ;
195+ const translatedChildren = translationNodes . map (
196+ ( { nodeName, textContent } ) => {
197+ if ( nodeName === "#text" ) {
198+ return textContent ;
199+ }
200+
201+ const childName = nodeName . toLowerCase ( ) ;
202+ const sourceChild = elemsLower ?. get ( childName ) ;
203+
204+ // If the child is not expected just take its textContent.
205+ if ( ! sourceChild ) {
206+ return textContent ;
207+ }
208+
209+ // If the element passed in the elems prop is a known void element,
210+ // explicitly dismiss any textContent which might have accidentally been
211+ // defined in the translation to prevent the "void element tags must not
212+ // have children" error.
213+ if (
214+ typeof sourceChild . type === "string" &&
215+ sourceChild . type in voidElementTags
216+ ) {
217+ return sourceChild ;
218+ }
219+
220+ // TODO Protect contents of elements wrapped in <Localized>
221+ // https://github.com/projectfluent/fluent.js/issues/184
222+ // TODO Control localizable attributes on elements passed as props
223+ // https://github.com/projectfluent/fluent.js/issues/185
224+ return cloneElement ( sourceChild , undefined , textContent ) ;
225+ }
226+ ) ;
227+
228+ return cloneElement ( sourceElement , localizedProps , ...translatedChildren ) ;
229+ }
230+
76231 // XXX Control this via a prop passed to the LocalizationProvider.
77232 // See https://github.com/projectfluent/fluent.js/issues/411.
78233 reportError ( error : Error ) : void {
0 commit comments