Skip to content

Commit 1803c30

Browse files
committed
Split Localized into LocalizedElement and LocalizedText
1 parent 36d09ae commit 1803c30

File tree

4 files changed

+263
-174
lines changed

4 files changed

+263
-174
lines changed

fluent-react/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,7 @@ export { ReactLocalization} from "./localization";
2121
export { LocalizationProvider } from "./provider";
2222
export { withLocalization, WithLocalizationProps } from "./with_localization";
2323
export { Localized, LocalizedProps } from "./localized";
24+
export { LocalizedElement, LocalizedElementProps } from "./localized_element";
25+
export { LocalizedText, LocalizedTextProps } from "./localized_text";
2426
export { MarkupParser } from "./markup";
2527
export { useLocalization } from "./use_localization";

fluent-react/src/localized.ts

+12-174
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,8 @@
1-
import {
2-
Fragment,
3-
ReactElement,
4-
ReactNode,
5-
cloneElement,
6-
createElement,
7-
isValidElement,
8-
useContext
9-
} from "react";
1+
import { ReactElement, ReactNode, createElement } from "react";
102
import PropTypes from "prop-types";
11-
import voidElementTags from "../vendor/voidElementTags";
12-
import { FluentContext } from "./context";
133
import { FluentVariable } from "@fluent/bundle";
14-
15-
// Match the opening angle bracket (<) in HTML tags, and HTML entities like
16-
// &amp;, &#0038;, &#x0026;.
17-
const reMarkup = /<|&#?\w+;/;
4+
import { LocalizedElement } from "./localized_element";
5+
import { LocalizedText } from "./localized_text";
186

