From ef5183879f3f09a77baf924c2211fffc6cacccce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Thu, 2 Jul 2020 11:39:19 +0200 Subject: [PATCH] Split Localized into LocalizedElement and LocalizedText --- fluent-react/src/index.ts | 2 + fluent-react/src/localized.ts | 186 ++------------------------ fluent-react/src/localized_element.ts | 176 ++++++++++++++++++++++++ fluent-react/src/localized_text.ts | 73 ++++++++++ 4 files changed, 263 insertions(+), 174 deletions(-) create mode 100644 fluent-react/src/localized_element.ts create mode 100644 fluent-react/src/localized_text.ts diff --git a/fluent-react/src/index.ts b/fluent-react/src/index.ts index ce201be09..9a44bce03 100644 --- a/fluent-react/src/index.ts +++ b/fluent-react/src/index.ts @@ -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"; diff --git a/fluent-react/src/localized.ts b/fluent-react/src/localized.ts index 33dfda189..4cd012097 100644 --- a/fluent-react/src/localized.ts +++ b/fluent-react/src/localized.ts @@ -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 -// &, &, &. -const reMarkup = /<|&#?\w+;/; +import { LocalizedElement } from "./localized_element"; +import { LocalizedText } from "./localized_text"; export interface LocalizedProps { id: string; @@ -24,171 +12,21 @@ export interface LocalizedProps { elems?: Record; } /* - * The `Localized` class renders its child with translated props and children. - * - * - *

{'Hello, world!'}

- *
- * - * 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`. - * - * - *

{'Hello, { $username }!'}

- *
- * - * 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(" expected to receive a single " + - "React node child"); + if (!props.children || typeof props.children === "string") { + // Redirect to LocalizedText for string children: Fallback + // copy, and empty calls: . + 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 = []; - - // Check if the child inside 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 | 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; - 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 - // 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 }; diff --git a/fluent-react/src/localized_element.ts b/fluent-react/src/localized_element.ts new file mode 100644 index 000000000..6dde98ce4 --- /dev/null +++ b/fluent-react/src/localized_element.ts @@ -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 +// &, &, &. +const reMarkup = /<|&#?\w+;/; + +export interface LocalizedElementProps { + id: string; + attrs?: Record; + children?: ReactNode; + vars?: Record; + elems?: Record; +} +/* + * The `LocalizedElement` component renders its child with translated contents + * and props. + * + * + *

Hello, world!

+ *
+ * + * Arguments to the translation can be passed as an object in the `vars` prop. + * + * + *

{'Hello, {$userName}!'}

+ *
+ * + * 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. + * + * + *

Hello, world!

+ *
+ */ +export function LocalizedElement(props: LocalizedElementProps): ReactElement { + const { id, attrs, vars, elems, children: child = null } = props; + + // Check if the child inside is a valid element. + if (!isValidElement(child)) { + throw new Error(" 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 = []; + + // 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 | 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; + 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 + // 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 +}; diff --git a/fluent-react/src/localized_text.ts b/fluent-react/src/localized_text.ts new file mode 100644 index 000000000..bf6730810 --- /dev/null +++ b/fluent-react/src/localized_text.ts @@ -0,0 +1,73 @@ +import { + Fragment, + ReactElement, + ReactNode, + createElement, + useContext +} from "react"; +import PropTypes from "prop-types"; +import { FluentContext } from "./context"; +import { FluentVariable } from "@fluent/bundle"; + +export interface LocalizedTextProps { + id: string; + children?: ReactNode; + vars?: Record; +} +/* + * The `LocalizedText` component renders a translation as a string. + * + * + * Hello, world! + * + * + * The string passed as the child will be used as the fallback if the + * translation is missing. It's also possible to pass no fallback: + * + * + * + * Arguments to the translation can be passed as an object in the `vars` prop. + * + * + * {'Hello, {$userName}!'} + * + */ +export function LocalizedText(props: LocalizedTextProps): ReactElement { + const { id, vars, children: child = null } = props; + + const l10n = useContext(FluentContext); + if (!l10n) { + // Use the child as fallback. + return createElement(Fragment, null, child); + } + + const bundle = l10n.getBundle(id); + if (bundle === null) { + // Use the child 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)!; + + if (msg.value === null) { + // Use the child as fallback. + return createElement(Fragment, null, child); + } + + let errors: Array = []; + let value = bundle.formatPattern(msg.value, vars, errors); + for (let error of errors) { + l10n.reportError(error); + } + + // Replace the fallback string with the message value; + return createElement(Fragment, null, value); + +} + +LocalizedText.propTypes = { + children: PropTypes.string +};