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/>1>'
+ *
+ * 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(`${actualCounter}>`); // <4>...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>61>',
+ meta: { context: [], tags: [], occurrences: ['react.jsx'] },
+ },
+ '3ed8a3c47f6a32ece9c9ae0c2a060d45': {
+ string: 'Text <1><2>72>1>',
+ 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>61>': {
+ string: 'Text <1>61>',
+ meta: { context: [], tags: [], occurrences: ['react.jsx'] },
+ },
+ 'Text <1><2>72>1>': {
+ string: 'Text <1><2>72>1>',
+ 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>button1> and a <2>bold2> 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 (
+ * <>
+ *