Skip to content

Commit b98443a

Browse files
author
Konstantinos Bairaktaris
committed
Translatable body inside T-component
1 parent 6e95c0d commit b98443a

File tree

4 files changed

+288
-13
lines changed

4 files changed

+288
-13
lines changed

packages/cli/src/api/parsers/babel.js

+52
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,53 @@ function babelParse(source) {
3535
}
3636
}
3737

38+
/* Converts a list of JSX AST nodes to a string. Each "tag" must be converted
39+
* to a numbered tag in the order they were encountered in and all props must
40+
* be stripped.
41+
*
42+
* const root = babelParse('<><one two="three">four<five six="seven" /></one></>');
43+
* const children = root.program.body[0].expression.children;
44+
* const [result] = toStr(children)
45+
* console.log(result.join(''));
46+
* // <<< '<1>four<2/></1>'
47+
*
48+
* The second argument and return value are there because of how recursion
49+
* works. For high-level invocation you won't have to worry about them.
50+
* */
51+
function toStr(children, counter = 0) {
52+
if (!children) { return [[], 0]; }
53+
54+
let result = [];
55+
56+
let actualCounter = counter;
57+
for (let i = 0; i < children.length; i += 1) {
58+
const child = children[i];
59+
if (child.type === 'JSXElement') {
60+
actualCounter += 1;
61+
if (child.children && child.children.length > 0) {
62+
// child has children, recursively run 'toStr' on them
63+
const [newResult, newCounter] = toStr(child.children, actualCounter);
64+
if (newResult.length === 0) { return [[], 0]; }
65+
result.push(`<${actualCounter}>`); // <4>
66+
result = result.concat(newResult); // <4>...
67+
result.push(`</${actualCounter}>`); // <4>...</4>
68+
// Take numbered tags that were found during the recursion into account
69+
actualCounter = newCounter;
70+
} else {
71+
// child has no children of its own, replace with something like '<4/>'
72+
result.push(`<${actualCounter}/>`);
73+
}
74+
} else if (child.type === 'JSXText') {
75+
// Child is not a React element, append as-is
76+
const chunk = child.value.trim();
77+
if (chunk) { result.push(chunk); }
78+
} else {
79+
return [[], 0];
80+
}
81+
}
82+
return [result, actualCounter];
83+
}
84+
3885
function babelExtractPhrases(HASHES, source, relativeFile, options) {
3986
const ast = babelParse(source);
4087
babelTraverse(ast, {
@@ -140,6 +187,11 @@ function babelExtractPhrases(HASHES, source, relativeFile, options) {
140187
params[property] = attrValue;
141188
});
142189

190+
if (!string && elem.name.name === 'T' && node.children && node.children.length) {
191+
const [result] = toStr(node.children);
192+
string = result.join(' ').trim();
193+
}
194+
143195
if (!string) return;
144196

145197
const partial = createPayload(string, params, relativeFile, options);

packages/react/README.md

+59-8
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ npm install @transifex/native @transifex/react --save
6060

6161
## `T` Component
6262

63+
### Regular usage
64+
6365
```javascript
6466
import React from 'react';
6567

@@ -86,6 +88,8 @@ Available optional props:
8688
| _charlimit | Number | Character limit instruction for translators |
8789
| _tags | String | Comma separated list of tags |
8890

91+
### Interpolation of React elements
92+
8993
The T-component can accept React elements as properties and they will be
9094
rendered properly, ie this would be possible:
9195

@@ -96,6 +100,14 @@ rendered properly, ie this would be possible:
96100
bold={<b><T _str="bold" /></b>} />
97101
```
98102

103+
Assuming the translations look like this:
104+
105+
| source | translation |
106+
|-----------------------------------------|--------------------------------------------------|
107+
| A {button} and a {bold} walk into a bar | Ένα {button} και ένα {bold} μπαίνουν σε ένα μπαρ |
108+
| button | κουμπί |
109+
| bold | βαρύ |
110+
99111
This will render like this in English:
100112

101113
```html
@@ -108,17 +120,56 @@ And like this in Greek:
108120
Ένα <button>κουμπί</button> και ένα <b>βαρύ</b> μπαίνουν σε ένα μπαρ
109121
```
110122

111-
Assuming the translations look like this:
112-
113-
| source | translation |
114-
|-----------------------------------------|--------------------------------------------------|
115-
| A {button} and a {bold} walk into a bar | Ένα {button} και ένα {bold} μπαίνουν σε ένα μπαρ |
116-
| button | κουμπί |
117-
| bold | βαρύ |
118-
119123
The main thing to keep in mind is that the `_str` property to the T-component
120124
must **always** be a valid ICU messageformat template.
121125

126+
### Translatable body
127+
128+
Another way to use the T-component is to include a translatable body that is a
129+
mix of text and React elements:
130+
131+
```javascript
132+
<T>
133+
A <button>button</button> and a <b>bold</b> walk into a bar
134+
</T>
135+
```
136+
137+
You must not inject any javascript code in the content of a T-component because:
138+
139+
1. It will be rendered differently every time and the SDK won't be able to
140+
predictably find a translation
141+
2. The CLI will not be able to extract a source string from it
142+
143+
If you do this, the string that will be sent to Transifex for translation will
144+
look like this:
145+
146+
```
147+
A <1> button </1> and a <2> bold </2> walk into a bar
148+
```
149+
150+
As long as the translation respects the numbered tags, the T-component will
151+
render the translation properly. Any props that the React elements have in the
152+
source version of the text will be applied to the translation as well.
153+
154+
You can interpolate parameters as before, but you have to be careful with how
155+
you define them in the source body:
156+
157+
```javascript
158+
// ✗ Wrong, this is a javascript expression
159+
<T username="Bill">hello {username}</T>
160+
161+
// ✓ Correct, this is a string
162+
<T username="Bill">hello {'{username}'}</T>
163+
```
164+
165+
This time however, the interpolated values **cannot** be React elements.
166+
167+
```javascript
168+
// ✗ Wrong, this will fail to render
169+
<T bold={<b>BOLD</b>}>This is {'{bold}'}</T>
170+
```
171+
172+
122173
## `UT` Component
123174

124175
```javascript

packages/react/src/components/T.jsx

+38-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import React, { Fragment } from 'react';
12
import PropTypes from 'prop-types';
23

34
import useT from '../hooks/useT';
5+
import { toStr, toElement } from '../utils/toStr';
46

57
/* Main transifex-native component for react. It delegates the translation to
68
* the `useT` hook, which will force the component to rerender in the event of
@@ -19,12 +21,43 @@ import useT from '../hooks/useT';
1921
* </p>
2022
* </>
2123
* );
22-
* } */
24+
* }
25+
*
26+
* You can also include translatable content as the body of the T-tag. The body
27+
* must be a combination of text and React elements; you should **not** include
28+
* any javascript logic or it won't manage to be picked up by the CLI and
29+
* translated properly.
30+
*
31+
* function App() {
32+
* const [name, setName] = useState('Bill');
33+
* return (
34+
* <>
35+
* <p><T>hello world</T></p>
36+
* <p><T>hello <b>world</b></T></p>
37+
* <p>
38+
* <input value={name} onChange={(e) => setName(e.target.value)} />
39+
* <T name=name>hello {'{name}'}</T>
40+
* </p>
41+
* </>
42+
* );
43+
* }
44+
*
45+
* */
46+
47+
export default function T({ _str, children, ...props }) {
48+
const t = useT();
49+
if (!children) { return t(_str, props); }
50+
51+
const [templateArray, propsContainer] = toStr(children);
52+
const templateString = templateArray.join(' ').trim();
53+
const translation = t(templateString, props);
2354

24-
export default function T({ _str, ...props }) {
25-
return useT()(_str, props);
55+
const result = toElement(translation, propsContainer);
56+
if (result.length === 0) { return ''; }
57+
if (result.length === 1) { return result[0]; }
58+
return <Fragment>{result}</Fragment>;
2659
}
2760

28-
T.defaultProps = {};
61+
T.defaultProps = { _str: null, children: null };
2962

30-
T.propTypes = { _str: PropTypes.string.isRequired };
63+
T.propTypes = { _str: PropTypes.string, children: PropTypes.node };

packages/react/src/utils/toStr.js

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import React from 'react';
2+
3+
/* Convert a React component's children to a string. Each "tag" must be
4+
* converted to a numbered tag in the order they were encountered in and all
5+
* props must be stripped. The props must be preserved in the second return
6+
* value so that they can be reinserted again later.
7+
*
8+
* element = <><one two="three">four<five six="seven" /></one></>;
9+
* const [result, propsContainer] = toStr(element.props.children);
10+
* console.log(result.join(''));
11+
* // <<< '<1>four<2/></1>'
12+
* console.log(propsContainer);
13+
* // <<< [['one', {two: 'three'}], ['five', {six: 'seven'}]]
14+
*
15+
* The second argument and third return value are there because of how
16+
* recursion works. For high-level invocation you won't have to worry about
17+
* them.
18+
* */
19+
export function toStr(children, counter = 0) {
20+
if (!children) { return [[], [], 0]; }
21+
let actualChildren = children;
22+
if (!Array.isArray(children)) {
23+
actualChildren = [children];
24+
}
25+
26+
// Return values
27+
let result = [];
28+
let propsContainer = [];
29+
30+
let actualCounter = counter;
31+
for (let i = 0; i < actualChildren.length; i += 1) {
32+
const child = actualChildren[i];
33+
if (React.isValidElement(child)) {
34+
actualCounter += 1;
35+
36+
// Each entry in propsContainer matches one matched react element. So for
37+
// the element replaced with '<4>', the relevant props will be
38+
// `propsContainer[3]` (4 - 1)
39+
const props = [
40+
child.type,
41+
{ ...child.props }, // Do this so that delete can work later
42+
];
43+
delete props[1].children;
44+
propsContainer.push(props);
45+
46+
if (child.props.children) {
47+
// child has children, recursively run 'toStr' on them
48+
const [newResult, newProps, newCounter] = toStr(
49+
child.props.children,
50+
actualCounter,
51+
);
52+
result.push(`<${actualCounter}>`); // <4>
53+
result = result.concat(newResult); // <4>...
54+
result.push(`</${actualCounter}>`); // <4>...</4>
55+
// Extend propsContainer with what was found during the recursion
56+
propsContainer = propsContainer.concat(newProps);
57+
// Take numbered tags that were found during the recursion into account
58+
actualCounter = newCounter;
59+
} else {
60+
// child has no children of its own, replace with something like '<4/>'
61+
result.push(`<${actualCounter}/>`);
62+
}
63+
} else {
64+
// Child is not a React element, append as-is
65+
/* eslint-disable no-lonely-if */
66+
if (typeof child === 'string' || child instanceof String) {
67+
const chunk = child.trim();
68+
if (chunk) { result.push(chunk); }
69+
} else {
70+
result.push(child);
71+
}
72+
/* eslint-enable */
73+
}
74+
}
75+
76+
return [result, propsContainer, actualCounter];
77+
}
78+
79+
/* Convert a string that was generated from 'toStr', or its translation, back
80+
* to a React element, combining it with the props that were extracted during
81+
* 'toStr'.
82+
*
83+
* toElement(
84+
* 'one<1>five<2/></1>',
85+
* [['two', {three: 'four'}], ['six', {seven: 'eight'}]],
86+
* );
87+
* // The browser will render the equivalent of
88+
* // one<two three="four">five<six seven="eight" /></two>
89+
* */
90+
export function toElement(translation, propsContainer) {
91+
const regexp = /<(\d+)(\/?)>/; // Find opening or single tags
92+
const result = [];
93+
94+
let lastEnd = 0; // Last position in 'translation' we have "consumed" so far
95+
let lastKey = 0;
96+
97+
for (;;) {
98+
const match = regexp.exec(translation.substring(lastEnd));
99+
if (match === null) { break; } // We've reached the end
100+
101+
// Copy until match
102+
const matchIndex = lastEnd + match.index;
103+
const chunk = translation.substring(lastEnd, matchIndex);
104+
if (chunk) { result.push(chunk); }
105+
106+
const [openingTag, numberString, rightSlash] = match;
107+
const number = parseInt(numberString, 10);
108+
const [type, props] = propsContainer[number - 1]; // Find relevant props
109+
if (rightSlash) {
110+
// Single tag, copy props and don't include children in the React element
111+
result.push(React.createElement(type, { ...props, key: lastKey }));
112+
lastEnd += openingTag.length;
113+
} else {
114+
// Opening tag, find the closing tag which is guaranteed to be there and
115+
// to be unique
116+
const endingTag = `</${number}>`;
117+
const endingTagPos = translation.indexOf(endingTag);
118+
// Recursively convert contents to React elements
119+
const newResult = toElement(
120+
translation.substring(matchIndex + openingTag.length, endingTagPos),
121+
propsContainer,
122+
);
123+
// Copy props and include recursion result as children
124+
result.push(React.createElement(
125+
type,
126+
{ ...props, key: lastKey },
127+
...newResult,
128+
));
129+
lastEnd = endingTagPos + endingTag.length;
130+
}
131+
lastKey += 1;
132+
}
133+
134+
// Copy rest of 'translation'
135+
const chunk = translation.substring(lastEnd, translation.length);
136+
if (chunk) { result.push(chunk); }
137+
138+
return result;
139+
}

0 commit comments

Comments
 (0)