Skip to content

Commit 9e91b0c

Browse files
authored
feat: rework the nodesToString function to output expected element tags (#234)
* feat: rework the "nodes-to-string" function to output expected element tags * chore: bump to v4 * fix: filter out empty nodes * rollback: rollback to CJS exports * chore: fix eslint errors * docs: update README.md * docs: update README.md
1 parent 1d8294d commit 9e91b0c

File tree

9 files changed

+235
-86
lines changed

9 files changed

+235
-86
lines changed

README.md

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,15 @@ module.exports = {
123123
fallbackKey: function(ns, value) {
124124
return value;
125125
},
126+
127+
// https://react.i18next.com/latest/trans-component#usage-with-simple-html-elements-like-less-than-br-greater-than-and-others-v10.4.0
128+
supportBasicHtmlNodes: true, // Enables keeping the name of simple nodes (e.g. <br/>) in translations instead of indexed keys.
129+
keepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'], // Which nodes are allowed to be kept in translations during defaultValue generation of <Trans>.
130+
131+
// https://github.com/acornjs/acorn/tree/master/acorn#interface
126132
acorn: {
127133
ecmaVersion: 2020,
128134
sourceType: 'module', // defaults to 'module'
129-
// Check out https://github.com/acornjs/acorn/tree/master/acorn#interface for additional options
130135
}
131136
},
132137
lngs: ['en','de'],
@@ -148,7 +153,9 @@ module.exports = {
148153
interpolation: {
149154
prefix: '{{',
150155
suffix: '}}'
151-
}
156+
},
157+
metadata: {},
158+
allowDynamicKeys: false,
152159
},
153160
transform: function customTransform(file, enc, done) {
154161
"use strict";
@@ -498,18 +505,28 @@ Below are the configuration options with their default values:
498505
sort: false,
499506
attr: {
500507
list: ['data-i18n'],
501-
extensions: ['.html', '.htm']
508+
extensions: ['.html', '.htm'],
502509
},
503510
func: {
504511
list: ['i18next.t', 'i18n.t'],
505-
extensions: ['.js', '.jsx']
512+
extensions: ['.js', '.jsx'],
506513
},
507514
trans: {
508515
component: 'Trans',
509516
i18nKey: 'i18nKey',
510517
defaultsKey: 'defaults',
511518
extensions: ['.js', '.jsx'],
512-
fallbackKey: false
519+
fallbackKey: false,
520+
521+
// https://react.i18next.com/latest/trans-component#usage-with-simple-html-elements-like-less-than-br-greater-than-and-others-v10.4.0
522+
supportBasicHtmlNodes: true, // Enables keeping the name of simple nodes (e.g. <br/>) in translations instead of indexed keys.
523+
keepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'], // Which nodes are allowed to be kept in translations during defaultValue generation of <Trans>.
524+
525+
// https://github.com/acornjs/acorn/tree/master/acorn#interface
526+
acorn: {
527+
ecmaVersion: 2020,
528+
sourceType: 'module', // defaults to 'module'
529+
},
513530
},
514531
lngs: ['en'],
515532
ns: ['translation'],
@@ -520,7 +537,7 @@ Below are the configuration options with their default values:
520537
loadPath: 'i18n/{{lng}}/{{ns}}.json',
521538
savePath: 'i18n/{{lng}}/{{ns}}.json',
522539
jsonIndent: 2,
523-
lineEnding: '\n'
540+
lineEnding: '\n',
524541
},
525542
nsSeparator: ':',
526543
keySeparator: '.',
@@ -529,8 +546,10 @@ Below are the configuration options with their default values:
529546
contextDefaultValues: [],
530547
interpolation: {
531548
prefix: '{{',
532-
suffix: '}}'
533-
}
549+
suffix: '}}',
550+
},
551+
metadata: {},
552+
allowDynamicKeys: false,
534553
}
535554
```
536555

@@ -606,7 +625,17 @@ If an `Object` is supplied, you can specify a list of extensions, or override th
606625
i18nKey: 'i18nKey',
607626
defaultsKey: 'defaults',
608627
extensions: ['.js', '.jsx'],
609-
fallbackKey: false
628+
fallbackKey: false,
629+
630+
// https://react.i18next.com/latest/trans-component#usage-with-simple-html-elements-like-less-than-br-greater-than-and-others-v10.4.0
631+
supportBasicHtmlNodes: true, // Enables keeping the name of simple nodes (e.g. <br/>) in translations instead of indexed keys.
632+
keepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'], // Which nodes are allowed to be kept in translations during defaultValue generation of <Trans>.
633+
634+
// https://github.com/acornjs/acorn/tree/master/acorn#interface
635+
acorn: {
636+
ecmaVersion: 2020,
637+
sourceType: 'module', // defaults to 'module'
638+
},
610639
}
611640
}
612641
```
@@ -819,9 +848,6 @@ interpolation options
819848
}
820849
```
821850

822-
## Integration Guide
823-
Checkout [Integration Guide](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide) to learn how to integrate with [React](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide#react), [Gettext Style I18n](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide#gettext-style-i18n), and [Handlebars](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide#handlebars).
824-
825851
#### metadata
826852

827853
Type: `Object` Default: `{}`
@@ -881,6 +907,9 @@ Example Usage:
881907
done();
882908
```
883909

