Skip to content

Commit e99614e

Browse files
authored
Add getElement method to ReactLocalization (#595)
1 parent 8c2eae4 commit e99614e

File tree

5 files changed

+232
-182
lines changed

5 files changed

+232
-182
lines changed

fluent-react/src/localization.ts

+157-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import { FluentBundle, FluentVariable } from "@fluent/bundle";
22
import { mapBundleSync } from "@fluent/sequence";
3+
import {
4+
Fragment,
5+
ReactElement,
6+
createElement,
7+
isValidElement,
8+
cloneElement,
9+
} from "react";
310
import { CachedSyncIterable } from "cached-iterable";
411
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+
// &amp;, &#0038;, &#x0026;.
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

Comments
 (0)