Skip to content

Commit

Permalink
Translatable body inside T-component
Browse files Browse the repository at this point in the history
  • Loading branch information
Konstantinos Bairaktaris committed Jul 25, 2024
1 parent 4707bb5 commit a513565
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 12 deletions.
52 changes: 52 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,53 @@ 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
const chunk = child.value.trim();
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 +187,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(' ').trim();
}

if (!string) return;

const partial = createPayload(string, params, relativeFile, options);
Expand Down
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>button</button> and a <b>bold</b> walk into a bar
</T>
```

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

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 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(' ').trim();
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 };
139 changes: 139 additions & 0 deletions packages/react/src/utils/toStr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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 = <><one two="three">four<five six="seven" /></one></>;
* const [result, propsContainer] = toStr(element.props.children);
* console.log(result.join(''));
* // <<< '<1>four<2/></1>'
* 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(`</${actualCounter}>`); // <4>...</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) {
const chunk = child.trim();
if (chunk) { result.push(chunk); }
} 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/></1>',
* [['two', {three: 'four'}], ['six', {seven: 'eight'}]],
* );
* // The browser will render the equivalent of
* // one<two three="four">five<six seven="eight" /></two>
* */
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 += openingTag.length;
} else {
// Opening tag, find the closing tag which is guaranteed to be there and
// to be unique
const endingTag = `</${number}>`;
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;
}

0 comments on commit a513565

Please sign in to comment.