Skip to content

Commit

Permalink
Split Localized into LocalizedElement and LocalizedText
Browse files Browse the repository at this point in the history
  • Loading branch information
stasm committed Jul 7, 2020
1 parent 36d09ae commit ef51838
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 174 deletions.
2 changes: 2 additions & 0 deletions fluent-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ export { ReactLocalization} from "./localization";
export { LocalizationProvider } from "./provider";
export { withLocalization, WithLocalizationProps } from "./with_localization";
export { Localized, LocalizedProps } from "./localized";
export { LocalizedElement, LocalizedElementProps } from "./localized_element";
export { LocalizedText, LocalizedTextProps } from "./localized_text";
export { MarkupParser } from "./markup";
export { useLocalization } from "./use_localization";
186 changes: 12 additions & 174 deletions fluent-react/src/localized.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
import {
Fragment,
ReactElement,
ReactNode,
cloneElement,
createElement,
isValidElement,
useContext
} from "react";
import { ReactElement, ReactNode, createElement } from "react";
import PropTypes from "prop-types";
import voidElementTags from "../vendor/voidElementTags";
import { FluentContext } from "./context";
import { FluentVariable } from "@fluent/bundle";

// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &amp;, &#0038;, &#x0026;.
const reMarkup = /<|&#?\w+;/;
import { LocalizedElement } from "./localized_element";
import { LocalizedText } from "./localized_text";

