Skip to content

Commit

Permalink
Merge pull request #44 from curvetips/trans-component
Browse files Browse the repository at this point in the history
Support react-i18n <Trans> components
  • Loading branch information
cheton committed Sep 16, 2017
2 parents fb3f7eb + 653cf0f commit a7f31e0
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 32 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"dependencies": {
"esprima": "^4.0.0",
"lodash": "^4.17.4",
"parse5": "^3.0.2",
"through2": "^2.0.3",
"vinyl": "^2.0.2",
"vinyl-fs": "^2.4.4"
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const transform = (parser, customTransform) => {
if (includes(get(options, 'func.extensions'), extname)) {
// Parse translation function (e.g. i18next.t('key'))
parser.parseFuncFromString(content);
// Look for Trans components in JSX
parser.parseTransFromString(content);
}

if (typeof customTransform === 'function') {
Expand Down
77 changes: 77 additions & 0 deletions src/jsx-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import parse5 from 'parse5'

const jsExpr = /(.*?)({+[^]+?}+)(.*)/

function transform(node) {
let output = []
delete node.parentNode
if (node.nodeName === '#text') {
let text = node.value
let m = jsExpr.exec(node.value)
if (!m) {
return node
}
while ((m = jsExpr.exec(text))) {
if (m[1]) {
output.push({
nodeName: '#text',
value: m[1],
parentNode: node.parentNode
})
}
output.push({
nodeName: '#expression',
value: m[2],
parentNode: node.parentNode
})
text = m[3]
}
if (text) {
output.push({
nodeName: '#text',
value: text,
parentNode: node.parentNode
})
}
} else {
node.childNodes = Array.prototype.concat.apply(
[],
node.childNodes.map(transform)
)
output.push(node)
}
return output
}


export function parseJSX(fragment) {
const ast = parse5.parseFragment(fragment)
return transform(ast)[0].childNodes
}


function astToText(ast) {
let output = ''

function walk(nodes) {
nodes.forEach((node, ix) => {
if (node.nodeName === '#text') {
output += node.value;
} else if (node.nodeName === '#expression') {
output += `<${ix}>${node.value}</${ix}>`
} else {
output += `<${ix}>`
walk(node.childNodes)
output += `</${ix}>`
}
})
}

walk(ast)
return output
}

export default function jsxToText(fragment) {
const ast = parseJSX(fragment)
return astToText(ast)
}
90 changes: 58 additions & 32 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import trim from 'lodash/trim';
import toPairs from 'lodash/toPairs';
import sortBy from 'lodash/sortBy';
import { parse } from 'esprima';
import parse5 from 'parse5';
import ensureArray from './ensure-array';
import jsxToText from './jsx-parser';

const defaults = {
debug: false, // verbose logging
Expand Down Expand Up @@ -338,12 +340,37 @@ class Parser {

return this;
}
// Parses translation keys from `Trans` components in JSX
// <Trans i18nKey="some.key">Default text</Trans>
parseTransFromString(content, opts = {}, customHandler = null) {
const pattern = '<Trans[^]*?i18nKey="([^"]+)"[^]*?>([^]*?)</\\s*Trans\\s*>';
const re = new RegExp(pattern, 'gim');
let setter = this.set.bind(this);

if (isFunction(opts)) {
setter = opts;
opts = {};
}

let r;
while ((r = re.exec(content))) {
const key = trim(r[1]);
let fragment = trim(r[2]);
fragment = fragment.replace(/\s+/g, ' ');
const defaultValue = jsxToText(fragment);
const options = { defaultValue };
setter(key, options);
}
return this;
}
// Parses translation keys from `data-i18n` attribute in HTML
// <div data-i18n="[attr]ns:foo.bar;[attr]ns:foo.baz">
// </div>
parseAttrFromString(content, opts = {}, customHandler = null) {
let setter = this.set.bind(this);

if (isFunction(opts)) {
customHandler = opts;
setter = opts;
opts = {};
}

Expand All @@ -355,43 +382,42 @@ class Parser {
return this;
}

const matchPattern = attrs
.map(attr => ('(?:' + attr + ')'))
.join('|')
.replace(/\./g, '\\.');
const pattern = '(?:(?:^[\\s]*)|[^a-zA-Z0-9_])(?:' + matchPattern + ')=("[^"]*"|\'[^\']*\')';
const re = new RegExp(pattern, 'gim');
const ast = parse5.parse(content);

let r;
const parseAttributeValue = (key) => {
key = trim(key);
if (key.length === 0) {
return;
}
if (key.indexOf('[') === 0) {
const parts = key.split(']');
key = parts[1];
}
if (key.indexOf(';') === (key.length - 1)) {
key = key.substr(0, key.length - 2);
}

while ((r = re.exec(content))) {
const attr = trim(r[1], '\'"');
const keys = (attr.indexOf(';') >= 0)
? attr.split(';')
: [attr];

keys.forEach((key) => {
key = trim(key);
if (key.length === 0) {
return;
}
if (key.indexOf('[') === 0) {
const parts = key.split(']');
key = parts[1];
}
if (key.indexOf(';') === (key.length - 1)) {
key = key.substr(0, key.length - 2);
}
setter(key);
}

if (customHandler) {
customHandler(key);
return;
const walk = (nodes) => {
nodes.forEach(node => {
if (node.attrs) {
node.attrs.forEach(attr => {
if (attrs.indexOf(attr.name)!==-1) {
const values = attr.value.split(';');
values.forEach(parseAttributeValue);
}
});
}

this.set(key);
});
if (node.childNodes) {
walk(node.childNodes);
}
})
}

walk(ast.childNodes)

return this;
}
// Get the value of a translation key or the whole resource store containing translation information
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/app.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const mycomp = () => (
<Trans i18nKey="key1">Key 1 default</Trans>
<Trans i18nKey="key2">
Key 2
default value
</Trans>

<Trans i18nKey="key3">This is a <strong>test</strong></Trans>
<Trans i18nKey="key4" count={count}>You have {{count}} apples</Trans>
<Trans i18nKey="key5">You have <a>one <i>very</i> bad</a> apple</Trans>
<Trans i18nKey="key6">This is a <strong>{{test}}</strong></Trans>
)
73 changes: 73 additions & 0 deletions test/jsx-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fs from 'fs';
import path from 'path';
import { test } from 'tap';
import jsxToText, { parseJSX } from '../src/jsx-parser';

const defaults = {};

test('JSX Parse plain text', (t) => {
const ast = parseJSX('This is plain text')
t.same(ast.length, 1);
t.same(ast[0].nodeName, '#text')
t.same(ast[0].value, 'This is plain text')
t.end();
});

test('JSX Transform bare javascript expression', (t) => {
const ast = parseJSX('{{name}}')
t.same(ast.length, 1);
t.same(ast[0].nodeName, '#expression')
t.same(ast[0].value, '{{name}}')
t.end();
});

test('JSX Transform leading javascript expression', (t) => {
const ast = parseJSX('{{name}}, you are so fine')
t.same(ast.length, 2);
t.same(ast[0].nodeName, '#expression')
t.same(ast[0].value, '{{name}}')
t.same(ast[1].nodeName, '#text')
t.same(ast[1].value, ', you are so fine')
t.end();
});

test('JSX Transform trailing javascript expression', (t) => {
const ast = parseJSX('My name is {{name}}')
t.same(ast.length, 2);
t.same(ast[0].nodeName, '#text')
t.same(ast[0].value, 'My name is ')
t.same(ast[1].nodeName, '#expression')
t.same(ast[1].value, '{{name}}')
t.end();
});

test('JSX Transform javascript expression', (t) => {
const ast = parseJSX('My name is {{name}}. And you?')
t.same(ast.length, 3);
t.same(ast[0].nodeName, '#text')
t.same(ast[0].value, 'My name is ')
t.same(ast[1].nodeName, '#expression')
t.same(ast[1].value, '{{name}}')
t.same(ast[2].nodeName, '#text')
t.same(ast[2].value, '. And you?')
t.end();
});

test('JSX Nested expression', (t) => {
const ast = parseJSX('Hello, <strong>{{name}}</strong>, how are you?')
t.same(ast.length, 3);
t.same(ast[0].nodeName, '#text')
t.same(ast[1].nodeName, 'strong')
t.same(ast[1].childNodes.length, 1)
t.same(ast[1].childNodes[0].nodeName, '#expression')
t.same(ast[1].childNodes[0].value, '{{name}}')
t.same(ast[2].nodeName, '#text')
t.end();
});


test('JSX to i18next', (t) => {
t.same(jsxToText('Basic text'), 'Basic text')
t.same(jsxToText('Hello, {{name}}'), 'Hello, <1>{{name}}</1>')
t.end()
})
23 changes: 23 additions & 0 deletions test/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ test('Parse translation function', (t) => {
t.end();
});

test('Parse Trans component', (t) => {
const parser = new Parser({
lngs: ['en'],
fallbackLng: 'en'
});

const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.jsx'), 'utf-8');
parser.parseTransFromString(content);
t.same(parser.get(), {
en: {
translation: {
"key1": "Key 1 default",
"key2": "Key 2 default value",
"key3": "This is a <1>test</1>",
"key4": "You have <1>{{count}}</1> apples",
"key5": "You have <1>one <1>very</1> bad</1> apple",
"key6": "This is a <1><0>{{test}}</0></1>"
}
}
});
t.end();
});

test('Parse HTML attribute', (t) => {
const parser = new Parser({
lngs: ['en'],
Expand Down

0 comments on commit a7f31e0

Please sign in to comment.