diff --git a/packages/cli/src/api/parsers/babel.js b/packages/cli/src/api/parsers/babel.js index 99d088ba..2cc12981 100644 --- a/packages/cli/src/api/parsers/babel.js +++ b/packages/cli/src/api/parsers/babel.js @@ -35,6 +35,71 @@ function babelParse(source) { } } +/* Converts a list of JSX AST nodes to a string. Each "tag" must be converted + * to a numbered tag in the order they were encountered in and all props must + * be stripped. + * + * const root = babelParse('<>four'); + * const children = root.program.body[0].expression.children; + * const [result] = toStr(children) + * console.log(result.join('')); + * // <<< '<1>four<2/>' + * + * The second argument and return value are there because of how recursion + * works. For high-level invocation you won't have to worry about them. + * */ +function toStr(children, counter = 0) { + if (!children) { return [[], 0]; } + + let result = []; + + let actualCounter = counter; + for (let i = 0; i < children.length; i += 1) { + const child = children[i]; + if (child.type === 'JSXElement') { + actualCounter += 1; + if (child.children && child.children.length > 0) { + // child has children, recursively run 'toStr' on them + const [newResult, newCounter] = toStr(child.children, actualCounter); + if (newResult.length === 0) { return [[], 0]; } + result.push(`<${actualCounter}>`); // <4> + result = result.concat(newResult); // <4>... + result.push(``); // <4>... + // Take numbered tags that were found during the recursion into account + actualCounter = newCounter; + } else { + // child has no children of its own, replace with something like '<4/>' + result.push(`<${actualCounter}/>`); + } + } else if (child.type === 'JSXText') { + // Child is not a React element, append as-is + let chunk = child.value; + + // Try to mimic how JSX is parsed in runtime React + const [startMatch] = /^[\s\n]*/.exec(child.value); + if (startMatch.includes('\n')) { + chunk = chunk.substring(startMatch.length); + } + + const [endMatch] = /[\s\n]*$/.exec(child.value); + if (endMatch.includes('\n')) { + chunk = chunk.substring(0, chunk.length - endMatch.length); + } + + if (chunk) { result.push(chunk); } + } else if ( + child.type === 'JSXExpressionContainer' + && child.expression.type === 'StringLiteral' + ) { + const chunk = child.expression.value; + if (chunk) { result.push(chunk); } + } else { + return [[], 0]; + } + } + return [result, actualCounter]; +} + function babelExtractPhrases(HASHES, source, relativeFile, options) { const ast = babelParse(source); babelTraverse(ast, { @@ -140,6 +205,11 @@ function babelExtractPhrases(HASHES, source, relativeFile, options) { params[property] = attrValue; }); + if (!string && elem.name.name === 'T' && node.children && node.children.length) { + const [result] = toStr(node.children); + string = result.join(''); + } + if (!string) return; const partial = createPayload(string, params, relativeFile, options); diff --git a/packages/cli/test/api/extract.hashedkeys.test.js b/packages/cli/test/api/extract.hashedkeys.test.js index 2881387a..1be9dfa1 100644 --- a/packages/cli/test/api/extract.hashedkeys.test.js +++ b/packages/cli/test/api/extract.hashedkeys.test.js @@ -154,6 +154,34 @@ describe('extractPhrases with hashed keys', () => { string: 'HTML inline text', meta: { context: [], tags: [], occurrences: ['react.jsx'] }, }, + '57b0d93fc0e1c3af68a41214147efd97': { + string: 'Text 5', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + '121b687b8625b4e58ba7f36dca77ad7f': { + string: 'Text <1>6', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + '3ed8a3c47f6a32ece9c9ae0c2a060d45': { + string: 'Text <1><2>7', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + '1ecaf4c087b894bf86987fc2972ddba7': { + string: 'Text 8', + meta: { context: ['foo'], tags: [], occurrences: ['react.jsx'] }, + }, + f9818c4a4b3772c365b8522ff29cb785: { + string: 'Text <1/> 9', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + '37678ce8d9c3a694ce19b947c64b9787': { + string: 'Text {msg}', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + '5c6622f57e93ed83011b45833a12b0aa': { + string: 'Text 10', + meta: { context: [], tags: ['tag1', 'tag2'], occurrences: ['react.jsx'] }, + }, }); }); diff --git a/packages/cli/test/api/extract.sourcekeys.test.js b/packages/cli/test/api/extract.sourcekeys.test.js index 7de31557..ebf8f017 100644 --- a/packages/cli/test/api/extract.sourcekeys.test.js +++ b/packages/cli/test/api/extract.sourcekeys.test.js @@ -147,6 +147,34 @@ describe('extractPhrases with source keys', () => { string: 'HTML inline text', meta: { context: [], tags: [], occurrences: ['react.jsx'] }, }, + 'Text 5': { + string: 'Text 5', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + 'Text <1>6': { + string: 'Text <1>6', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + 'Text <1><2>7': { + string: 'Text <1><2>7', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + 'Text 8::foo': { + string: 'Text 8', + meta: { context: ['foo'], tags: [], occurrences: ['react.jsx'] }, + }, + 'Text <1/> 9': { + string: 'Text <1/> 9', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + 'Text {msg}': { + string: 'Text {msg}', + meta: { context: [], tags: [], occurrences: ['react.jsx'] }, + }, + 'Text 10': { + string: 'Text 10', + meta: { context: [], tags: ['tag1', 'tag2'], occurrences: ['react.jsx'] }, + }, }); }); diff --git a/packages/cli/test/fixtures/react.jsx b/packages/cli/test/fixtures/react.jsx index 48666399..44d28238 100644 --- a/packages/cli/test/fixtures/react.jsx +++ b/packages/cli/test/fixtures/react.jsx @@ -30,6 +30,13 @@ function foo() { {msg} {msg2} + Text 5 + Text 6 + Text 7 + Text 8 + Text
9
+ Text {'{msg}'} + Text 10 ); } diff --git a/packages/react/README.md b/packages/react/README.md index ffb5725a..c5a34d52 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -60,6 +60,8 @@ npm install @transifex/native @transifex/react --save ## `T` Component +### Regular usage + ```javascript import React from 'react'; @@ -86,6 +88,8 @@ Available optional props: | _charlimit | Number | Character limit instruction for translators | | _tags | String | Comma separated list of tags | +### Interpolation of React elements + The T-component can accept React elements as properties and they will be rendered properly, ie this would be possible: @@ -96,6 +100,14 @@ rendered properly, ie this would be possible: bold={} /> ``` +Assuming the translations look like this: + +| source | translation | +|-----------------------------------------|--------------------------------------------------| +| A {button} and a {bold} walk into a bar | Ένα {button} και ένα {bold} μπαίνουν σε ένα μπαρ | +| button | κουμπί | +| bold | βαρύ | + This will render like this in English: ```html @@ -108,17 +120,56 @@ And like this in Greek: Ένα και ένα βαρύ μπαίνουν σε ένα μπαρ ``` -Assuming the translations look like this: - -| source | translation | -|-----------------------------------------|--------------------------------------------------| -| A {button} and a {bold} walk into a bar | Ένα {button} και ένα {bold} μπαίνουν σε ένα μπαρ | -| button | κουμπί | -| bold | βαρύ | - The main thing to keep in mind is that the `_str` property to the T-component must **always** be a valid ICU messageformat template. +### Translatable body + +Another way to use the T-component is to include a translatable body that is a +mix of text and React elements: + +```javascript + + A and a bold walk into a bar + +``` + +If you do this, the string that will be sent to Transifex for translation will +look like this: + +``` +A <1>button and a <2>bold walk into a bar +``` + +As long as the translation respects the numbered tags, the T-component will +render the translation properly. Any props that the React elements have in the +source version of the text will be applied to the translation as well. + +You must not inject any javascript code in the content of a T-component because: + +1. It will be rendered differently every time and the SDK won't be able to + predictably find a translation +2. The CLI will not be able to extract a source string from it + +You can interpolate parameters as before, but you have to be careful with how +you define them in the source body: + +```javascript +// ✗ Wrong, this is a javascript expression +hello {username} + +// ✓ Correct, this is a string +hello {'{username}'} +``` + +This time however, the interpolated values **cannot** be React elements. + +```javascript +// ✗ Wrong, this will fail to render +BOLD}>This is {'{bold}'} +``` + + ## `UT` Component ```javascript diff --git a/packages/react/src/components/T.jsx b/packages/react/src/components/T.jsx index a2bef8c3..60b0ba01 100644 --- a/packages/react/src/components/T.jsx +++ b/packages/react/src/components/T.jsx @@ -1,6 +1,8 @@ +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import useT from '../hooks/useT'; +import { toStr, toElement } from '../utils/toStr'; /* Main transifex-native component for react. It delegates the translation to * the `useT` hook, which will force the component to rerender in the event of @@ -19,10 +21,42 @@ import useT from '../hooks/useT'; *

* * ); - * } */ + * } + * + * You can also include translatable content as the body of the T-tag. The body + * must be a combination of text and React elements; you should **not** include + * any javascript logic or it won't manage to be picked up by the CLI and + * translated properly. + * + * function App() { + * const [name, setName] = useState('Bill'); + * return ( + * <> + *