export interface LocalizedProps {
id: string;
Expand All @@ -24,171 +12,21 @@ export interface LocalizedProps {
elems?: Record<string, ReactElement>;
}
/*
* The `Localized` class renders its child with translated props and children.
*
* <Localized id="hello-world">
* <p>{'Hello, world!'}</p>
* </Localized>
*
* The `id` prop should be the unique identifier of the translation. Any
* attributes found in the translation will be applied to the wrapped element.
*
* Arguments to the translation can be passed as `$`-prefixed props on
* `Localized`.
*
* <Localized id="hello-world" $username={name}>
* <p>{'Hello, { $username }!'}</p>
* </Localized>
*
* It's recommended that the contents of the wrapped component be a string
* expression. The string will be used as the ultimate fallback if no
* translation is available. It also makes it easy to grep for strings in the
* source code.
* The `Localized` component redirects to `LocalizedElement` or
* `LocalizedText`, depending on props.children.
*/
export function Localized(props: LocalizedProps): ReactElement {
const { id, attrs, vars, elems, children: child = null } = props;
const l10n = useContext(FluentContext);

// Validate that the child element isn't an array
if (Array.isArray(child)) {
throw new Error("<Localized/> expected to receive a single " +
"React node child");
if (!props.children || typeof props.children === "string") {
// Redirect to LocalizedText for string children: <Localized>Fallback
// copy</Localized>, and empty calls: <Localized />.
return createElement(LocalizedText, props);
}

if (!l10n) {
// Use the wrapped component as fallback.
return createElement(Fragment, null, child);
}

const bundle = l10n.getBundle(id);

if (bundle === null) {
// Use the wrapped component as fallback.
return createElement(Fragment, null, child);
}

// l10n.getBundle makes the bundle.hasMessage check which ensures that
// bundle.getMessage returns an existing message.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const msg = bundle.getMessage(id)!;
let errors: Array<Error> = [];

// Check if the child inside <Localized> is a valid element -- if not, then
// it's either null or a simple fallback string. No need to localize the
// attributes.
if (!isValidElement(child)) {
if (msg.value) {
// Replace the fallback string with the message value;
let value = bundle.formatPattern(msg.value, vars, errors);
for (let error of errors) {
l10n.reportError(error);
}
return createElement(Fragment, null, value);
}

return createElement(Fragment, null, child);
}

let localizedProps: Record<string, string> | undefined;

// The default is to forbid all message attributes. If the attrs prop exists
// on the Localized instance, only set message attributes which have been
// explicitly allowed by the developer.
if (attrs && msg.attributes) {
localizedProps = {};
errors = [];
for (const [name, allowed] of Object.entries(attrs)) {
if (allowed && name in msg.attributes) {
localizedProps[name] = bundle.formatPattern(
msg.attributes[name], vars, errors);
}
}
for (let error of errors) {
l10n.reportError(error);
}
}

// If the wrapped component is a known void element, explicitly dismiss the
// message value and do not pass it to cloneElement in order to avoid the
// "void element tags must neither have `children` nor use
// `dangerouslySetInnerHTML`" error.
if (child.type in voidElementTags) {
return cloneElement(child, localizedProps);
}

// If the message has a null value, we're only interested in its attributes.
// Do not pass the null value to cloneElement as it would nuke all children
// of the wrapped component.
if (msg.value === null) {
return cloneElement(child, localizedProps);
}

errors = [];
const messageValue = bundle.formatPattern(msg.value, vars, errors);
for (let error of errors) {
l10n.reportError(error);
}

// If the message value doesn't contain any markup nor any HTML entities,
// insert it as the only child of the wrapped component.
if (!reMarkup.test(messageValue) || l10n.parseMarkup === null) {
return cloneElement(child, localizedProps, messageValue);
}

let elemsLower: Record<string, ReactElement>;
if (elems) {
elemsLower = {};
for (let [name, elem] of Object.entries(elems)) {
elemsLower[name.toLowerCase()] = elem;
}
}


// If the message contains markup, parse it and try to match the children
// found in the translation with the props passed to this Localized.
const translationNodes = l10n.parseMarkup(messageValue);
const translatedChildren = translationNodes.map(childNode => {
if (childNode.nodeName === "#text") {
return childNode.textContent;
}

const childName = childNode.nodeName.toLowerCase();

// If the child is not expected just take its textContent.
if (
!elemsLower ||
!Object.prototype.hasOwnProperty.call(elemsLower, childName)
) {
return childNode.textContent;
}

const sourceChild = elemsLower[childName];

// Ignore elems which are not valid React elements.
if (!isValidElement(sourceChild)) {
return childNode.textContent;
}

// If the element passed in the elems prop is a known void element,
// explicitly dismiss any textContent which might have accidentally been
// defined in the translation to prevent the "void element tags must not
// have children" error.
if (sourceChild.type in voidElementTags) {
return sourceChild;
}

// TODO Protect contents of elements wrapped in <Localized>
// https://github.com/projectfluent/fluent.js/issues/184
// TODO Control localizable attributes on elements passed as props
// https://github.com/projectfluent/fluent.js/issues/185
return cloneElement(sourceChild, undefined, childNode.textContent);
});

return cloneElement(child, localizedProps, ...translatedChildren);
// Redirect to LocalizedElement for element children. Only a single element
// child is supported; LocalizedElement enforces this requirement.
return createElement(LocalizedElement, props);
}

export default Localized;

Localized.propTypes = {
children: PropTypes.node
};
176 changes: 176 additions & 0 deletions fluent-react/src/localized_element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
Fragment,
ReactElement,
ReactNode,
cloneElement,
createElement,
isValidElement,
useContext
} from "react";
import PropTypes from "prop-types";
import voidElementTags from "../vendor/voidElementTags";
import { FluentContext } from "./context";
import { FluentVariable } from "@fluent/bundle";

// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &amp;, &#0038;, &#x0026;.
const reMarkup = /<|&#?\w+;/;

export interface LocalizedElementProps {
id: string;
attrs?: Record<string, boolean>;
children?: ReactNode;
vars?: Record<string, FluentVariable>;
elems?: Record<string, ReactElement>;
}
/*
* The `LocalizedElement` component renders its child with translated contents
* and props.
*
* <Localized id="hello-world">
* <p>Hello, world!</p>
* </Localized>
*
* Arguments to the translation can be passed as an object in the `vars` prop.
*
* <LocalizedElement id="hello-world" vars={{userName: name}}>
* <p>{'Hello, {$userName}!'}</p>
* </LocalizedElement>
*
* The props of the wrapped child can be localized using Fluent attributes
* found on the requested message, provided they are explicitly allowed by the
* `attrs` prop.
*
* <LocalizedElement id="hello-world" attrs={{title: true}}>
* <p>Hello, world!</p>
* </LocalizedElement>
*/
export function LocalizedElement(props: LocalizedElementProps): ReactElement {
const { id, attrs, vars, elems, children: child = null } = props;

// Check if the child inside <LocalizedElement> is a valid element.
if (!isValidElement(child)) {
throw new Error("<LocalizedElement/> expected to receive a single " +
"React element child");
}

const l10n = useContext(FluentContext);
if (!l10n) {
// Use the wrapped component as fallback.
return createElement(Fragment, null, child);
}

const bundle = l10n.getBundle(id);
if (bundle === null) {
// Use the wrapped component as fallback.
return createElement(Fragment, null, child);
}

let errors: Array<Error> = [];

// l10n.getBundle makes the bundle.hasMessage check which ensures that
// bundle.getMessage returns an existing message.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const msg = bundle.getMessage(id)!;

let localizedProps: Record<string, string> | undefined;

// The default is to forbid all message attributes. If the attrs prop exists
// on the Localized instance, only set message attributes which have been
// explicitly allowed by the developer.
if (attrs && msg.attributes) {
localizedProps = {};
errors = [];
for (const [name, allowed] of Object.entries(attrs)) {
if (allowed && name in msg.attributes) {
localizedProps[name] = bundle.formatPattern(
msg.attributes[name], vars, errors);
}
}
for (let error of errors) {
l10n.reportError(error);
}
}

// If the wrapped component is a known void element, explicitly dismiss the
// message value and do not pass it to cloneElement in order to avoid the
// "void element tags must neither have `children` nor use
// `dangerouslySetInnerHTML`" error.
if (child.type in voidElementTags) {
return cloneElement(child, localizedProps);
}

// If the message has a null value, we're only interested in its attributes.
// Do not pass the null value to cloneElement as it would nuke all children
// of the wrapped component.
if (msg.value === null) {
return cloneElement(child, localizedProps);
}

errors = [];
const messageValue = bundle.formatPattern(msg.value, vars, errors);
for (let error of errors) {
l10n.reportError(error);
}

// If the message value doesn't contain any markup nor any HTML entities,
// insert it as the only child of the wrapped component.
if (!reMarkup.test(messageValue) || l10n.parseMarkup === null) {
return cloneElement(child, localizedProps, messageValue);
}

let elemsLower: Record<string, ReactElement>;
if (elems) {
elemsLower = {};
for (let [name, elem] of Object.entries(elems)) {
elemsLower[name.toLowerCase()] = elem;
}
}


// If the message contains markup, parse it and try to match the children
// found in the translation with the props passed to this Localized.
const translationNodes = l10n.parseMarkup(messageValue);
const translatedChildren = translationNodes.map(childNode => {
if (childNode.nodeName === "#text") {
return childNode.textContent;
}

const childName = childNode.nodeName.toLowerCase();

// If the child is not expected just take its textContent.
if (
!elemsLower ||
!Object.prototype.hasOwnProperty.call(elemsLower, childName)
) {
return childNode.textContent;
}

const sourceChild = elemsLower[childName];

// Ignore elems which are not valid React elements.
if (!isValidElement(sourceChild)) {
return childNode.textContent;
}

// If the element passed in the elems prop is a known void element,
// explicitly dismiss any textContent which might have accidentally been
// defined in the translation to prevent the "void element tags must not
// have children" error.
if (sourceChild.type in voidElementTags) {
return sourceChild;
}

// TODO Protect contents of elements wrapped in <Localized>
// https://github.com/projectfluent/fluent.js/issues/184
// TODO Control localizable attributes on elements passed as props
// https://github.com/projectfluent/fluent.js/issues/185
return cloneElement(sourceChild, undefined, childNode.textContent);
});

return cloneElement(child, localizedProps, ...translatedChildren);
}

LocalizedElement.propTypes = {
children: PropTypes.element
};
Loading

0 comments on commit ef51838

Please sign in to comment.