From 1e00a17066546a262b0c632edb8723e487e1a75e Mon Sep 17 00:00:00 2001 From: Geoffrey Date: Thu, 28 Nov 2019 15:12:45 +0100 Subject: [PATCH] feat(react-i18n): interpolate JSX tags inside translations (#22) Add the ability to interpolate JSX inside translations. --- packages/react-i18n/README.md | 67 +++++++-- .../src/components/i18nElement.component.js | 5 +- .../src/components/i18nString.component.js | 5 +- .../__snapshots__/i18n.utils.spec.js.snap | 95 +++++++++++++ .../src/utils/__tests__/i18n.utils.spec.js | 132 ++++++++++++++++++ packages/react-i18n/src/utils/i18n.utils.js | 92 +++++++++++- 6 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 packages/react-i18n/src/utils/__tests__/__snapshots__/i18n.utils.spec.js.snap diff --git a/packages/react-i18n/README.md b/packages/react-i18n/README.md index 5c49f2c..1c33d4f 100644 --- a/packages/react-i18n/README.md +++ b/packages/react-i18n/README.md @@ -70,10 +70,11 @@ export default const MyComponent = ({ nbExample, t }) => { }; ``` -* **i18nKeys**: key from the dictionary (required) -* **number**: amount used for plural forms -* **data**: object containing key/values used for interpolation in the translation -* **general**: use general plural form if truthy +- **i18nKeys**: key from the dictionary (required) +- **number**: amount used for plural forms +- **data**: object containing key/values used for interpolation in the translation +- **general**: use general plural form if truthy +- **renderers**: object containing the renderers to use to interpolate JSX (for more information see `JSX interpolation section`) ### i18n HTML component @@ -94,11 +95,12 @@ export default const MyComponent = ({ nbExample, t }) => { }; ``` -* **i18nKeys**: key from the dictionary (required) -* **number**: amount used for plural forms -* **data**: object containing key/values used for interpolation in the translation -* **general**: use general plural form if truthy -* **element**: HTML element, or React element used for rendering. (default value: `span`) +- **i18nKeys**: key from the dictionary (required) +- **number**: amount used for plural forms +- **data**: object containing key/values used for interpolation in the translation +- **general**: use general plural form if truthy +- **element**: HTML element, or React element used for rendering. (default value: `span`) +- **renderers**: object containing the renderers to use to interpolate JSX (for more information see `JSX interpolation section`) Note that **number** and **data** can be used together. @@ -129,11 +131,12 @@ const MyComponent = ({ nbExample, t }) => { export default translate(MyComponent); ``` -* **t**: translation function, params are: - * **key**: key from the dictionary (required) - * **data**: object containing key/values used for interpolation in the translation - * **number**: amount used for plural forms - * **general**: use general plural form if truthy +- **t**: translation function, params are: +- **key**: key from the dictionary (required) +- **data**: object containing key/values used for interpolation in the translation +- **number**: amount used for plural forms +- **general**: use general plural form if truthy +- **renderers**: object containing the renderers to use to interpolate JSX (for more information see `JSX interpolation section`) Note that **number** and **data** can be used together. @@ -205,3 +208,39 @@ This is the configuration of plural form for keys: The variable used in translation template string has to be `%(number)d`, and is automatically provided by the translate function. To use general form, you need to set 4th parameter of the translate function to `true` + +### JSX Interpolation + +It is possible to interpolate JSX components inside translation, to do so you have to give `renderers` parameter or props. +For example if you have in your translation : `foo bar` you should have a `LinkToHome` renderer. + +```jsx harmony +import React from 'react'; +import { useTranslate } from '@m6web/react-i18n'; + +const renderers = { + LinkToHome: ({ children }) => {children}, +}; + +const MyComponent = () => { + const t = useTranslate(); + + return ( +
+

{t('foo.example', undefined, undefined, undefined, renderers)}

+
+ ); +}; +``` + +In this example, the `` inside your translation will be rendered by the component given in `renderers`. + +For the moment only the children props are used by the renderer. +```jsx harmony +// Do +Home + or + +// Don't +Home +``` diff --git a/packages/react-i18n/src/components/i18nElement.component.js b/packages/react-i18n/src/components/i18nElement.component.js index eabd4ad..bfba717 100644 --- a/packages/react-i18n/src/components/i18nElement.component.js +++ b/packages/react-i18n/src/components/i18nElement.component.js @@ -2,9 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Context } from '../context/i18n.context'; -export const HtmlTrans = ({ i18nKey, data, number, general, element: Element, ...props }) => ( +export const HtmlTrans = ({ i18nKey, data, number, general, element: Element, renderers, ...props }) => ( - {t => } + {t => } ); @@ -20,4 +20,5 @@ HtmlTrans.propTypes = { data: PropTypes.object, number: PropTypes.number, general: PropTypes.bool, + renderers: PropTypes.object, }; diff --git a/packages/react-i18n/src/components/i18nString.component.js b/packages/react-i18n/src/components/i18nString.component.js index d5498d7..4563510 100644 --- a/packages/react-i18n/src/components/i18nString.component.js +++ b/packages/react-i18n/src/components/i18nString.component.js @@ -2,8 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Context } from './../context/i18n.context'; -export const Trans = ({ i18nKey, data, number, general }) => ( - {t => t(i18nKey, data, number, general)} +export const Trans = ({ i18nKey, data, number, general, renderers }) => ( + {t => t(i18nKey, data, number, general, renderers)} ); Trans.defaultProps = { @@ -16,4 +16,5 @@ Trans.propTypes = { data: PropTypes.object, number: PropTypes.number, general: PropTypes.bool, + renderers: PropTypes.object, }; diff --git a/packages/react-i18n/src/utils/__tests__/__snapshots__/i18n.utils.spec.js.snap b/packages/react-i18n/src/utils/__tests__/__snapshots__/i18n.utils.spec.js.snap new file mode 100644 index 0000000..f996d50 --- /dev/null +++ b/packages/react-i18n/src/utils/__tests__/__snapshots__/i18n.utils.spec.js.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`i18n translate function with JSX should not try to interpolate basic html tag 1`] = ` +
+ <em>Hello</em> <strong>Moto</strong> ! +
+`; + +exports[`i18n translate function with JSX should render a complex JSX structure inside the translation 1`] = ` +
+ + + + + Hell + + + o + + + +
+
+ Moto + +
+
+ ! +
+`; + +exports[`i18n translate function with JSX should render the JSX component and sibling JSX component present inside the translation 1`] = ` +
+ + + Hello + + + + + + Moto + + + ! +
+`; + +exports[`i18n translate function with JSX should render the JSX component present inside the translation 1`] = ` +
+ Hello + + + Moto + + + ! +
+`; + +exports[`i18n translate function with JSX should render the nested JSX component present inside the translation 1`] = ` +
+ Hello + + + + + Moto + + + + + ! +
+`; + +exports[`i18n translate function with JSX should render the short (without children) JSX component inside the translation 1`] = ` +
+ Hello + +
+
+ Moto + +
+
+ ! +
+`; + +exports[`i18n translate function with JSX should works with badly formatted JSX 1`] = ` +
+ <Bold>Toto</Italic> +
+`; diff --git a/packages/react-i18n/src/utils/__tests__/i18n.utils.spec.js b/packages/react-i18n/src/utils/__tests__/i18n.utils.spec.js index e517869..2dbb4b8 100644 --- a/packages/react-i18n/src/utils/__tests__/i18n.utils.spec.js +++ b/packages/react-i18n/src/utils/__tests__/i18n.utils.spec.js @@ -1,3 +1,7 @@ +/* eslint-disable react/prop-types */ + +import React from 'react'; +import { mount } from 'enzyme'; import { translate, buildList } from '../i18n.utils'; describe('i18n translate function', () => { @@ -159,6 +163,134 @@ describe('i18n translate function', () => { ); }); }); + + describe('with JSX', () => { + const Bold = ({ children }) => {children}; + const Italic = ({ children }) => {children}; + const LineBreak = () =>
; + + it('should render the JSX component present inside the translation', () => { + const lang = { + foo: { + bar: 'Hello Moto !', + }, + }; + const renderers = { Bold }; + const t = translate(lang); + + const result = t('foo.bar', undefined, undefined, false, renderers); + const wrapper = mount(
{result}
); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render the nested JSX component present inside the translation', () => { + const lang = { + foo: { + bar: 'Hello Moto !', + }, + }; + const renderers = { Bold, Italic }; + const t = translate(lang); + + const result = t('foo.bar', undefined, undefined, false, renderers); + const wrapper = mount(
{result}
); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render the JSX component and sibling JSX component present inside the translation', () => { + const lang = { + foo: { + bar: 'Hello Moto !', + }, + }; + const renderers = { Bold, Italic }; + const t = translate(lang); + + const result = t('foo.bar', undefined, undefined, false, renderers); + const wrapper = mount(
{result}
); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render the short (without children) JSX component inside the translation', () => { + const lang = { + foo: { + bar: 'HelloMoto !', + }, + }; + const renderers = { LineBreak }; + const t = translate(lang); + + const result = t('foo.bar', undefined, undefined, false, renderers); + const wrapper = mount(
{result}
); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render a complex JSX structure inside the translation', () => { + const lang = { + foo: { + bar: 'HelloMoto !', + }, + }; + const renderers = { LineBreak, Bold, Italic }; + const t = translate(lang); + + const result = t('foo.bar', undefined, undefined, false, renderers); + const wrapper = mount(
{result}
); + + expect(wrapper).toMatchSnapshot(); + }); + + /* eslint-disable no-console */ + it('should throw an error if renderer not provided', () => { + console.warn = jest.fn(); + const lang = { + foo: { + bar: 'Hello Moto !', + }, + }; + const renderers = { Bold }; + const t = translate(lang); + t('foo.bar', undefined, undefined, false, renderers); + + expect(console.warn).toHaveBeenCalledWith('No renderer provided for component "Italic"'); + }); + /* eslint-enable no-console */ + + it('should not try to interpolate basic html tag', () => { + const lang = { + foo: { + bar: 'Hello Moto !', + }, + }; + const renderers = {}; + const t = translate(lang); + + const result = t('foo.bar', undefined, undefined, false, renderers); + const wrapper = mount(
{result}
); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should works with badly formatted JSX', () => { + const lang = { + foo: { + bar: 'Toto' + } + } + + const renderers = {Bold, Italic} + const t = translate(lang) + + const result = t('foo.bar', undefined, undefined, undefined, renderers) + const wrapper = mount(
{result}
) + + expect(wrapper).toMatchSnapshot() + }); + }); }); describe('i18n listBuilder function', () => { diff --git a/packages/react-i18n/src/utils/i18n.utils.js b/packages/react-i18n/src/utils/i18n.utils.js index 9007794..83f8f58 100644 --- a/packages/react-i18n/src/utils/i18n.utils.js +++ b/packages/react-i18n/src/utils/i18n.utils.js @@ -1,3 +1,4 @@ +import React from 'react'; import _ from 'lodash'; import { sprintf } from 'sprintf-js'; @@ -35,10 +36,90 @@ const pluralizeFunctions = { }, }; +// find tags like : with content inside +const JSX_TAG_WITH_CONTENT_REGEX = /(.*?)<([A-Z]\w+)>(.*)<\/\2+>(.*)/; +// find tags like : +const SHORT_JSX_TAG_REGEX = /(.*?)<([A-Z]\w+) ?\/>(.*)/; + +const parseJSX = content => { + const regexResultTagWithContent = JSX_TAG_WITH_CONTENT_REGEX.exec(content); + if (regexResultTagWithContent) { + return { + beforeTagContent: regexResultTagWithContent[1], + componentTag: regexResultTagWithContent[2], + insideTagContent: regexResultTagWithContent[3], + afterTagContent: regexResultTagWithContent[4], + }; + } + + const regexResultShortTag = SHORT_JSX_TAG_REGEX.exec(content); + if (regexResultShortTag) { + return { + beforeTagContent: regexResultShortTag[1], + componentTag: regexResultShortTag[2], + afterTagContent: regexResultShortTag[3], + }; + } + + return null; +}; + +const createComponentInstance = (component, children) => { + if (!component) { + return null; + } + + return React.createElement(component, {}, ...(Array.isArray(children) ? children : [children])); +}; + +const interpolateJSXInsideTranslation = (translation, renderers) => { + const parsingResult = parseJSX(translation); + if (!parsingResult) { + return translation; + } + + const { beforeTagContent, componentTag, insideTagContent, afterTagContent } = parsingResult; + const translationChildren = []; + const interpolateAndAddToChildren = content => { + const interpolatedContent = interpolateJSXInsideTranslation(content, renderers); + + if (typeof interpolatedContent === 'string') { + translationChildren.push(interpolatedContent); + } + + if (Array.isArray(interpolatedContent)) { + translationChildren.push(...interpolatedContent); + } + }; + + if (beforeTagContent) { + interpolateAndAddToChildren(beforeTagContent); + } + + if (componentTag) { + const interpolatedChildren = insideTagContent ? interpolateJSXInsideTranslation(insideTagContent, renderers) : null; + const rendererToUse = renderers[componentTag]; + const componentInstance = createComponentInstance(rendererToUse, interpolatedChildren); + + if (componentInstance) { + translationChildren.push(componentInstance); + } else { + // eslint-disable-next-line no-console + console.warn(`No renderer provided for component "${componentTag}"`); + } + } + + if (afterTagContent) { + interpolateAndAddToChildren(afterTagContent); + } + + return translationChildren; +}; + export const translate = (lang, i18nNames = {}) => { const pluralize = pluralizeFunctions[_.get(lang, '_i18n.lang')] || pluralizeFunctions.fr; - return (key, data = {}, number, general) => { + return (key, data = {}, number, general, renderers) => { let combineKey = key; // Pluralize if (typeof number !== 'undefined') { @@ -47,7 +128,14 @@ export const translate = (lang, i18nNames = {}) => { const translation = _.get(lang, combineKey, combineKey); - return sprintf(translation, { ...data, ...i18nNames, number }); + const translatedResult = sprintf(translation, { ...data, ...i18nNames, number }); + if (renderers) { + const JSXTranslated = interpolateJSXInsideTranslation(translatedResult, renderers); + + return createComponentInstance(React.Fragment, JSXTranslated); + } + + return translatedResult; }; };