1
1
import { FluentBundle , FluentVariable } from "@fluent/bundle" ;
2
2
import { mapBundleSync } from "@fluent/sequence" ;
3
+ import {
4
+ Fragment ,
5
+ ReactElement ,
6
+ createElement ,
7
+ isValidElement ,
8
+ cloneElement ,
9
+ } from "react" ;
3
10
import { CachedSyncIterable } from "cached-iterable" ;
4
11
import { 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 + ; / ;
5
17
6
18
/*
7
19
* `ReactLocalization` handles translation formatting and fallback.
@@ -38,15 +50,15 @@ export class ReactLocalization {
38
50
39
51
getString (
40
52
id : string ,
41
- args ?: Record < string , FluentVariable > | null ,
53
+ vars ?: Record < string , FluentVariable > | null ,
42
54
fallback ?: string
43
55
) : string {
44
56
const bundle = this . getBundle ( id ) ;
45
57
if ( bundle ) {
46
58
const msg = bundle . getMessage ( id ) ;
47
59
if ( msg && msg . value ) {
48
60
let errors : Array < Error > = [ ] ;
49
- let value = bundle . formatPattern ( msg . value , args , errors ) ;
61
+ let value = bundle . formatPattern ( msg . value , vars , errors ) ;
50
62
for ( let error of errors ) {
51
63
this . reportError ( error ) ;
52
64
}
@@ -73,6 +85,149 @@ export class ReactLocalization {
73
85
return fallback || id ;
74
86
}
75
87
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
+
76
231
// XXX Control this via a prop passed to the LocalizationProvider.
77
232
// See https://github.com/projectfluent/fluent.js/issues/411.
78
233
reportError ( error : Error ) : void {
0 commit comments