hello world

+ *

hello world

+ *

+ * setName(e.target.value)} /> + * hello {'{name}'} + *

+ * + * ); + * } + * + * */ + +export default function T({ _str, children, ...props }) { + const t = useT(); + if (!children) { return t(_str, props); } + + const [templateArray, propsContainer] = toStr(children); + const templateString = templateArray.join(''); + const translation = t(templateString, props); -export default function T({ _str, ...props }) { - return useT()(_str, props); + const result = toElement(translation, propsContainer); + if (result.length === 0) { return ''; } + if (result.length === 1) { return result[0]; } + return {result}; } -T.propTypes = { _str: PropTypes.string.isRequired }; +T.defaultProps = { _str: null, children: null }; +T.propTypes = { _str: PropTypes.string, children: PropTypes.node }; diff --git a/packages/react/src/utils/toStr.js b/packages/react/src/utils/toStr.js new file mode 100644 index 00000000..861f827d --- /dev/null +++ b/packages/react/src/utils/toStr.js @@ -0,0 +1,138 @@ +import React from 'react'; + +/* Convert a React component's children to a string. Each "tag" must be + * converted to a numbered tag in the order they were encountered in and all + * props must be stripped. The props must be preserved in the second return + * value so that they can be reinserted again later. + * + * element = <>four; + * const [result, propsContainer] = toStr(element.props.children); + * console.log(result.join('')); + * // <<< '<1>four<2/>' + * console.log(propsContainer); + * // <<< [['one', {two: 'three'}], ['five', {six: 'seven'}]] + * + * The second argument and third return value are there because of how + * recursion works. For high-level invocation you won't have to worry about + * them. + * */ +export function toStr(children, counter = 0) { + if (!children) { return [[], [], 0]; } + let actualChildren = children; + if (!Array.isArray(children)) { + actualChildren = [children]; + } + + // Return values + let result = []; + let propsContainer = []; + + let actualCounter = counter; + for (let i = 0; i < actualChildren.length; i += 1) { + const child = actualChildren[i]; + if (React.isValidElement(child)) { + actualCounter += 1; + + // Each entry in propsContainer matches one matched react element. So for + // the element replaced with '<4>', the relevant props will be + // `propsContainer[3]` (4 - 1) + const props = [ + child.type, + { ...child.props }, // Do this so that delete can work later + ]; + delete props[1].children; + propsContainer.push(props); + + if (child.props.children) { + // child has children, recursively run 'toStr' on them + const [newResult, newProps, newCounter] = toStr( + child.props.children, + actualCounter, + ); + result.push(`<${actualCounter}>`); // <4> + result = result.concat(newResult); // <4>... + result.push(``); // <4>... + // Extend propsContainer with what was found during the recursion + propsContainer = propsContainer.concat(newProps); + // Take numbered tags that were found during the recursion into account + actualCounter = newCounter; + } else { + // child has no children of its own, replace with something like '<4/>' + result.push(`<${actualCounter}/>`); + } + } else { + // Child is not a React element, append as-is + /* eslint-disable no-lonely-if */ + if (typeof child === 'string' || child instanceof String) { + if (child) { result.push(child); } + } else { + result.push(child); + } + /* eslint-enable */ + } + } + + return [result, propsContainer, actualCounter]; +} + +/* Convert a string that was generated from 'toStr', or its translation, back + * to a React element, combining it with the props that were extracted during + * 'toStr'. + * + * toElement( + * 'one<1>five<2/>', + * [['two', {three: 'four'}], ['six', {seven: 'eight'}]], + * ); + * // The browser will render the equivalent of + * // onefive + * */ +export function toElement(translation, propsContainer) { + const regexp = /<(\d+)(\/?)>/; // Find opening or single tags + const result = []; + + let lastEnd = 0; // Last position in 'translation' we have "consumed" so far + let lastKey = 0; + + for (;;) { + const match = regexp.exec(translation.substring(lastEnd)); + if (match === null) { break; } // We've reached the end + + // Copy until match + const matchIndex = lastEnd + match.index; + const chunk = translation.substring(lastEnd, matchIndex); + if (chunk) { result.push(chunk); } + + const [openingTag, numberString, rightSlash] = match; + const number = parseInt(numberString, 10); + const [type, props] = propsContainer[number - 1]; // Find relevant props + if (rightSlash) { + // Single tag, copy props and don't include children in the React element + result.push(React.createElement(type, { ...props, key: lastKey })); + lastEnd += matchIndex + openingTag.length; + } else { + // Opening tag, find the closing tag which is guaranteed to be there and + // to be unique + const endingTag = ``; + const endingTagPos = translation.indexOf(endingTag); + // Recursively convert contents to React elements + const newResult = toElement( + translation.substring(matchIndex + openingTag.length, endingTagPos), + propsContainer, + ); + // Copy props and include recursion result as children + result.push(React.createElement( + type, + { ...props, key: lastKey }, + ...newResult, + )); + lastEnd = endingTagPos + endingTag.length; + } + lastKey += 1; + } + + // Copy rest of 'translation' + const chunk = translation.substring(lastEnd, translation.length); + if (chunk) { result.push(chunk); } + + return result; +} diff --git a/packages/react/tests/T.test.js b/packages/react/tests/T.test.js index af864ead..a653cebc 100644 --- a/packages/react/tests/T.test.js +++ b/packages/react/tests/T.test.js @@ -44,4 +44,28 @@ describe('T', () => { render(world} />); expect(screen.getByText('world')).toBeTruthy(); }); + + it('renders body', () => { + render(hello safe text); + expect(screen.queryByText('hello')).toBeInTheDocument(); + expect(screen.queryByText('safe text')).toBeInTheDocument(); + }); + + it('renders body with single tags', () => { + render(hello
safe text
); + expect(screen.queryByText('hello')).toBeInTheDocument(); + expect(screen.queryByText('safe text')).toBeInTheDocument(); + }); + + it('renders nestedbody', () => { + render(hello safe text); + expect(screen.queryByText('hello')).toBeInTheDocument(); + expect(screen.queryByText('safe text')).toBeInTheDocument(); + }); + + it('renders body with params', () => { + render(hello mister {'{username}'}); + expect(screen.queryByText('hello')).toBeInTheDocument(); + expect(screen.queryByText('mister Bill')).toBeInTheDocument(); + }); });