Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Translatable body inside T-component #142

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/cli/src/api/parsers/babel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<><one two="three">four<five six="seven" /></one></>');
* 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, {
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/test/api/extract.hashedkeys.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,34 @@ describe('extractPhrases with hashed keys', () => {
string: '<b>HTML inline text</b>',
meta: { context: [], tags: [], occurrences: ['react.jsx'] },
},
'57b0d93fc0e1c3af68a41214147efd97': {
string: 'Text 5',
meta: { context: [], tags: [], occurrences: ['react.jsx'] },
},
'121b687b8625b4e58ba7f36dca77ad7f': {
string: 'Text <1>6</1>',
meta: { context: [], tags: [], occurrences: ['react.jsx'] },
},
'3ed8a3c47f6a32ece9c9ae0c2a060d45': {
string: 'Text <1><2>7</2></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'] },
},
});
});

Expand Down
28 changes: 28 additions & 0 deletions packages/cli/test/api/extract.sourcekeys.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,34 @@ describe('extractPhrases with source keys', () => {
string: '<b>HTML inline text</b>',
meta: { context: [], tags: [], occurrences: ['react.jsx'] },
},
'Text 5': {
string: 'Text 5',
meta: { context: [], tags: [], occurrences: ['react.jsx'] },
},
'Text <1>6</1>': {
string: 'Text <1>6</1>',
meta: { context: [], tags: [], occurrences: ['react.jsx'] },
},
'Text <1><2>7</2></1>': {
string: 'Text <1><2>7</2></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'] },
},
});
});

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/test/fixtures/react.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ function foo() {
<T _str={str3} />
{msg}
{msg2}
<T>Text 5</T>
<T>Text <b>6</b></T>
<T>Text <b><i>7</i></b></T>
<T _context={context}>Text 8</T>
<T>Text <br /> 9</T>
<T msg={msg}>Text {'{msg}'}</T>
<T _tags="tag1,tag2">Text 10</T>
</div>
);
}
67 changes: 59 additions & 8 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ npm install @transifex/native @transifex/react --save

## `T` Component

### Regular usage

```javascript
import React from 'react';

Expand All @@ -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:

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

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
Expand All @@ -108,17 +120,56 @@ And like this in Greek:
Ένα <button>κουμπί</button> και ένα <b>βαρύ</b> μπαίνουν σε ένα μπαρ
```

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
<T>
A <button title="button!!!">button</button> and a <b>bold</b> walk into a bar
</T>
```

If you do this, the string that will be sent to Transifex for translation will
look like this:

```
A <1>button</1> and a <2>bold</2> 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
<T username="Bill">hello {username}</T>

// ✓ Correct, this is a string
<T username="Bill">hello {'{username}'}</T>
```

This time however, the interpolated values **cannot** be React elements.

```javascript
// ✗ Wrong, this will fail to render
<T bold={<b>BOLD</b>}>This is {'{bold}'}</T>
```


## `UT` Component

```javascript
Expand Down
42 changes: 38 additions & 4 deletions packages/react/src/components/T.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,10 +21,42 @@ import useT from '../hooks/useT';
* </p>
* </>
* );
* } */
* }
*
* 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 (
* <>
* <p><T>hello world</T></p>
* <p><T>hello <b>world</b></T></p>
* <p>
* <input value={name} onChange={(e) => setName(e.target.value)} />
* <T name=name>hello {'{name}'}</T>
* </p>
* </>
* );
* }
*
* */

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 <Fragment>{result}</Fragment>;
}

T.propTypes = { _str: PropTypes.string.isRequired };
T.defaultProps = { _str: null, children: null };
T.propTypes = { _str: PropTypes.string, children: PropTypes.node };
Loading
Loading