197
export interface LocalizedProps {
208
id: string;
@@ -24,171 +12,21 @@ export interface LocalizedProps {
2412
elems?: Record<string, ReactElement>;
2513
}
2614
/*
27-
* The `Localized` class renders its child with translated props and children.
28-
*
29-
* <Localized id="hello-world">
30-
* <p>{'Hello, world!'}</p>
31-
* </Localized>
32-
*
33-
* The `id` prop should be the unique identifier of the translation. Any
34-
* attributes found in the translation will be applied to the wrapped element.
35-
*
36-
* Arguments to the translation can be passed as `$`-prefixed props on
37-
* `Localized`.
38-
*
39-
* <Localized id="hello-world" $username={name}>
40-
* <p>{'Hello, { $username }!'}</p>
41-
* </Localized>
42-
*
43-
* It's recommended that the contents of the wrapped component be a string
44-
* expression. The string will be used as the ultimate fallback if no
45-
* translation is available. It also makes it easy to grep for strings in the
46-
* source code.
15+
* The `Localized` component redirects to `LocalizedElement` or
16+
* `LocalizedText`, depending on props.children.
4717
*/
4818
export function Localized(props: LocalizedProps): ReactElement {
49-
const { id, attrs, vars, elems, children: child = null } = props;
50-
const l10n = useContext(FluentContext);
51-
52-
// Validate that the child element isn't an array
53-
if (Array.isArray(child)) {
54-
throw new Error("<Localized/> expected to receive a single " +
55-
"React node child");
19+
if (!props.children || typeof props.children === "string") {
20+
// Redirect to LocalizedText for string children: <Localized>Fallback
21+
// copy</Localized>, and empty calls: <Localized />.
22+
return createElement(LocalizedText, props);
5623
}
5724

58-
if (!l10n) {
59-
// Use the wrapped component as fallback.
60-
return createElement(Fragment, null, child);
61-
}
62-
63-
const bundle = l10n.getBundle(id);
64-
65-
if (bundle === null) {
66-
// Use the wrapped component as fallback.
67-
return createElement(Fragment, null, child);
68-
}
69-
70-
// l10n.getBundle makes the bundle.hasMessage check which ensures that
71-
// bundle.getMessage returns an existing message.
72-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
73-
const msg = bundle.getMessage(id)!;
74-
let errors: Array<Error> = [];
75-
76-
// Check if the child inside <Localized> is a valid element -- if not, then
77-
// it's either null or a simple fallback string. No need to localize the
78-
// attributes.
79-
if (!isValidElement(child)) {
80-
if (msg.value) {
81-
// Replace the fallback string with the message value;
82-
let value = bundle.formatPattern(msg.value, vars, errors);
83-
for (let error of errors) {
84-
l10n.reportError(error);
85-
}
86-
return createElement(Fragment, null, value);
87-
}
88-
89-
return createElement(Fragment, null, child);
90-
}
91-
92-
let localizedProps: Record<string, string> | undefined;
93-
94-
// The default is to forbid all message attributes. If the attrs prop exists
95-
// on the Localized instance, only set message attributes which have been
96-
// explicitly allowed by the developer.
97-
if (attrs && msg.attributes) {
98-
localizedProps = {};
99-
errors = [];
100-
for (const [name, allowed] of Object.entries(attrs)) {
101-
if (allowed && name in msg.attributes) {
102-
localizedProps[name] = bundle.formatPattern(
103-
msg.attributes[name], vars, errors);
104-
}
105-
}
106-
for (let error of errors) {
107-
l10n.reportError(error);
108-
}
109-
}
110-
111-
// If the wrapped component is a known void element, explicitly dismiss the
112-
// message value and do not pass it to cloneElement in order to avoid the
113-
// "void element tags must neither have `children` nor use
114-
// `dangerouslySetInnerHTML`" error.
115-
if (child.type in voidElementTags) {
116-
return cloneElement(child, localizedProps);
117-
}
118-
119-
// If the message has a null value, we're only interested in its attributes.
120-
// Do not pass the null value to cloneElement as it would nuke all children
121-
// of the wrapped component.
122-
if (msg.value === null) {
123-
return cloneElement(child, localizedProps);
124-
}
125-
126-
errors = [];
127-
const messageValue = bundle.formatPattern(msg.value, vars, errors);
128-
for (let error of errors) {
129-
l10n.reportError(error);
130-
}
131-
132-
// If the message value doesn't contain any markup nor any HTML entities,
133-
// insert it as the only child of the wrapped component.
134-
if (!reMarkup.test(messageValue) || l10n.parseMarkup === null) {
135-
return cloneElement(child, localizedProps, messageValue);
136-
}
137-
138-
let elemsLower: Record<string, ReactElement>;
139-
if (elems) {
140-
elemsLower = {};
141-
for (let [name, elem] of Object.entries(elems)) {
142-
elemsLower[name.toLowerCase()] = elem;
143-
}
144-
}
145-
146-
147-
// If the message contains markup, parse it and try to match the children
148-
// found in the translation with the props passed to this Localized.
149-
const translationNodes = l10n.parseMarkup(messageValue);
150-
const translatedChildren = translationNodes.map(childNode => {
151-
if (childNode.nodeName === "#text") {
152-
return childNode.textContent;
153-
}
154-
155-
const childName = childNode.nodeName.toLowerCase();
156-
157-
// If the child is not expected just take its textContent.
158-
if (
159-
!elemsLower ||
160-
!Object.prototype.hasOwnProperty.call(elemsLower, childName)
161-
) {
162-
return childNode.textContent;
163-
}
164-
165-
const sourceChild = elemsLower[childName];
166-
167-
// Ignore elems which are not valid React elements.
168-
if (!isValidElement(sourceChild)) {
169-
return childNode.textContent;
170-
}
171-
172-
// If the element passed in the elems prop is a known void element,
173-
// explicitly dismiss any textContent which might have accidentally been
174-
// defined in the translation to prevent the "void element tags must not
175-
// have children" error.
176-
if (sourceChild.type in voidElementTags) {
177-
return sourceChild;
178-
}
179-
180-
// TODO Protect contents of elements wrapped in <Localized>
181-
// https://github.com/projectfluent/fluent.js/issues/184
182-
// TODO Control localizable attributes on elements passed as props
183-
// https://github.com/projectfluent/fluent.js/issues/185
184-
return cloneElement(sourceChild, undefined, childNode.textContent);
185-
});
186-
187-
return cloneElement(child, localizedProps, ...translatedChildren);
25+
// Redirect to LocalizedElement for element children. Only a single element
26+
// child is supported; LocalizedElement enforces this requirement.
27+
return createElement(LocalizedElement, props);
18828
}
18929

190-
export default Localized;
191-
19230
Localized.propTypes = {
19331
children: PropTypes.node
19432
};

fluent-react/src/localized_element.ts

+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import {
2+
Fragment,
3+
ReactElement,
4+
ReactNode,
5+
cloneElement,
6+
createElement,
7+
isValidElement,
8+
useContext
9+
} from "react";
10+
import PropTypes from "prop-types";
11+
import voidElementTags from "../vendor/voidElementTags";
12+
import { FluentContext } from "./context";
13+
import { FluentVariable } from "@fluent/bundle";
14+
15+
// Match the opening angle bracket (<) in HTML tags, and HTML entities like
16+
// &amp;, &#0038;, &#x0026;.
17+
const reMarkup = /<|&#?\w+;/;
18+
19+
export interface LocalizedElementProps {
20+
id: string;
21+
attrs?: Record<string, boolean>;
22+
children?: ReactNode;
23+
vars?: Record<string, FluentVariable>;
24+
elems?: Record<string, ReactElement>;
25+
}
26+
/*
27+
* The `LocalizedElement` component renders its child with translated contents
28+
* and props.
29+
*
30+
* <Localized id="hello-world">
31+
* <p>Hello, world!</p>
32+
* </Localized>
33+
*
34+
* Arguments to the translation can be passed as an object in the `vars` prop.
35+
*
36+
* <LocalizedElement id="hello-world" vars={{userName: name}}>
37+
* <p>{'Hello, {$userName}!'}</p>
38+
* </LocalizedElement>
39+
*
40+
* The props of the wrapped child can be localized using Fluent attributes
41+
* found on the requested message, provided they are explicitly allowed by the
42+
* `attrs` prop.
43+
*
44+
* <LocalizedElement id="hello-world" attrs={{title: true}}>
45+
* <p>Hello, world!</p>
46+
* </LocalizedElement>
47+
*/
48+
export function LocalizedElement(props: LocalizedElementProps): ReactElement {
49+
const { id, attrs, vars, elems, children: child = null } = props;
50+
51+
// Check if the child inside <LocalizedElement> is a valid element.
52+
if (!isValidElement(child)) {
53+
throw new Error("<LocalizedElement/> expected to receive a single " +
54+
"React element child");
55+
}
56+
57+
const l10n = useContext(FluentContext);
58+
if (!l10n) {
59+
// Use the wrapped component as fallback.
60+
return createElement(Fragment, null, child);
61+
}
62+
63+
const bundle = l10n.getBundle(id);
64+
if (bundle === null) {
65+
// Use the wrapped component as fallback.
66+
return createElement(Fragment, null, child);
67+
}
68+
69+
let errors: Array<Error> = [];
70+
71+
// l10n.getBundle makes the bundle.hasMessage check which ensures that
72+
// bundle.getMessage returns an existing message.
73+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
74+
const msg = bundle.getMessage(id)!;
75+
76+
let localizedProps: Record<string, string> | undefined;
77+
78+
// The default is to forbid all message attributes. If the attrs prop exists
79+
// on the Localized instance, only set message attributes which have been
80+
// explicitly allowed by the developer.
81+
if (attrs && msg.attributes) {
82+
localizedProps = {};
83+
errors = [];
84+
for (const [name, allowed] of Object.entries(attrs)) {
85+
if (allowed && name in msg.attributes) {
86+
localizedProps[name] = bundle.formatPattern(
87+
msg.attributes[name], vars, errors);
88+
}
89+
}
90+
for (let error of errors) {
91+
l10n.reportError(error);
92+
}
93+
}
94+
95+
// If the wrapped component is a known void element, explicitly dismiss the
96+
// message value and do not pass it to cloneElement in order to avoid the
97+
// "void element tags must neither have `children` nor use
98+
// `dangerouslySetInnerHTML`" error.
99+
if (child.type in voidElementTags) {
100+
return cloneElement(child, localizedProps);
101+
}
102+
103+
// If the message has a null value, we're only interested in its attributes.
104+
// Do not pass the null value to cloneElement as it would nuke all children
105+
// of the wrapped component.
106+
if (msg.value === null) {
107+
return cloneElement(child, localizedProps);
108+
}
109+
110+
errors = [];
111+
const messageValue = bundle.formatPattern(msg.value, vars, errors);
112+
for (let error of errors) {
113+
l10n.reportError(error);
114+
}
115+
116+
// If the message value doesn't contain any markup nor any HTML entities,
117+
// insert it as the only child of the wrapped component.
118+
if (!reMarkup.test(messageValue) || l10n.parseMarkup === null) {
119+
return cloneElement(child, localizedProps, messageValue);
120+
}
121+
122+
let elemsLower: Record<string, ReactElement>;
123+
if (elems) {
124+
elemsLower = {};
125+
for (let [name, elem] of Object.entries(elems)) {
126+
elemsLower[name.toLowerCase()] = elem;
127+
}
128+
}
129+
130+
131+
// If the message contains markup, parse it and try to match the children
132+
// found in the translation with the props passed to this Localized.
133+
const translationNodes = l10n.parseMarkup(messageValue);
134+
const translatedChildren = translationNodes.map(childNode => {
135+
if (childNode.nodeName === "#text") {
136+
return childNode.textContent;
137+
}
138+
139+
const childName = childNode.nodeName.toLowerCase();
140+
141+
// If the child is not expected just take its textContent.
142+
if (
143+
!elemsLower ||
144+
!Object.prototype.hasOwnProperty.call(elemsLower, childName)
145+
) {
146+
return childNode.textContent;
147+
}
148+
149+
const sourceChild = elemsLower[childName];
150+
151+
// Ignore elems which are not valid React elements.
152+
if (!isValidElement(sourceChild)) {
153+
return childNode.textContent;
154+
}
155+
156+
// If the element passed in the elems prop is a known void element,
157+
// explicitly dismiss any textContent which might have accidentally been
158+
// defined in the translation to prevent the "void element tags must not
159+
// have children" error.
160+
if (sourceChild.type in voidElementTags) {
161+
return sourceChild;
162+
}
163+
164+
// TODO Protect contents of elements wrapped in <Localized>
165+
// https://github.com/projectfluent/fluent.js/issues/184
166+
// TODO Control localizable attributes on elements passed as props
167+
// https://github.com/projectfluent/fluent.js/issues/185
168+
return cloneElement(sourceChild, undefined, childNode.textContent);
169+
});
170+
171+
return cloneElement(child, localizedProps, ...translatedChildren);
172+
}
173+
174+
LocalizedElement.propTypes = {
175+
children: PropTypes.element
176+
};

0 commit comments

Comments
 (0)