Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split Localized into LocalizedElement, LocalizedText #502

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
191 changes: 19 additions & 172 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, isValidElement } 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,30 @@ 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 (!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 (!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 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);
if (isValidElement(props.children)) {
// Redirect to LocalizedElement for element children. Only a single element
// child is supported; LocalizedElement enforces this requirement.
return createElement(LocalizedElement, props);
}

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);
throw new Error(
"<Localized> can be used either similar to <LocalizedElement>, " +
"in which case it expects a single React element child, or similar to " +
"<LocalizedText>, in which case it expects a single string-typed child " +
"or no children at all."
);
}

export default Localized;

Localized.propTypes = {
children: PropTypes.node
};
175 changes: 175 additions & 0 deletions fluent-react/src/localized_element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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 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