910+
## Integration Guide
911+
Checkout [Integration Guide](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide) to learn how to integrate with [React](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide#react), [Gettext Style I18n](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide#gettext-style-i18n), and [Handlebars](https://github.com/i18next/i18next-scanner/wiki/Integration-Guide#handlebars).
912+
884913
## License
885914

886915
MIT

bin/cli.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
const path = require('path');
44
const program = require('commander');
5-
const ensureArray = require('ensure-array');
5+
const { ensureArray } = require('ensure-type');
66
const sort = require('gulp-sort');
77
const vfs = require('vinyl-fs');
8-
const scanner = require('../lib').default;
8+
const scanner = require('../lib');
99
const pkg = require('../package.json');
1010

1111
program

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "i18next-scanner",
3-
"version": "3.3.0",
3+
"version": "4.0.0",
44
"description": "Scan your code, extract translation keys/values, and merge them into i18n resource files.",
55
"homepage": "https://github.com/i18next/i18next-scanner",
66
"author": "Cheton Wu <cheton@gmail.com>",
@@ -57,7 +57,7 @@
5757
"clone-deep": "^4.0.0",
5858
"commander": "^9.0.0",
5959
"deepmerge": "^4.0.0",
60-
"ensure-array": "^1.0.0",
60+
"ensure-type": "^1.5.0",
6161
"eol": "^0.9.1",
6262
"esprima-next": "^5.7.0",
6363
"gulp-sort": "^2.0.0",

src/index.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable no-buffer-constructor */
1+
/* eslint-disable import/no-import-module-exports */
22
import fs from 'fs';
33
import path from 'path';
44
import eol from 'eol';
@@ -93,7 +93,7 @@ const flush = (parser, customFlush) => {
9393
contents = Buffer.from(text);
9494
} catch (e) {
9595
// Fallback to "new Buffer(string[, encoding])" which is deprecated since Node.js v6.0.0
96-
contents = new Buffer(text);
96+
contents = new Buffer(text); // eslint-disable-line no-buffer-constructor
9797
}
9898

9999
this.push(new VirtualFile({
@@ -121,9 +121,11 @@ const createStream = (options, customTransform, customFlush) => {
121121
return stream;
122122
};
123123

124-
export default (...args) => createStream(...args);
124+
// Convenience API
125+
module.exports = (...args) => module.exports.createStream(...args);
125126

126-
export {
127-
createStream,
128-
Parser,
129-
};
127+
// Basic API
128+
module.exports.createStream = createStream;
129+
130+
// Parser
131+
module.exports.Parser = Parser;

src/nodes-to-string.js

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ensureArray, ensureBoolean, ensureString } from 'ensure-type';
12
import _get from 'lodash/get';
23

34
const isJSXText = (node) => {
@@ -32,16 +33,26 @@ const isObjectExpression = (node) => {
3233
return node.type === 'ObjectExpression';
3334
};
3435

35-
const nodesToString = (nodes, code) => {
36-
let memo = '';
37-
let nodeIndex = 0;
38-
nodes.forEach((node, i) => {
39-
if (isJSXText(node) || isStringLiteral(node)) {
40-
const value = (node.value)
41-
.replace(/^[\r\n]+\s*/g, '') // remove leading spaces containing a leading newline character
42-
.replace(/[\r\n]+\s*$/g, '') // remove trailing spaces containing a leading newline character
43-
.replace(/[\r\n]+\s*/g, ' '); // replace spaces containing a leading newline character with a single space character
36+
const trimValue = value => ensureString(value)
37+
.replace(/^[\r\n]+\s*/g, '') // remove leading spaces containing a leading newline character
38+
.replace(/[\r\n]+\s*$/g, '') // remove trailing spaces containing a leading newline character
39+
.replace(/[\r\n]+\s*/g, ' '); // replace spaces containing a leading newline character with a single space character
40+
41+
const nodesToString = (nodes, options) => {
42+
const supportBasicHtmlNodes = ensureBoolean(options?.supportBasicHtmlNodes);
43+
const keepBasicHtmlNodesFor = ensureArray(options?.keepBasicHtmlNodesFor);
44+
const filteredNodes = ensureArray(nodes)
45+
.filter(node => {
46+
if (isJSXText(node)) {
47+
return trimValue(node.value);
48+
}
49+
return true;
50+
});
4451

52+
let memo = '';
53+
filteredNodes.forEach((node, nodeIndex) => {
54+
if (isJSXText(node)) {
55+
const value = trimValue(node.value);
4556
if (!value) {
4657
return;
4758
}
@@ -55,17 +66,44 @@ const nodesToString = (nodes, code) => {
5566
} if (isStringLiteral(expression)) {
5667
memo += expression.value;
5768
} else if (isObjectExpression(expression) && (_get(expression, 'properties[0].type') === 'Property')) {
58-
memo += `<${nodeIndex}>{{${expression.properties[0].key.name}}}</${nodeIndex}>`;
69+
memo += `{{${expression.properties[0].key.name}}}`;
5970
} else {
6071
console.error(`Unsupported JSX expression. Only static values or {{interpolation}} blocks are supported. Got ${expression.type}:`);
61-
console.error(code.slice(node.start, node.end));
72+
console.error(ensureString(options?.code).slice(node.start, node.end));
6273
console.error(node.expression);
6374
}
6475
} else if (node.children) {
65-
memo += `<${nodeIndex}>${nodesToString(node.children, code)}</${nodeIndex}>`;
66-
}
76+
const nodeType = node.openingElement?.name?.name;
77+
const selfClosing = node.openingElement?.selfClosing;
78+
const attributeCount = ensureArray(node.openingElement?.attributes).length;
79+
const filteredChildNodes = ensureArray(node.children)
80+
.filter(childNode => {
81+
if (isJSXText(childNode)) {
82+
return trimValue(childNode.value);
83+
}
84+
return true;
85+
});
86+
const childCount = filteredChildNodes.length;
87+
const firstChildNode = filteredChildNodes[0];
88+
const shouldKeepChild = supportBasicHtmlNodes && keepBasicHtmlNodesFor.indexOf(node.openingElement?.name?.name) > -1;
6789

68-
++nodeIndex;
90+
if (selfClosing && shouldKeepChild && (attributeCount === 0)) {
91+
// actual e.g. lorem <br/> ipsum
92+
// expected e.g. lorem <br/> ipsum
93+
memo += `<${nodeType}/>`;
94+
} else if ((childCount === 0 && !shouldKeepChild) || (childCount === 0 && attributeCount !== 0)) {
95+
// actual e.g. lorem <hr className="test" /> ipsum
96+
// expected e.g. lorem <0></0> ipsum
97+
memo += `<${nodeIndex}></${nodeIndex}>`;
98+
} else if (shouldKeepChild && (attributeCount === 0) && (childCount === 1) && (isJSXText(firstChildNode) || isStringLiteral(firstChildNode?.expression))) {
99+
// actual e.g. dolor <strong>bold</strong> amet
100+
// expected e.g. dolor <strong>bold</strong> amet
101+
memo += `<${nodeType}>${nodesToString(node.children, options)}</${nodeType}>`;
102+
} else {
103+
// regular case mapping the inner children
104+
memo += `<${nodeIndex}>${nodesToString(node.children, options)}</${nodeIndex}>`;
105+
}
106+
}
69107
});
70108

71109
return memo;

src/parser.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import acornStage3 from 'acorn-stage3';
77
import chalk from 'chalk';
88
import cloneDeep from 'clone-deep';
99
import deepMerge from 'deepmerge';
10-
import ensureArray from 'ensure-array';
10+
import { ensureArray } from 'ensure-type';
1111
import { parse } from 'esprima-next';
1212
import _ from 'lodash';
1313
import parse5 from 'parse5';
@@ -43,11 +43,13 @@ const defaults = {
4343
defaultsKey: 'defaults',
4444
extensions: ['.js', '.jsx'],
4545
fallbackKey: false,
46+
supportBasicHtmlNodes: true, // Enables keeping the name of simple nodes (e.g. <br/>) in translations instead of indexed keys.
47+
keepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'], // Which nodes are allowed to be kept in translations during defaultValue generation of <Trans>.
4648
acorn: {
4749
ecmaVersion: 2020, // defaults to 2020
4850
sourceType: 'module', // defaults to 'module'
4951
// Check out https://github.com/acornjs/acorn/tree/master/acorn#interface for additional options
50-
}
52+
},
5153
},
5254

5355
lngs: ['en'], // array of supported languages
@@ -171,6 +173,12 @@ const normalizeOptions = (options) => {
171173
if (_.isUndefined(_.get(options, 'trans.acorn'))) {
172174
_.set(options, 'trans.acorn', defaults.trans.acorn);
173175
}
176+
if (_.isUndefined(_.get(options, 'trans.supportBasicHtmlNodes'))) {
177+
_.set(options, 'trans.supportBasicHtmlNodes', defaults.trans.supportBasicHtmlNodes);
178+
}
179+
if (_.isUndefined(_.get(options, 'trans.keepBasicHtmlNodesFor'))) {
180+
_.set(options, 'trans.keepBasicHtmlNodesFor', defaults.trans.keepBasicHtmlNodesFor);
181+
}
174182
}
175183

176184
// Resource
@@ -538,6 +546,8 @@ class Parser {
538546
defaultsKey = this.options.trans.defaultsKey, // string
539547
fallbackKey, // boolean|function
540548
acorn: acornOptions = this.options.trans.acorn, // object
549+
supportBasicHtmlNodes = this.options.trans.supportBasicHtmlNodes, // boolean
550+
keepBasicHtmlNodesFor = this.options.trans.keepBasicHtmlNodesFor, // array
541551
} = { ...opts };
542552

543553
const parseJSXElement = (node, code) => {
@@ -634,7 +644,11 @@ class Parser {
634644
const tOptions = attr.tOptions;
635645
const options = {
636646
...tOptions,
637-
defaultValue: defaultsString || nodesToString(node.children, code),
647+
defaultValue: defaultsString || nodesToString(node.children, {
648+
code,
649+
supportBasicHtmlNodes,
650+
keepBasicHtmlNodesFor,
651+
}),
638652
fallbackKey: fallbackKey || this.options.trans.fallbackKey
639653
};
640654

0 commit comments

Comments
 (0)