From 1d8294dcb754d622698496367f064b39afc1dc18 Mon Sep 17 00:00:00 2001 From: Cheton Wu Date: Sun, 19 Jun 2022 16:50:22 +0800 Subject: [PATCH] feat: migrate from Tap to Jest (#220) * feat: use jest to test i18next-scanner * chore: work in progress * test: migrating to jest * chore: update CLI and add missing dependencies * chore: exclude ./bin from linting * ci: remove redundant coverage script Co-authored-by: Cheton Wu --- .eslintrc.js | 15 + .github/workflows/node.js.yml | 1 - .gitignore | 1 + babel.config.js | 34 +- bin/cli.js | 84 +- jest.setup.js | 1 + package.json | 72 +- src/acorn-jsx-walk.js | 88 +- src/flatten-object-keys.js | 12 +- src/index.js | 194 ++- src/nodes-to-string.js | 96 +- src/omit-empty-object.js | 22 +- src/parser.js | 1760 +++++++++++------------ test/fixtures/trans.render.js | 0 test/jsx-parser.js | 36 - test/jsx-parser.test.js | 32 + test/parser.js | 1258 ---------------- test/parser.test.js | 1197 +++++++++++++++ test/react-i18next/i18n.js | 68 + test/react-i18next/trans-render.test.js | 631 ++++++++ test/transform-stream.js | 763 ---------- test/transform-stream.test.js | 757 ++++++++++ 22 files changed, 3909 insertions(+), 3213 deletions(-) create mode 100644 .eslintrc.js create mode 100644 jest.setup.js create mode 100644 test/fixtures/trans.render.js delete mode 100644 test/jsx-parser.js create mode 100644 test/jsx-parser.test.js delete mode 100644 test/parser.js create mode 100644 test/parser.test.js create mode 100644 test/react-i18next/i18n.js create mode 100644 test/react-i18next/trans-render.test.js delete mode 100755 test/transform-stream.js create mode 100644 test/transform-stream.test.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..c017e9e --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,15 @@ +const path = require('path'); + +module.exports = { + extends: 'trendmicro', + parser: '@babel/eslint-parser', + env: { + browser: true, + node: true, + 'jest/globals': true, + }, + plugins: [ + '@babel', + 'jest', + ], +}; diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index e3e71b9..4bed2ce 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,7 +20,6 @@ jobs: - run: yarn eslint - run: yarn build - run: yarn test - - run: yarn coverage - uses: codecov/codecov-action@v2 with: name: node-${{ matrix.node-version }} diff --git a/.gitignore b/.gitignore index f4679a1..7a30ab7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules package-lock.json +yarn.lock /.nyc_output /lib /output diff --git a/babel.config.js b/babel.config.js index f5a80f7..6bc7161 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,12 +1,28 @@ -module.exports = { +module.exports = (api) => { + const { env } = { ...api }; + const plugins = [ + ]; + + if (typeof env === 'function' && env('test')) { + // Enable async/await for jest + plugins.push('@babel/plugin-transform-runtime'); + } + + return { extends: '@trendmicro/babel-config', presets: [ - [ - '@babel/preset-env', - { - useBuiltIns: 'entry', - corejs: 3, - } - ] - ] + [ + '@babel/preset-env', + { + useBuiltIns: 'entry', + corejs: 3, + } + ], + [ + '@babel/preset-react', + {}, + ], + ], + plugins, + }; }; diff --git a/bin/cli.js b/bin/cli.js index 7dc6544..76873fb 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,73 +1,75 @@ #!/usr/bin/env node -const fs = require('fs'); const path = require('path'); const program = require('commander'); const ensureArray = require('ensure-array'); const sort = require('gulp-sort'); const vfs = require('vinyl-fs'); -const scanner = require('../lib'); +const scanner = require('../lib').default; const pkg = require('../package.json'); program - .version(pkg.version) - .usage('[options] ') - .option('--config ', 'Path to the config file (default: i18next-scanner.config.js)', 'i18next-scanner.config.js') - .option('--output ', 'Path to the output directory (default: .)'); + .version(pkg.version) + .usage('[options] ') + .option('--config ', 'Path to the config file (default: i18next-scanner.config.js)', 'i18next-scanner.config.js') + .option('--output ', 'Path to the output directory (default: .)'); -program.on('--help', function() { - console.log(''); - console.log(' Examples:'); - console.log(''); - console.log(' $ i18next-scanner --config i18next-scanner.config.js --output /path/to/output \'src/**/*.{js,jsx}\''); - console.log(' $ i18next-scanner --config i18next-scanner.config.js "src/**/*.{js,jsx}"'); - console.log(' $ i18next-scanner "/path/to/src/app.js" "/path/to/assets/index.html"'); - console.log(''); +program.on('--help', () => { + console.log(''); + console.log(' Examples:'); + console.log(''); + console.log(' $ i18next-scanner --config i18next-scanner.config.js --output /path/to/output \'src/**/*.{js,jsx}\''); + console.log(' $ i18next-scanner --config i18next-scanner.config.js "src/**/*.{js,jsx}"'); + console.log(' $ i18next-scanner "/path/to/src/app.js" "/path/to/assets/index.html"'); + console.log(''); }); program.parse(process.argv); -if (!program.config) { - program.help(); - return; +const options = program.opts(); + +if (!options.config) { + program.help(); + process.exit(1); } let config = {}; try { - config = require(path.resolve(program.config)); + // eslint-disable-next-line import/no-dynamic-require + config = require(path.resolve(options.config)); } catch (err) { - console.error('i18next-scanner:', err); - return; + console.error('i18next-scanner:', err); + process.exit(1); } { // Input - config.input = (program.args.length > 0) ? program.args : ensureArray(config.input); - config.input = config.input.map(function(s) { - s = s.trim(); - - // On Windows, arguments contain spaces must be enclosed with double quotes, not single quotes. - if (s.match(/(^'.*'$|^".*"$)/)) { - // Remove first and last character - s = s.slice(1, -1); - } - return s; - }); + config.input = (program.args.length > 0) ? program.args : ensureArray(config.input); + config.input = config.input.map((s) => { + s = s.trim(); - if (config.input.length === 0) { - program.help(); - return; + // On Windows, arguments contain spaces must be enclosed with double quotes, not single quotes. + if (s.match(/(^'.*'$|^".*"$)/)) { + // Remove first and last character + s = s.slice(1, -1); } + return s; + }); + + if (config.input.length === 0) { + program.help(); + process.exit(1); + } } { // Output - config.output = program.output || config.output; + config.output = options.output || config.output; - if (!config.output) { - config.output = '.'; - } + if (!config.output) { + config.output = '.'; + } } vfs.src(config.input) - .pipe(sort()) // Sort files in stream by path - .pipe(scanner(config.options, config.transform, config.flush)) - .pipe(vfs.dest(config.output)) + .pipe(sort()) // Sort files in stream by path + .pipe(scanner(config.options, config.transform, config.flush)) + .pipe(vfs.dest(config.output)); diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/package.json b/package.json index 7d5996d..d32e408 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,10 @@ "scripts": { "prepublishOnly": "npm run eslint && npm run build && npm test", "build": "babel ./src --out-dir ./lib", + "clean": "del lib .nyc_output", "eslint": "eslint ./src", - "coverage": "nyc --reporter=lcov --reporter=text yarn test", - "test": "tap test/*.js --no-timeout --node-arg=--require --node-arg=@babel/register --node-arg=--require --node-arg=core-js/stable --node-arg=--require --node-arg=regenerator-runtime/runtime" + "eslint-fix": "eslint --fix ./src ./test/*.js", + "test": "jest --no-cache" }, "repository": { "type": "git", @@ -54,7 +55,7 @@ "acorn-walk": "^8.0.0", "chalk": "^4.1.0", "clone-deep": "^4.0.0", - "commander": "^6.2.0", + "commander": "^9.0.0", "deepmerge": "^4.0.0", "ensure-array": "^1.0.0", "eol": "^0.9.1", @@ -69,27 +70,62 @@ "vinyl-fs": "^3.0.1" }, "devDependencies": { - "@babel/cli": "~7.12.1", - "@babel/core": "~7.12.3", - "@babel/preset-env": "~7.12.1", - "@babel/register": "~7.12.1", - "@trendmicro/babel-config": "~1.0.0-alpha", - "babel-eslint": "~10.1.0", + "@babel/cli": "^7.17.10", + "@babel/core": "^7.18.5", + "@babel/eslint-parser": "^7.18.2", + "@babel/eslint-plugin": "^7.17.7", + "@babel/plugin-transform-runtime": "^7.18.5", + "@babel/preset-env": "^7.18.2", + "@babel/preset-react": "^7.17.12", + "@babel/register": "^7.17.7", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.3.0", + "@trendmicro/babel-config": "^1.0.2", "codecov": "^3.8.3", - "core-js": "^3.7.0", - "eslint": "^7.13.0", - "eslint-config-trendmicro": "^1.4.1", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-react": "^7.21.5", + "core-js": "^3.23.1", + "del-cli": "^4.0.1", + "eslint": "^8.18.0", + "eslint-config-trendmicro": "^3.1.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "~26.5.3", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.30.0", "gulp": "^4.0.2", "gulp-tap": "^2.0.0", "gulp-util": "^3.0.8", + "jest": "^28.1.1", + "jest-environment-jsdom": "^28.1.1", + "jest-extended": "^2.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-i18next": "^11.12.0", "sha1": "^1.1.1", - "tap": "^15.0.10", "text-table": "^0.2.0" }, - "tap": { - "check-coverage": false + "jest": { + "setupFiles": [], + "setupFilesAfterEnv": [ + "/jest.setup.js" + ], + "unmockedModulePathPatterns": [ + "react" + ], + "testMatch": [ + "/test/**/?(*.)(spec|test).js?(x)" + ], + "collectCoverage": true, + "collectCoverageFrom": [ + "/src/**/*.js", + "!**/node_modules/**", + "!**/test/**" + ], + "coverageReporters": [ + "lcov", + "text", + "html" + ], + "testPathIgnorePatterns": [], + "testTimeout": 30000, + "testEnvironment": "node" } } diff --git a/src/acorn-jsx-walk.js b/src/acorn-jsx-walk.js index b749774..2b5387d 100644 --- a/src/acorn-jsx-walk.js +++ b/src/acorn-jsx-walk.js @@ -3,56 +3,56 @@ import { simple as walk, base } from 'acorn-walk'; import { DynamicImportKey } from 'acorn-dynamic-import'; Object.assign(base, { - FieldDefinition(node, state, callback) { - if (node.value !== null) { - callback(node.value, state); - } - }, - - // Workaround with `acorn` and `acorn-dynamic-import` - // https://github.com/kristoferbaxter/dynamic-walk/blob/master/workaround.js - [DynamicImportKey]: () => {}, + FieldDefinition(node, state, callback) { + if (node.value !== null) { + callback(node.value, state); + } + }, + + // Workaround with `acorn` and `acorn-dynamic-import` + // https://github.com/kristoferbaxter/dynamic-walk/blob/master/workaround.js + [DynamicImportKey]: () => {}, }); // Extends acorn walk with JSX elements // https://github.com/RReverser/acorn-jsx/issues/23#issuecomment-403753801 Object.assign(base, { - JSXAttribute(node, state, callback) { - if (node.value !== null) { - callback(node.value, state); - } - }, - - JSXElement(node, state, callback) { - node.openingElement.attributes.forEach(attribute => { - callback(attribute, state); - }); - node.children.forEach(node => { - callback(node, state); - }); - }, - - JSXEmptyExpression(node, state, callback) { - // Comments. Just ignore. - }, - - JSXExpressionContainer(node, state, callback) { - callback(node.expression, state); - }, - - JSXFragment(node, state, callback) { - node.children.forEach(node => { - callback(node, state); - }); - }, - - JSXSpreadAttribute(node, state, callback) { - callback(node.argument, state); - }, - - JSXText() {} + JSXAttribute(node, state, callback) { + if (node.value !== null) { + callback(node.value, state); + } + }, + + JSXElement(node, state, callback) { + node.openingElement.attributes.forEach(attribute => { + callback(attribute, state); + }); + node.children.forEach(node => { + callback(node, state); + }); + }, + + JSXEmptyExpression(node, state, callback) { + // Comments. Just ignore. + }, + + JSXExpressionContainer(node, state, callback) { + callback(node.expression, state); + }, + + JSXFragment(node, state, callback) { + node.children.forEach(node => { + callback(node, state); + }); + }, + + JSXSpreadAttribute(node, state, callback) { + callback(node.argument, state); + }, + + JSXText() {} }); export default (ast, options) => { - walk(ast, { ...options }); + walk(ast, { ...options }); }; diff --git a/src/flatten-object-keys.js b/src/flatten-object-keys.js index fc6fa78..1456a18 100644 --- a/src/flatten-object-keys.js +++ b/src/flatten-object-keys.js @@ -18,12 +18,12 @@ import isPlainObject from 'lodash/isPlainObject'; // [ 'a', 'b', 'd', 'g' ] ] // const flattenObjectKeys = (obj, keys = []) => { - return Object.keys(obj).reduce((acc, key) => { - const o = ((isPlainObject(obj[key]) && Object.keys(obj[key]).length > 0) || (Array.isArray(obj[key]) && obj[key].length > 0)) - ? flattenObjectKeys(obj[key], keys.concat(key)) - : [keys.concat(key)]; - return acc.concat(o); - }, []); + return Object.keys(obj).reduce((acc, key) => { + const o = ((isPlainObject(obj[key]) && Object.keys(obj[key]).length > 0) || (Array.isArray(obj[key]) && obj[key].length > 0)) + ? flattenObjectKeys(obj[key], keys.concat(key)) + : [keys.concat(key)]; + return acc.concat(o); + }, []); }; export default flattenObjectKeys; diff --git a/src/index.js b/src/index.js index f48efce..d2b9abe 100644 --- a/src/index.js +++ b/src/index.js @@ -9,102 +9,102 @@ import through2 from 'through2'; import Parser from './parser'; const transform = (parser, customTransform) => { - return function _transform(file, enc, done) { - const { options } = parser; - const content = fs.readFileSync(file.path, enc); - const extname = path.extname(file.path); - - if (includes(get(options, 'attr.extensions'), extname)) { - // Parse attribute (e.g. data-i18n="key") - parser.parseAttrFromString(content, { - transformOptions: { - filepath: file.path - } - }); + return function _transform(file, enc, done) { + const { options } = parser; + const content = fs.readFileSync(file.path, enc); + const extname = path.extname(file.path); + + if (includes(get(options, 'attr.extensions'), extname)) { + // Parse attribute (e.g. data-i18n="key") + parser.parseAttrFromString(content, { + transformOptions: { + filepath: file.path } - - if (includes(get(options, 'func.extensions'), extname)) { - // Parse translation function (e.g. i18next.t('key')) - parser.parseFuncFromString(content, { - transformOptions: { - filepath: file.path - } - }); + }); + } + + if (includes(get(options, 'func.extensions'), extname)) { + // Parse translation function (e.g. i18next.t('key')) + parser.parseFuncFromString(content, { + transformOptions: { + filepath: file.path } - - if (includes(get(options, 'trans.extensions'), extname)) { - // Look for Trans components in JSX - parser.parseTransFromString(content, { - transformOptions: { - filepath: file.path - } - }); + }); + } + + if (includes(get(options, 'trans.extensions'), extname)) { + // Look for Trans components in JSX + parser.parseTransFromString(content, { + transformOptions: { + filepath: file.path } + }); + } - if (typeof customTransform === 'function') { - this.parser = parser; - customTransform.call(this, file, enc, done); - return; - } + if (typeof customTransform === 'function') { + this.parser = parser; + customTransform.call(this, file, enc, done); + return; + } - done(); - }; + done(); + }; }; const flush = (parser, customFlush) => { - return function _flush(done) { - const { options } = parser; + return function _flush(done) { + const { options } = parser; + + if (typeof customFlush === 'function') { + this.parser = parser; + customFlush.call(this, done); + return; + } + + // Flush to resource store + const resStore = parser.get({ sort: options.sort }); + const { jsonIndent } = options.resource; + const lineEnding = String(options.resource.lineEnding).toLowerCase(); + + Object.keys(resStore).forEach((lng) => { + const namespaces = resStore[lng]; + + Object.keys(namespaces).forEach((ns) => { + const obj = namespaces[ns]; + const resPath = parser.formatResourceSavePath(lng, ns); + let text = JSON.stringify(obj, null, jsonIndent) + '\n'; + + if (lineEnding === 'auto') { + text = eol.auto(text); + } else if (lineEnding === '\r\n' || lineEnding === 'crlf') { + text = eol.crlf(text); + } else if (lineEnding === '\n' || lineEnding === 'lf') { + text = eol.lf(text); + } else if (lineEnding === '\r' || lineEnding === 'cr') { + text = eol.cr(text); + } else { // Defaults to LF + text = eol.lf(text); + } - if (typeof customFlush === 'function') { - this.parser = parser; - customFlush.call(this, done); - return; + let contents = null; + + try { + // "Buffer.from(string[, encoding])" is added in Node.js v5.10.0 + contents = Buffer.from(text); + } catch (e) { + // Fallback to "new Buffer(string[, encoding])" which is deprecated since Node.js v6.0.0 + contents = new Buffer(text); } - // Flush to resource store - const resStore = parser.get({ sort: options.sort }); - const { jsonIndent } = options.resource; - const lineEnding = String(options.resource.lineEnding).toLowerCase(); - - Object.keys(resStore).forEach((lng) => { - const namespaces = resStore[lng]; - - Object.keys(namespaces).forEach((ns) => { - const obj = namespaces[ns]; - const resPath = parser.formatResourceSavePath(lng, ns); - let text = JSON.stringify(obj, null, jsonIndent) + '\n'; - - if (lineEnding === 'auto') { - text = eol.auto(text); - } else if (lineEnding === '\r\n' || lineEnding === 'crlf') { - text = eol.crlf(text); - } else if (lineEnding === '\n' || lineEnding === 'lf') { - text = eol.lf(text); - } else if (lineEnding === '\r' || lineEnding === 'cr') { - text = eol.cr(text); - } else { // Defaults to LF - text = eol.lf(text); - } - - let contents = null; - - try { - // "Buffer.from(string[, encoding])" is added in Node.js v5.10.0 - contents = Buffer.from(text); - } catch (e) { - // Fallback to "new Buffer(string[, encoding])" which is deprecated since Node.js v6.0.0 - contents = new Buffer(text); - } - - this.push(new VirtualFile({ - path: resPath, - contents: contents - })); - }); - }); - - done(); - }; + this.push(new VirtualFile({ + path: resPath, + contents: contents + })); + }); + }); + + done(); + }; }; // @param {object} options The options object. @@ -112,20 +112,18 @@ const flush = (parser, customFlush) => { // @param {function} [customFlush] // @return {object} Returns a through2.obj(). const createStream = (options, customTransform, customFlush) => { - const parser = new Parser(options); - const stream = through2.obj( - transform(parser, customTransform), - flush(parser, customFlush) - ); + const parser = new Parser(options); + const stream = through2.obj( + transform(parser, customTransform), + flush(parser, customFlush) + ); - return stream; + return stream; }; -// Convenience API -module.exports = (...args) => module.exports.createStream(...args); +export default (...args) => createStream(...args); -// Basic API -module.exports.createStream = createStream; - -// Parser -module.exports.Parser = Parser; +export { + createStream, + Parser, +}; diff --git a/src/nodes-to-string.js b/src/nodes-to-string.js index f72d9ae..49778ab 100644 --- a/src/nodes-to-string.js +++ b/src/nodes-to-string.js @@ -1,74 +1,74 @@ import _get from 'lodash/get'; const isJSXText = (node) => { - if (!node) { - return false; - } + if (!node) { + return false; + } - return node.type === 'JSXText'; + return node.type === 'JSXText'; }; const isNumericLiteral = (node) => { - if (!node) { - return false; - } + if (!node) { + return false; + } - return node.type === 'Literal' && typeof node.value === 'number'; + return node.type === 'Literal' && typeof node.value === 'number'; }; const isStringLiteral = (node) => { - if (!node) { - return false; - } + if (!node) { + return false; + } - return node.type === 'Literal' && typeof node.value === 'string'; + return node.type === 'Literal' && typeof node.value === 'string'; }; const isObjectExpression = (node) => { - if (!node) { - return false; - } + if (!node) { + return false; + } - return node.type === 'ObjectExpression'; + return node.type === 'ObjectExpression'; }; const nodesToString = (nodes, code) => { - let memo = ''; - let nodeIndex = 0; - nodes.forEach((node, i) => { - if (isJSXText(node) || isStringLiteral(node)) { - const value = (node.value) - .replace(/^[\r\n]+\s*/g, '') // remove leading spaces containing a leading newline character - .replace(/[\r\n]+\s*$/g, '') // remove trailing spaces containing a leading newline character - .replace(/[\r\n]+\s*/g, ' '); // replace spaces containing a leading newline character with a single space character + let memo = ''; + let nodeIndex = 0; + nodes.forEach((node, i) => { + if (isJSXText(node) || isStringLiteral(node)) { + const value = (node.value) + .replace(/^[\r\n]+\s*/g, '') // remove leading spaces containing a leading newline character + .replace(/[\r\n]+\s*$/g, '') // remove trailing spaces containing a leading newline character + .replace(/[\r\n]+\s*/g, ' '); // replace spaces containing a leading newline character with a single space character - if (!value) { - return; - } - memo += value; - } else if (node.type === 'JSXExpressionContainer') { - const { expression = {} } = node; + if (!value) { + return; + } + memo += value; + } else if (node.type === 'JSXExpressionContainer') { + const { expression = {} } = node; - if (isNumericLiteral(expression)) { - // Numeric literal is ignored in react-i18next - memo += ''; - } if (isStringLiteral(expression)) { - memo += expression.value; - } else if (isObjectExpression(expression) && (_get(expression, 'properties[0].type') === 'Property')) { - memo += `<${nodeIndex}>{{${expression.properties[0].key.name}}}`; - } else { - console.error(`Unsupported JSX expression. Only static values or {{interpolation}} blocks are supported. Got ${expression.type}:`); - console.error(code.slice(node.start, node.end)); - console.error(node.expression); - } - } else if (node.children) { - memo += `<${nodeIndex}>${nodesToString(node.children, code)}`; - } + if (isNumericLiteral(expression)) { + // Numeric literal is ignored in react-i18next + memo += ''; + } if (isStringLiteral(expression)) { + memo += expression.value; + } else if (isObjectExpression(expression) && (_get(expression, 'properties[0].type') === 'Property')) { + memo += `<${nodeIndex}>{{${expression.properties[0].key.name}}}`; + } else { + console.error(`Unsupported JSX expression. Only static values or {{interpolation}} blocks are supported. Got ${expression.type}:`); + console.error(code.slice(node.start, node.end)); + console.error(node.expression); + } + } else if (node.children) { + memo += `<${nodeIndex}>${nodesToString(node.children, code)}`; + } - ++nodeIndex; - }); + ++nodeIndex; + }); - return memo; + return memo; }; export default nodesToString; diff --git a/src/omit-empty-object.js b/src/omit-empty-object.js index 682973c..edf69ad 100644 --- a/src/omit-empty-object.js +++ b/src/omit-empty-object.js @@ -16,19 +16,19 @@ import cloneDeep from 'clone-deep'; // { a: { b: { c: 1 } } } // const unsetEmptyObject = (obj) => { - Object.keys(obj).forEach(key => { - if (!isPlainObject(obj[key])) { - return; - } + Object.keys(obj).forEach(key => { + if (!isPlainObject(obj[key])) { + return; + } - unsetEmptyObject(obj[key]); - if (isPlainObject(obj[key]) && Object.keys(obj[key]).length === 0) { - obj[key] = undefined; - delete obj[key]; - } - }); + unsetEmptyObject(obj[key]); + if (isPlainObject(obj[key]) && Object.keys(obj[key]).length === 0) { + obj[key] = undefined; + delete obj[key]; + } + }); - return obj; + return obj; }; const omitEmptyObject = (obj) => unsetEmptyObject(cloneDeep(obj)); diff --git a/src/parser.js b/src/parser.js index 7e20b00..53ce926 100644 --- a/src/parser.js +++ b/src/parser.js @@ -19,201 +19,201 @@ import omitEmptyObject from './omit-empty-object'; import nodesToString from './nodes-to-string'; i18next.init({ - compatibilityJSON: 'v3', + compatibilityJSON: 'v3', }); const defaults = { - debug: false, // verbose logging - - sort: false, // sort keys in alphabetical order - - attr: { // HTML attributes to parse - list: ['data-i18n'], - extensions: ['.html', '.htm'] - }, - - func: { // function names to parse - list: ['i18next.t', 'i18n.t'], - extensions: ['.js', '.jsx'] - }, - - trans: { // Trans component (https://github.com/i18next/react-i18next) - component: 'Trans', - i18nKey: 'i18nKey', - defaultsKey: 'defaults', - extensions: ['.js', '.jsx'], - fallbackKey: false, - acorn: { - ecmaVersion: 2020, // defaults to 2020 - sourceType: 'module', // defaults to 'module' - // Check out https://github.com/acornjs/acorn/tree/master/acorn#interface for additional options - } - }, + debug: false, // verbose logging + + sort: false, // sort keys in alphabetical order + + attr: { // HTML attributes to parse + list: ['data-i18n'], + extensions: ['.html', '.htm'] + }, + + func: { // function names to parse + list: ['i18next.t', 'i18n.t'], + extensions: ['.js', '.jsx'] + }, + + trans: { // Trans component (https://github.com/i18next/react-i18next) + component: 'Trans', + i18nKey: 'i18nKey', + defaultsKey: 'defaults', + extensions: ['.js', '.jsx'], + fallbackKey: false, + acorn: { + ecmaVersion: 2020, // defaults to 2020 + sourceType: 'module', // defaults to 'module' + // Check out https://github.com/acornjs/acorn/tree/master/acorn#interface for additional options + } + }, - lngs: ['en'], // array of supported languages - fallbackLng: 'en', // language to lookup key if not found while calling `parser.get(key, { lng: '' })` + lngs: ['en'], // array of supported languages + fallbackLng: 'en', // language to lookup key if not found while calling `parser.get(key, { lng: '' })` - ns: [], // string or array of namespaces + ns: [], // string or array of namespaces - defaultLng: 'en', // default language used for checking default values + defaultLng: 'en', // default language used for checking default values - defaultNs: 'translation', // default namespace used if not passed to translation function + defaultNs: 'translation', // default namespace used if not passed to translation function - defaultValue: '', // default value used if not passed to `parser.set` + defaultValue: '', // default value used if not passed to `parser.set` - // resource - resource: { - // The path where resources get loaded from. Relative to current working directory. - loadPath: 'i18n/{{lng}}/{{ns}}.json', + // resource + resource: { + // The path where resources get loaded from. Relative to current working directory. + loadPath: 'i18n/{{lng}}/{{ns}}.json', - // The path to store resources. Relative to the path specified by `gulp.dest(path)`. - savePath: 'i18n/{{lng}}/{{ns}}.json', + // The path to store resources. Relative to the path specified by `gulp.dest(path)`. + savePath: 'i18n/{{lng}}/{{ns}}.json', - // Specify the number of space characters to use as white space to insert into the output JSON string for readability purpose. - jsonIndent: 2, + // Specify the number of space characters to use as white space to insert into the output JSON string for readability purpose. + jsonIndent: 2, - // Normalize line endings to '\r\n', '\r', '\n', or 'auto' for the current operating system. Defaults to '\n'. - // Aliases: 'CRLF', 'CR', 'LF', 'crlf', 'cr', 'lf' - lineEnding: '\n' - }, + // Normalize line endings to '\r\n', '\r', '\n', or 'auto' for the current operating system. Defaults to '\n'. + // Aliases: 'CRLF', 'CR', 'LF', 'crlf', 'cr', 'lf' + lineEnding: '\n' + }, - keySeparator: '.', // char to separate keys - nsSeparator: ':', // char to split namespace from key + keySeparator: '.', // char to separate keys + nsSeparator: ':', // char to split namespace from key - // Context Form - context: true, // whether to add context form key - contextFallback: true, // whether to add a fallback key as well as the context form key - contextSeparator: '_', // char to split context from key - contextDefaultValues: [], // list of values for dynamic values + // Context Form + context: true, // whether to add context form key + contextFallback: true, // whether to add a fallback key as well as the context form key + contextSeparator: '_', // char to split context from key + contextDefaultValues: [], // list of values for dynamic values - // Plural Form - plural: true, // whether to add plural form key - pluralFallback: true, // whether to add a fallback key as well as the plural form key - pluralSeparator: '_', // char to split plural from key + // Plural Form + plural: true, // whether to add plural form key + pluralFallback: true, // whether to add a fallback key as well as the plural form key + pluralSeparator: '_', // char to split plural from key - // interpolation options - interpolation: { - prefix: '{{', // prefix for interpolation - suffix: '}}' // suffix for interpolation - }, - metadata: {}, // additional custom options - allowDynamicKeys: false, // allow Dynamic Keys + // interpolation options + interpolation: { + prefix: '{{', // prefix for interpolation + suffix: '}}' // suffix for interpolation + }, + metadata: {}, // additional custom options + allowDynamicKeys: false, // allow Dynamic Keys }; // http://codereview.stackexchange.com/questions/45991/balanced-parentheses const matchBalancedParentheses = (str = '') => { - const parentheses = '[]{}()'; - const stack = []; - let bracePosition; - let start = -1; - let i = 0; - - str = '' + str; // ensure string - for (i = 0; i < str.length; ++i) { - if ((start >= 0) && (stack.length === 0)) { - return str.substring(start, i); - } - - bracePosition = parentheses.indexOf(str[i]); - if (bracePosition < 0) { - continue; - } - if ((bracePosition % 2) === 0) { - if (start < 0) { - start = i; // remember the start position - } - stack.push(bracePosition + 1); // push next expected brace position - continue; - } - - if (stack.pop() !== bracePosition) { - return str.substring(start, i); - } + const parentheses = '[]{}()'; + const stack = []; + let bracePosition; + let start = -1; + let i = 0; + + str = '' + str; // ensure string + for (i = 0; i < str.length; ++i) { + if ((start >= 0) && (stack.length === 0)) { + return str.substring(start, i); } - return str.substring(start, i); -}; - -const normalizeOptions = (options) => { - // Attribute - if (_.isUndefined(_.get(options, 'attr.list'))) { - _.set(options, 'attr.list', defaults.attr.list); + bracePosition = parentheses.indexOf(str[i]); + if (bracePosition < 0) { + continue; } - if (_.isUndefined(_.get(options, 'attr.extensions'))) { - _.set(options, 'attr.extensions', defaults.attr.extensions); + if ((bracePosition % 2) === 0) { + if (start < 0) { + start = i; // remember the start position + } + stack.push(bracePosition + 1); // push next expected brace position + continue; } - // Function - if (_.isUndefined(_.get(options, 'func.list'))) { - _.set(options, 'func.list', defaults.func.list); - } - if (_.isUndefined(_.get(options, 'func.extensions'))) { - _.set(options, 'func.extensions', defaults.func.extensions); + if (stack.pop() !== bracePosition) { + return str.substring(start, i); } + } - // Trans - if (_.get(options, 'trans')) { - if (_.isUndefined(_.get(options, 'trans.component'))) { - _.set(options, 'trans.component', defaults.trans.component); - } - if (_.isUndefined(_.get(options, 'trans.i18nKey'))) { - _.set(options, 'trans.i18nKey', defaults.trans.i18nKey); - } - if (_.isUndefined(_.get(options, 'trans.defaultsKey'))) { - _.set(options, 'trans.defaultsKey', defaults.trans.defaultsKey); - } - if (_.isUndefined(_.get(options, 'trans.extensions'))) { - _.set(options, 'trans.extensions', defaults.trans.extensions); - } - if (_.isUndefined(_.get(options, 'trans.fallbackKey'))) { - _.set(options, 'trans.fallbackKey', defaults.trans.fallbackKey); - } - if (_.isUndefined(_.get(options, 'trans.acorn'))) { - _.set(options, 'trans.acorn', defaults.trans.acorn); - } - } + return str.substring(start, i); +}; - // Resource - if (_.isUndefined(_.get(options, 'resource.loadPath'))) { - _.set(options, 'resource.loadPath', defaults.resource.loadPath); - } - if (_.isUndefined(_.get(options, 'resource.savePath'))) { - _.set(options, 'resource.savePath', defaults.resource.savePath); - } - if (_.isUndefined(_.get(options, 'resource.jsonIndent'))) { - _.set(options, 'resource.jsonIndent', defaults.resource.jsonIndent); +const normalizeOptions = (options) => { + // Attribute + if (_.isUndefined(_.get(options, 'attr.list'))) { + _.set(options, 'attr.list', defaults.attr.list); + } + if (_.isUndefined(_.get(options, 'attr.extensions'))) { + _.set(options, 'attr.extensions', defaults.attr.extensions); + } + + // Function + if (_.isUndefined(_.get(options, 'func.list'))) { + _.set(options, 'func.list', defaults.func.list); + } + if (_.isUndefined(_.get(options, 'func.extensions'))) { + _.set(options, 'func.extensions', defaults.func.extensions); + } + + // Trans + if (_.get(options, 'trans')) { + if (_.isUndefined(_.get(options, 'trans.component'))) { + _.set(options, 'trans.component', defaults.trans.component); } - if (_.isUndefined(_.get(options, 'resource.lineEnding'))) { - _.set(options, 'resource.lineEnding', defaults.resource.lineEnding); + if (_.isUndefined(_.get(options, 'trans.i18nKey'))) { + _.set(options, 'trans.i18nKey', defaults.trans.i18nKey); } - - // Accept both nsseparator or nsSeparator - if (!_.isUndefined(options.nsseparator)) { - options.nsSeparator = options.nsseparator; - delete options.nsseparator; + if (_.isUndefined(_.get(options, 'trans.defaultsKey'))) { + _.set(options, 'trans.defaultsKey', defaults.trans.defaultsKey); } - // Allowed only string or false - if (!_.isString(options.nsSeparator)) { - options.nsSeparator = false; + if (_.isUndefined(_.get(options, 'trans.extensions'))) { + _.set(options, 'trans.extensions', defaults.trans.extensions); } - - // Accept both keyseparator or keySeparator - if (!_.isUndefined(options.keyseparator)) { - options.keySeparator = options.keyseparator; - delete options.keyseparator; + if (_.isUndefined(_.get(options, 'trans.fallbackKey'))) { + _.set(options, 'trans.fallbackKey', defaults.trans.fallbackKey); } - // Allowed only string or false - if (!_.isString(options.keySeparator)) { - options.keySeparator = false; + if (_.isUndefined(_.get(options, 'trans.acorn'))) { + _.set(options, 'trans.acorn', defaults.trans.acorn); } - - if (!_.isArray(options.ns)) { - options.ns = [options.ns]; - } - - options.ns = _.union(_.flatten(options.ns.concat(options.defaultNs))); - - return options; + } + + // Resource + if (_.isUndefined(_.get(options, 'resource.loadPath'))) { + _.set(options, 'resource.loadPath', defaults.resource.loadPath); + } + if (_.isUndefined(_.get(options, 'resource.savePath'))) { + _.set(options, 'resource.savePath', defaults.resource.savePath); + } + if (_.isUndefined(_.get(options, 'resource.jsonIndent'))) { + _.set(options, 'resource.jsonIndent', defaults.resource.jsonIndent); + } + if (_.isUndefined(_.get(options, 'resource.lineEnding'))) { + _.set(options, 'resource.lineEnding', defaults.resource.lineEnding); + } + + // Accept both nsseparator or nsSeparator + if (!_.isUndefined(options.nsseparator)) { + options.nsSeparator = options.nsseparator; + delete options.nsseparator; + } + // Allowed only string or false + if (!_.isString(options.nsSeparator)) { + options.nsSeparator = false; + } + + // Accept both keyseparator or keySeparator + if (!_.isUndefined(options.keyseparator)) { + options.keySeparator = options.keyseparator; + delete options.keyseparator; + } + // Allowed only string or false + if (!_.isString(options.keySeparator)) { + options.keySeparator = false; + } + + if (!_.isArray(options.ns)) { + options.ns = [options.ns]; + } + + options.ns = _.union(_.flatten(options.ns.concat(options.defaultNs))); + + return options; }; // Get an array of plural suffixes for a given language. @@ -221,25 +221,25 @@ const normalizeOptions = (options) => { // @param {string} pluralSeparator pluralSeparator, default '_'. // @return {array} An array of plural suffixes. const getPluralSuffixes = (lng, pluralSeparator = '_') => { - const rule = i18next.services.pluralResolver.getRule(lng); + const rule = i18next.services.pluralResolver.getRule(lng); - if (!(rule && rule.numbers)) { - return []; // Return an empty array if lng is not supported - } + if (!(rule && rule.numbers)) { + return []; // Return an empty array if lng is not supported + } - if (rule.numbers.length === 1) { - return [`${pluralSeparator}0`]; - } + if (rule.numbers.length === 1) { + return [`${pluralSeparator}0`]; + } - if (rule.numbers.length === 2) { - return ['', `${pluralSeparator}plural`]; - } + if (rule.numbers.length === 2) { + return ['', `${pluralSeparator}plural`]; + } - const suffixes = rule.numbers.reduce((acc, n, i) => { - return acc.concat(`${pluralSeparator}${i}`); - }, []); + const suffixes = rule.numbers.reduce((acc, n, i) => { + return acc.concat(`${pluralSeparator}${i}`); + }, []); - return suffixes; + return suffixes; }; /** @@ -247,199 +247,199 @@ const getPluralSuffixes = (lng, pluralSeparator = '_') => { * @constructor */ class Parser { - options = { ...defaults }; - - // The resStore stores all translation keys including unused ones - resStore = {}; + options = { ...defaults }; - // The resScan only stores translation keys parsed from code - resScan = {}; + // The resStore stores all translation keys including unused ones + resStore = {}; - // The all plurals suffixes for each of target languages. - pluralSuffixes = {}; + // The resScan only stores translation keys parsed from code + resScan = {}; - constructor(options) { - this.options = normalizeOptions({ - ...this.options, - ...options - }); + // The all plurals suffixes for each of target languages. + pluralSuffixes = {}; - const lngs = this.options.lngs; - const namespaces = this.options.ns; + constructor(options) { + this.options = normalizeOptions({ + ...this.options, + ...options + }); - lngs.forEach((lng) => { - this.resStore[lng] = this.resStore[lng] || {}; - this.resScan[lng] = this.resScan[lng] || {}; - - this.pluralSuffixes[lng] = ensureArray(getPluralSuffixes(lng, this.options.pluralSeparator)); - if (this.pluralSuffixes[lng].length === 0) { - this.log(`No plural rule found for: ${lng}`); - } + const lngs = this.options.lngs; + const namespaces = this.options.ns; - namespaces.forEach((ns) => { - const resPath = this.formatResourceLoadPath(lng, ns); + lngs.forEach((lng) => { + this.resStore[lng] = this.resStore[lng] || {}; + this.resScan[lng] = this.resScan[lng] || {}; - this.resStore[lng][ns] = {}; - this.resScan[lng][ns] = {}; + this.pluralSuffixes[lng] = ensureArray(getPluralSuffixes(lng, this.options.pluralSeparator)); + if (this.pluralSuffixes[lng].length === 0) { + this.log(`No plural rule found for: ${lng}`); + } - try { - if (fs.existsSync(resPath)) { - this.resStore[lng][ns] = JSON.parse(fs.readFileSync(resPath, 'utf-8')); - } - } catch (err) { - this.error(`Unable to load resource file ${chalk.yellow(JSON.stringify(resPath))}: lng=${lng}, ns=${ns}`); - this.error(err); - } - }); - }); + namespaces.forEach((ns) => { + const resPath = this.formatResourceLoadPath(lng, ns); - this.log(`options=${JSON.stringify(this.options, null, 2)}`); - } + this.resStore[lng][ns] = {}; + this.resScan[lng][ns] = {}; - log(...args) { - const { debug } = this.options; - if (debug) { - console.log.apply(this, [chalk.cyan('i18next-scanner:')].concat(args)); + try { + if (fs.existsSync(resPath)) { + this.resStore[lng][ns] = JSON.parse(fs.readFileSync(resPath, 'utf-8')); + } + } catch (err) { + this.error(`Unable to load resource file ${chalk.yellow(JSON.stringify(resPath))}: lng=${lng}, ns=${ns}`); + this.error(err); } - } - - error(...args) { - console.error.apply(this, [chalk.red('i18next-scanner:')].concat(args)); - } - - formatResourceLoadPath(lng, ns) { - const options = this.options; + }); + }); - const regex = { - lng: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'lng' + options.interpolation.suffix), 'g'), - ns: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'ns' + options.interpolation.suffix), 'g') - }; + this.log(`options=${JSON.stringify(this.options, null, 2)}`); + } - return _.isFunction(options.resource.loadPath) - ? options.resource.loadPath(lng, ns) - : options.resource.loadPath - .replace(regex.lng, lng) - .replace(regex.ns, ns); + log(...args) { + const { debug } = this.options; + if (debug) { + console.log.apply(this, [chalk.cyan('i18next-scanner:')].concat(args)); } - - formatResourceSavePath(lng, ns) { - const options = this.options; - const regex = { - lng: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'lng' + options.interpolation.suffix), 'g'), - ns: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'ns' + options.interpolation.suffix), 'g') - }; - - return _.isFunction(options.resource.savePath) - ? options.resource.savePath(lng, ns) - : options.resource.savePath - .replace(regex.lng, lng) - .replace(regex.ns, ns); + } + + error(...args) { + console.error.apply(this, [chalk.red('i18next-scanner:')].concat(args)); + } + + formatResourceLoadPath(lng, ns) { + const options = this.options; + + const regex = { + lng: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'lng' + options.interpolation.suffix), 'g'), + ns: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'ns' + options.interpolation.suffix), 'g') + }; + + return _.isFunction(options.resource.loadPath) + ? options.resource.loadPath(lng, ns) + : options.resource.loadPath + .replace(regex.lng, lng) + .replace(regex.ns, ns); + } + + formatResourceSavePath(lng, ns) { + const options = this.options; + const regex = { + lng: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'lng' + options.interpolation.suffix), 'g'), + ns: new RegExp(_.escapeRegExp(options.interpolation.prefix + 'ns' + options.interpolation.suffix), 'g') + }; + + return _.isFunction(options.resource.savePath) + ? options.resource.savePath(lng, ns) + : options.resource.savePath + .replace(regex.lng, lng) + .replace(regex.ns, ns); + } + + fixStringAfterRegExp(strToFix) { + const options = this.options; + let fixedString = _.trim(strToFix); // Remove leading and trailing whitespace + const firstChar = fixedString[0]; + + if (firstChar === '`' && fixedString.match(/\${.*?}/)) { + if (options.allowDynamicKeys && fixedString.endsWith('}`')) { + // Allow Dyanmic Keys at the end of the string literal with option enabled + fixedString = fixedString.replace(/\$\{(.+?)\}/g, ''); + } else { + // Ignore key with embedded expressions in string literals + return null; + } } - fixStringAfterRegExp(strToFix) { - const options = this.options; - let fixedString = _.trim(strToFix); // Remove leading and trailing whitespace - const firstChar = fixedString[0]; - - if (firstChar === '`' && fixedString.match(/\${.*?}/)) { - if (options.allowDynamicKeys && fixedString.endsWith('}`')) { - // Allow Dyanmic Keys at the end of the string literal with option enabled - fixedString = fixedString.replace(/\$\{(.+?)\}/g, ''); - } else { - // Ignore key with embedded expressions in string literals - return null; - } - } - - if (_.includes(['\'', '"', '`'], firstChar)) { - // Remove first and last character - fixedString = fixedString.slice(1, -1); - } - - // restore multiline strings - fixedString = fixedString.replace(/(\\\n|\\\r\n)/g, ''); - - // JavaScript character escape sequences - // https://mathiasbynens.be/notes/javascript-escapes - - // Single character escape sequences - // Note: IE < 9 treats '\v' as 'v' instead of a vertical tab ('\x0B'). If cross-browser compatibility is a concern, use \x0B instead of \v. - // Another thing to note is that the \v and \0 escapes are not allowed in JSON strings. - fixedString = fixedString.replace(/(\\b|\\f|\\n|\\r|\\t|\\v|\\0|\\\\|\\"|\\')/g, (match) => eval(`"${match}"`)); - - // * Octal escapes have been deprecated in ES5. - // * Hexadecimal escape sequences: \\x[a-fA-F0-9]{2} - // * Unicode escape sequences: \\u[a-fA-F0-9]{4} - fixedString = fixedString.replace(/(\\x[a-fA-F0-9]{2}|\\u[a-fA-F0-9]{4})/g, (match) => eval(`"${match}"`)); - return fixedString; + if (_.includes(['\'', '"', '`'], firstChar)) { + // Remove first and last character + fixedString = fixedString.slice(1, -1); } - handleObjectExpression(props) { - return props.reduce((acc, prop) => { - if (prop.type !== 'ObjectMethod') { - const value = this.optionsBuilder(prop.value); - if (value !== undefined) { - return { - ...acc, - [prop.key.name]: value - }; - } - } - return acc; - }, {}); - } - - handleArrayExpression(elements) { - return elements.reduce((acc, element) => [ + // restore multiline strings + fixedString = fixedString.replace(/(\\\n|\\\r\n)/g, ''); + + // JavaScript character escape sequences + // https://mathiasbynens.be/notes/javascript-escapes + + // Single character escape sequences + // Note: IE < 9 treats '\v' as 'v' instead of a vertical tab ('\x0B'). If cross-browser compatibility is a concern, use \x0B instead of \v. + // Another thing to note is that the \v and \0 escapes are not allowed in JSON strings. + fixedString = fixedString.replace(/(\\b|\\f|\\n|\\r|\\t|\\v|\\0|\\\\|\\"|\\')/g, (match) => eval(`"${match}"`)); + + // * Octal escapes have been deprecated in ES5. + // * Hexadecimal escape sequences: \\x[a-fA-F0-9]{2} + // * Unicode escape sequences: \\u[a-fA-F0-9]{4} + fixedString = fixedString.replace(/(\\x[a-fA-F0-9]{2}|\\u[a-fA-F0-9]{4})/g, (match) => eval(`"${match}"`)); + return fixedString; + } + + handleObjectExpression(props) { + return props.reduce((acc, prop) => { + if (prop.type !== 'ObjectMethod') { + const value = this.optionsBuilder(prop.value); + if (value !== undefined) { + return { ...acc, - this.optionsBuilder(element) - ], - [],); - } - - optionsBuilder(prop) { - if (prop.value && prop.value.type === 'Literal' || prop.type && prop.type === 'Literal') { - return prop.value.value !== undefined ? prop.value.value : prop.value; - } else if (prop.value && prop.value.type === 'TemplateLiteral' || prop.type && prop.type === 'TemplateLiteral') { - return prop.value.quasis.map((element) => { - return element.value.cooked; - }).join(''); - } else if (prop.value && prop.value.type === 'ObjectExpression' || prop.type && prop.type === 'ObjectExpression') { - return this.handleObjectExpression(prop.value.properties); - } else if (prop.value && prop.value.type === 'ArrayExpression' || prop.type && prop.type === 'ArrayExpression') { - return this.handleArrayExpression(prop.elements); - } else { - // Unable to get value of the property - return ''; + [prop.key.name]: value + }; } + } + return acc; + }, {}); + } + + handleArrayExpression(elements) { + return elements.reduce((acc, element) => [ + ...acc, + this.optionsBuilder(element) + ], + [],); + } + + optionsBuilder(prop) { + if (prop.value && prop.value.type === 'Literal' || prop.type && prop.type === 'Literal') { + return prop.value.value !== undefined ? prop.value.value : prop.value; + } else if (prop.value && prop.value.type === 'TemplateLiteral' || prop.type && prop.type === 'TemplateLiteral') { + return prop.value.quasis.map((element) => { + return element.value.cooked; + }).join(''); + } else if (prop.value && prop.value.type === 'ObjectExpression' || prop.type && prop.type === 'ObjectExpression') { + return this.handleObjectExpression(prop.value.properties); + } else if (prop.value && prop.value.type === 'ArrayExpression' || prop.type && prop.type === 'ArrayExpression') { + return this.handleArrayExpression(prop.elements); + } else { + // Unable to get value of the property + return ''; + } + } + + // i18next.t('ns:foo.bar') // matched + // i18next.t("ns:foo.bar") // matched + // i18next.t('ns:foo.bar') // matched + // i18next.t("ns:foo.bar", { count: 1 }); // matched + // i18next.t("ns:foo.bar" + str); // not matched + parseFuncFromString(content, opts = {}, customHandler = null) { + if (_.isFunction(opts)) { + customHandler = opts; + opts = {}; } - // i18next.t('ns:foo.bar') // matched - // i18next.t("ns:foo.bar") // matched - // i18next.t('ns:foo.bar') // matched - // i18next.t("ns:foo.bar", { count: 1 }); // matched - // i18next.t("ns:foo.bar" + str); // not matched - parseFuncFromString(content, opts = {}, customHandler = null) { - if (_.isFunction(opts)) { - customHandler = opts; - opts = {}; - } - - const funcs = (opts.list !== undefined) - ? ensureArray(opts.list) - : ensureArray(this.options.func.list); + const funcs = (opts.list !== undefined) + ? ensureArray(opts.list) + : ensureArray(this.options.func.list); - if (funcs.length === 0) { - return this; - } + if (funcs.length === 0) { + return this; + } - const matchFuncs = funcs - .map(func => ('(?:' + func + ')')) - .join('|') - .replace(/\./g, '\\.'); - // `\s` matches a single whitespace character, which includes spaces, tabs, form feeds, line feeds and other unicode spaces. - const matchSpecialCharacters = '[\\r\\n\\s]*'; - const stringGroup = + const matchFuncs = funcs + .map(func => ('(?:' + func + ')')) + .join('|') + .replace(/\./g, '\\.'); + // `\s` matches a single whitespace character, which includes spaces, tabs, form feeds, line feeds and other unicode spaces. + const matchSpecialCharacters = '[\\r\\n\\s]*'; + const stringGroup = matchSpecialCharacters + '(' + // backtick (``) '`(?:[^`\\\\]|\\\\(?:.|$))*`' + @@ -450,617 +450,617 @@ class Parser { // single quote ('') '\'(?:[^\'\\\\]|\\\\(?:.|$))*\'' + ')' + matchSpecialCharacters; - const pattern = '(?:(?:^\\s*)|[^a-zA-Z0-9_])' + + const pattern = '(?:(?:^\\s*)|[^a-zA-Z0-9_])' + '(?:' + matchFuncs + ')' + '\\(' + stringGroup + '(?:[\\,]' + stringGroup + ')?' + '[\\,\\)]'; - const re = new RegExp(pattern, 'gim'); - - let r; - while ((r = re.exec(content))) { - const options = {}; - const full = r[0]; - - let key = this.fixStringAfterRegExp(r[1], true); - if (!key) { - continue; - } - - if (r[2] !== undefined) { - const defaultValue = this.fixStringAfterRegExp(r[2], false); - if (!defaultValue) { - continue; - } - options.defaultValue = defaultValue; - } + const re = new RegExp(pattern, 'gim'); + + let r; + while ((r = re.exec(content))) { + const options = {}; + const full = r[0]; + + let key = this.fixStringAfterRegExp(r[1], true); + if (!key) { + continue; + } + + if (r[2] !== undefined) { + const defaultValue = this.fixStringAfterRegExp(r[2], false); + if (!defaultValue) { + continue; + } + options.defaultValue = defaultValue; + } - const endsWithComma = (full[full.length - 1] === ','); - if (endsWithComma) { - const { propsFilter } = { ...opts }; + const endsWithComma = (full[full.length - 1] === ','); + if (endsWithComma) { + const { propsFilter } = { ...opts }; - let code = matchBalancedParentheses(content.substr(re.lastIndex)); + let code = matchBalancedParentheses(content.substr(re.lastIndex)); - if (typeof propsFilter === 'function') { - code = propsFilter(code); - } + if (typeof propsFilter === 'function') { + code = propsFilter(code); + } - try { - const syntax = code.trim() !== '' ? parse('(' + code + ')') : {}; - - const props = _.get(syntax, 'body[0].expression.properties') || []; - // http://i18next.com/docs/options/ - const supportedOptions = [ - 'defaultValue', - 'defaultValue_plural', - 'count', - 'context', - 'ns', - 'keySeparator', - 'nsSeparator', - 'metadata', - ]; - - props.forEach((prop) => { - if (_.includes(supportedOptions, prop.key.name)) { - options[prop.key.name] = this.optionsBuilder(prop); - } - }); - } catch (err) { - this.error(`Unable to parse code "${code}"`); - this.error(err); - } + try { + const syntax = code.trim() !== '' ? parse('(' + code + ')') : {}; + + const props = _.get(syntax, 'body[0].expression.properties') || []; + // http://i18next.com/docs/options/ + const supportedOptions = [ + 'defaultValue', + 'defaultValue_plural', + 'count', + 'context', + 'ns', + 'keySeparator', + 'nsSeparator', + 'metadata', + ]; + + props.forEach((prop) => { + if (_.includes(supportedOptions, prop.key.name)) { + options[prop.key.name] = this.optionsBuilder(prop); } + }); + } catch (err) { + this.error(`Unable to parse code "${code}"`); + this.error(err); + } + } - if (customHandler) { - customHandler(key, options); - continue; - } + if (customHandler) { + customHandler(key, options); + continue; + } - this.set(key, options); - } + this.set(key, options); + } + + return this; + } - return this; + // Parses translation keys from `Trans` components in JSX + // Default text + parseTransFromString(content, opts = {}, customHandler = null) { + if (_.isFunction(opts)) { + customHandler = opts; + opts = {}; } - // Parses translation keys from `Trans` components in JSX - // Default text - parseTransFromString(content, opts = {}, customHandler = null) { - if (_.isFunction(opts)) { - customHandler = opts; - opts = {}; + const { + transformOptions = {}, // object + component = this.options.trans.component, // string + i18nKey = this.options.trans.i18nKey, // string + defaultsKey = this.options.trans.defaultsKey, // string + fallbackKey, // boolean|function + acorn: acornOptions = this.options.trans.acorn, // object + } = { ...opts }; + + const parseJSXElement = (node, code) => { + if (!node) { + return; + } + + ensureArray(node.openingElement.attributes).forEach(attribute => { + const value = attribute.value; + + if (!(value && value.type === 'JSXExpressionContainer')) { + return; } - const { - transformOptions = {}, // object - component = this.options.trans.component, // string - i18nKey = this.options.trans.i18nKey, // string - defaultsKey = this.options.trans.defaultsKey, // string - fallbackKey, // boolean|function - acorn: acornOptions = this.options.trans.acorn, // object - } = { ...opts }; - - const parseJSXElement = (node, code) => { - if (!node) { - return; - } + const expression = value.expression; + if (!(expression && expression.type === 'JSXElement')) { + return; + } - ensureArray(node.openingElement.attributes).forEach(attribute => { - const value = attribute.value; + parseJSXElement(expression, code); + }); - if (!(value && value.type === 'JSXExpressionContainer')) { - return; - } + ensureArray(node.children).forEach(childNode => { + if (childNode.type === 'JSXElement') { + parseJSXElement(childNode, code); + } + }); - const expression = value.expression; - if (!(expression && expression.type === 'JSXElement')) { - return; - } + if (node.openingElement.name.name !== component) { + return; + } - parseJSXElement(expression, code); - }); + const attr = ensureArray(node.openingElement.attributes) + .reduce((acc, attribute) => { + if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier') { + return acc; + } - ensureArray(node.children).forEach(childNode => { - if (childNode.type === 'JSXElement') { - parseJSXElement(childNode, code); - } - }); + const { name } = attribute.name; - if (node.openingElement.name.name !== component) { - return; - } + if (attribute.value.type === 'Literal') { + acc[name] = attribute.value.value; + } else if (attribute.value.type === 'JSXExpressionContainer') { + const expression = attribute.value.expression; - const attr = ensureArray(node.openingElement.attributes) - .reduce((acc, attribute) => { - if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier') { - return acc; - } - - const { name } = attribute.name; - - if (attribute.value.type === 'Literal') { - acc[name] = attribute.value.value; - } else if (attribute.value.type === 'JSXExpressionContainer') { - const expression = attribute.value.expression; - - // Identifier - if (expression.type === 'Identifier') { - acc[name] = expression.name; - } - - // Literal - if (expression.type === 'Literal') { - acc[name] = expression.value; - } - - // Object Expression - if (expression.type === 'ObjectExpression') { - const properties = ensureArray(expression.properties); - acc[name] = properties.reduce((obj, property) => { - if (property.value.type === 'Literal') { - obj[property.key.name] = property.value.value; - } else if (property.value.type === 'TemplateLiteral') { - obj[property.key.name] = property.value.quasis - .map(element => element.value.cooked) - .join(''); - } else { - // Unable to get value of the property - obj[property.key.name] = ''; - } - - return obj; - }, {}); - } - - // Template Literal - if (expression.type === 'TemplateLiteral') { - acc[name] = expression.quasis - .map(element => element.value.cooked) - .join(''); - } - } - - return acc; - }, {}); - - const transKey = _.trim(attr[i18nKey]); - - const defaultsString = attr[defaultsKey] || ''; - if (typeof defaultsString !== 'string') { - this.log(`defaults value must be a static string, saw ${chalk.yellow(defaultsString)}`); + // Identifier + if (expression.type === 'Identifier') { + acc[name] = expression.name; } - // https://www.i18next.com/translation-function/essentials#overview-options - const tOptions = attr.tOptions; - const options = { - ...tOptions, - defaultValue: defaultsString || nodesToString(node.children, code), - fallbackKey: fallbackKey || this.options.trans.fallbackKey - }; - - if (Object.prototype.hasOwnProperty.call(attr, 'count')) { - options.count = Number(attr.count) || 0; + // Literal + if (expression.type === 'Literal') { + acc[name] = expression.value; } - if (Object.prototype.hasOwnProperty.call(attr, 'ns')) { - if (typeof attr.ns !== 'string') { - this.log(`The ns attribute must be a string, saw ${chalk.yellow(attr.ns)}`); + // Object Expression + if (expression.type === 'ObjectExpression') { + const properties = ensureArray(expression.properties); + acc[name] = properties.reduce((obj, property) => { + if (property.value.type === 'Literal') { + obj[property.key.name] = property.value.value; + } else if (property.value.type === 'TemplateLiteral') { + obj[property.key.name] = property.value.quasis + .map(element => element.value.cooked) + .join(''); + } else { + // Unable to get value of the property + obj[property.key.name] = ''; } - options.ns = attr.ns; + return obj; + }, {}); } - if (customHandler) { - customHandler(transKey, options); - return; + // Template Literal + if (expression.type === 'TemplateLiteral') { + acc[name] = expression.quasis + .map(element => element.value.cooked) + .join(''); } + } - this.set(transKey, options); - }; + return acc; + }, {}); - try { - const ast = acorn.Parser.extend(acornStage3, acornJsx()) - .parse(content, { - ...defaults.trans.acorn, - ...acornOptions - }); - - jsxwalk(ast, { - JSXElement: node => parseJSXElement(node, content) - }); - } catch (err) { - if (transformOptions.filepath) { - this.error(`Unable to parse ${chalk.blue(component)} component from ${chalk.yellow(JSON.stringify(transformOptions.filepath))}`); - console.error(' ' + err); - } else { - this.error(`Unable to parse ${chalk.blue(component)} component:`); - console.error(content); - console.error(' ' + err); - } - } + const transKey = _.trim(attr[i18nKey]); - return this; - } + const defaultsString = attr[defaultsKey] || ''; + if (typeof defaultsString !== 'string') { + this.log(`defaults value must be a static string, saw ${chalk.yellow(defaultsString)}`); + } - // Parses translation keys from `data-i18n` attribute in HTML - //
- //
- parseAttrFromString(content, opts = {}, customHandler = null) { - let setter = this.set.bind(this); - - if (_.isFunction(opts)) { - setter = opts; - opts = {}; - } else if (_.isFunction(customHandler)) { - setter = customHandler; - } + // https://www.i18next.com/translation-function/essentials#overview-options + const tOptions = attr.tOptions; + const options = { + ...tOptions, + defaultValue: defaultsString || nodesToString(node.children, code), + fallbackKey: fallbackKey || this.options.trans.fallbackKey + }; - const attrs = (opts.list !== undefined) - ? ensureArray(opts.list) - : ensureArray(this.options.attr.list); + if (Object.prototype.hasOwnProperty.call(attr, 'count')) { + options.count = Number(attr.count) || 0; + } - if (attrs.length === 0) { - return this; + if (Object.prototype.hasOwnProperty.call(attr, 'ns')) { + if (typeof attr.ns !== 'string') { + this.log(`The ns attribute must be a string, saw ${chalk.yellow(attr.ns)}`); } - const ast = parse5.parse(content); + options.ns = attr.ns; + } - 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); - } + if (customHandler) { + customHandler(transKey, options); + return; + } - setter(key); - }; - - 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); - } - }); - } - if (node.childNodes) { - walk(node.childNodes); - } - if (node.content && node.content.childNodes) { - walk(node.content.childNodes); - } - }); - }; + this.set(transKey, options); + }; - walk(ast.childNodes); + try { + const ast = acorn.Parser.extend(acornStage3, acornJsx()) + .parse(content, { + ...defaults.trans.acorn, + ...acornOptions + }); - return this; + jsxwalk(ast, { + JSXElement: node => parseJSXElement(node, content) + }); + } catch (err) { + if (transformOptions.filepath) { + this.error(`Unable to parse ${chalk.blue(component)} component from ${chalk.yellow(JSON.stringify(transformOptions.filepath))}`); + console.error(' ' + err); + } else { + this.error(`Unable to parse ${chalk.blue(component)} component:`); + console.error(content); + console.error(' ' + err); + } } - // Get the value of a translation key or the whole resource store containing translation information - // @param {string} [key] The translation key - // @param {object} [opts] The opts object - // @param {boolean} [opts.sort] True to sort object by key - // @param {boolean} [opts.lng] The language to use - // @return {object} - get(key, opts = {}) { - if (_.isPlainObject(key)) { - opts = key; - key = undefined; - } - - let resStore = {}; - if (this.options.removeUnusedKeys) { - // Merge two objects `resStore` and `resScan` deeply, returning a new merged object with the elements from both `resStore` and `resScan`. - const resMerged = deepMerge(this.resStore, this.resScan); - - Object.keys(this.resStore).forEach((lng) => { - Object.keys(this.resStore[lng]).forEach((ns) => { - const resStoreKeys = flattenObjectKeys(_.get(this.resStore, [lng, ns], {})); - const resScanKeys = flattenObjectKeys(_.get(this.resScan, [lng, ns], {})); - const unusedKeys = _.differenceWith(resStoreKeys, resScanKeys, _.isEqual); - - for (let i = 0; i < unusedKeys.length; ++i) { - _.unset(resMerged[lng][ns], unusedKeys[i]); - this.log(`Removed an unused translation key { ${chalk.red(JSON.stringify(unusedKeys[i]))} from ${chalk.red(JSON.stringify(this.formatResourceLoadPath(lng, ns)))}`); - } - - // Omit empty object - resMerged[lng][ns] = omitEmptyObject(resMerged[lng][ns]); - }); - }); - - resStore = resMerged; - } else { - resStore = cloneDeep(this.resStore); - } + return this; + } - if (opts.sort) { - Object.keys(resStore).forEach((lng) => { - const namespaces = resStore[lng]; - Object.keys(namespaces).forEach((ns) => { - // Deeply sort an object by its keys without mangling any arrays inside of it - resStore[lng][ns] = sortObject(namespaces[ns]); - }); - }); - } + // Parses translation keys from `data-i18n` attribute in HTML + //
+ //
+ parseAttrFromString(content, opts = {}, customHandler = null) { + let setter = this.set.bind(this); - if (!_.isUndefined(key)) { - let ns = this.options.defaultNs; + if (_.isFunction(opts)) { + setter = opts; + opts = {}; + } else if (_.isFunction(customHandler)) { + setter = customHandler; + } - // http://i18next.com/translate/keyBasedFallback/ - // Set nsSeparator and keySeparator to false if you prefer - // having keys as the fallback for translation. - // i18next.init({ - // nsSeparator: false, - // keySeparator: false - // }) + const attrs = (opts.list !== undefined) + ? ensureArray(opts.list) + : ensureArray(this.options.attr.list); - if (_.isString(this.options.nsSeparator) && (key.indexOf(this.options.nsSeparator) > -1)) { - const parts = key.split(this.options.nsSeparator); + if (attrs.length === 0) { + return this; + } - ns = parts[0]; - key = parts[1]; + const ast = parse5.parse(content); + + 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); + } + + setter(key); + }; + + 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); } + }); + } + if (node.childNodes) { + walk(node.childNodes); + } + if (node.content && node.content.childNodes) { + walk(node.content.childNodes); + } + }); + }; + + walk(ast.childNodes); + + return this; + } + + // Get the value of a translation key or the whole resource store containing translation information + // @param {string} [key] The translation key + // @param {object} [opts] The opts object + // @param {boolean} [opts.sort] True to sort object by key + // @param {boolean} [opts.lng] The language to use + // @return {object} + get(key, opts = {}) { + if (_.isPlainObject(key)) { + opts = key; + key = undefined; + } - const keys = _.isString(this.options.keySeparator) - ? key.split(this.options.keySeparator) - : [key]; - const lng = opts.lng - ? opts.lng - : this.options.fallbackLng; - const namespaces = resStore[lng] || {}; + let resStore = {}; + if (this.options.removeUnusedKeys) { + // Merge two objects `resStore` and `resScan` deeply, returning a new merged object with the elements from both `resStore` and `resScan`. + const resMerged = deepMerge(this.resStore, this.resScan); - let value = namespaces[ns]; - let x = 0; + Object.keys(this.resStore).forEach((lng) => { + Object.keys(this.resStore[lng]).forEach((ns) => { + const resStoreKeys = flattenObjectKeys(_.get(this.resStore, [lng, ns], {})); + const resScanKeys = flattenObjectKeys(_.get(this.resScan, [lng, ns], {})); + const unusedKeys = _.differenceWith(resStoreKeys, resScanKeys, _.isEqual); - while (keys[x]) { - value = value && value[keys[x]]; - x++; - } + for (let i = 0; i < unusedKeys.length; ++i) { + _.unset(resMerged[lng][ns], unusedKeys[i]); + this.log(`Removed an unused translation key { ${chalk.red(JSON.stringify(unusedKeys[i]))} from ${chalk.red(JSON.stringify(this.formatResourceLoadPath(lng, ns)))}`); + } - return value; - } + // Omit empty object + resMerged[lng][ns] = omitEmptyObject(resMerged[lng][ns]); + }); + }); - return resStore; + resStore = resMerged; + } else { + resStore = cloneDeep(this.resStore); } - // Set translation key with an optional defaultValue to i18n resource store - // @param {string} key The translation key - // @param {object} [options] The options object - // @param {boolean|function} [options.fallbackKey] When the key is missing, pass `true` to return `options.defaultValue` as key, or pass a function to return user-defined key. - // @param {string} [options.defaultValue] defaultValue to return if translation not found - // @param {number} [options.count] count value used for plurals - // @param {string} [options.context] used for contexts (eg. male) - // @param {string} [options.ns] namespace for the translation - // @param {string|boolean} [options.nsSeparator] The value used to override this.options.nsSeparator - // @param {string|boolean} [options.keySeparator] The value used to override this.options.keySeparator - set(key, options = {}) { - // Backward compatibility - if (_.isString(options)) { - const defaultValue = options; - options = { - defaultValue: defaultValue - }; - } + if (opts.sort) { + Object.keys(resStore).forEach((lng) => { + const namespaces = resStore[lng]; + Object.keys(namespaces).forEach((ns) => { + // Deeply sort an object by its keys without mangling any arrays inside of it + resStore[lng][ns] = sortObject(namespaces[ns]); + }); + }); + } - const nsSeparator = (options.nsSeparator !== undefined) - ? options.nsSeparator - : this.options.nsSeparator; - const keySeparator = (options.keySeparator !== undefined) - ? options.keySeparator - : this.options.keySeparator; + if (!_.isUndefined(key)) { + let ns = this.options.defaultNs; + + // http://i18next.com/translate/keyBasedFallback/ + // Set nsSeparator and keySeparator to false if you prefer + // having keys as the fallback for translation. + // i18next.init({ + // nsSeparator: false, + // keySeparator: false + // }) + + if (_.isString(this.options.nsSeparator) && (key.indexOf(this.options.nsSeparator) > -1)) { + const parts = key.split(this.options.nsSeparator); + + ns = parts[0]; + key = parts[1]; + } + + const keys = _.isString(this.options.keySeparator) + ? key.split(this.options.keySeparator) + : [key]; + const lng = opts.lng + ? opts.lng + : this.options.fallbackLng; + const namespaces = resStore[lng] || {}; + + let value = namespaces[ns]; + let x = 0; + + while (keys[x]) { + value = value && value[keys[x]]; + x++; + } + + return value; + } - let ns = options.ns || this.options.defaultNs; + return resStore; + } + + // Set translation key with an optional defaultValue to i18n resource store + // @param {string} key The translation key + // @param {object} [options] The options object + // @param {boolean|function} [options.fallbackKey] When the key is missing, pass `true` to return `options.defaultValue` as key, or pass a function to return user-defined key. + // @param {string} [options.defaultValue] defaultValue to return if translation not found + // @param {number} [options.count] count value used for plurals + // @param {string} [options.context] used for contexts (eg. male) + // @param {string} [options.ns] namespace for the translation + // @param {string|boolean} [options.nsSeparator] The value used to override this.options.nsSeparator + // @param {string|boolean} [options.keySeparator] The value used to override this.options.keySeparator + set(key, options = {}) { + // Backward compatibility + if (_.isString(options)) { + const defaultValue = options; + options = { + defaultValue: defaultValue + }; + } - console.assert(_.isString(ns) && !!ns.length, 'ns is not a valid string', ns); + const nsSeparator = (options.nsSeparator !== undefined) + ? options.nsSeparator + : this.options.nsSeparator; + const keySeparator = (options.keySeparator !== undefined) + ? options.keySeparator + : this.options.keySeparator; - // http://i18next.com/translate/keyBasedFallback/ - // Set nsSeparator and keySeparator to false if you prefer - // having keys as the fallback for translation. - // i18next.init({ - // nsSeparator: false, - // keySeparator: false - // }) + let ns = options.ns || this.options.defaultNs; - if (_.isString(nsSeparator) && (key.indexOf(nsSeparator) > -1)) { - const parts = key.split(nsSeparator); + console.assert(_.isString(ns) && !!ns.length, 'ns is not a valid string', ns); - ns = parts[0]; - key = parts[1]; - } + // http://i18next.com/translate/keyBasedFallback/ + // Set nsSeparator and keySeparator to false if you prefer + // having keys as the fallback for translation. + // i18next.init({ + // nsSeparator: false, + // keySeparator: false + // }) - let keys = []; + if (_.isString(nsSeparator) && (key.indexOf(nsSeparator) > -1)) { + const parts = key.split(nsSeparator); - if (key) { - keys = _.isString(keySeparator) ? key.split(keySeparator) : [key]; - } else { - // fallback key - if (options.fallbackKey === true) { - key = options.defaultValue; - } - if (typeof options.fallbackKey === 'function') { - key = options.fallbackKey(ns, options.defaultValue); - } + ns = parts[0]; + key = parts[1]; + } - if (!key) { - // Ignore empty key - return; - } + let keys = []; + + if (key) { + keys = _.isString(keySeparator) ? key.split(keySeparator) : [key]; + } else { + // fallback key + if (options.fallbackKey === true) { + key = options.defaultValue; + } + if (typeof options.fallbackKey === 'function') { + key = options.fallbackKey(ns, options.defaultValue); + } + + if (!key) { + // Ignore empty key + return; + } + + keys = [key]; + } - keys = [key]; + const { + lngs, + context, + contextFallback, + contextSeparator, + contextDefaultValues, + plural, + pluralFallback, + pluralSeparator, + defaultLng, + defaultValue + } = this.options; + + lngs.forEach((lng) => { + let resLoad = this.resStore[lng] && this.resStore[lng][ns]; + let resScan = this.resScan[lng] && this.resScan[lng][ns]; + + if (!_.isPlainObject(resLoad)) { // Skip undefined namespace + this.error(`${chalk.yellow(JSON.stringify(ns))} does not exist in the namespaces (${chalk.yellow(JSON.stringify(this.options.ns))}): key=${chalk.yellow(JSON.stringify(key))}, options=${chalk.yellow(JSON.stringify(options))}`); + return; + } + + Object.keys(keys).forEach((index) => { + const key = keys[index]; + + if (index < (keys.length - 1)) { + resLoad[key] = resLoad[key] || {}; + resLoad = resLoad[key]; + resScan[key] = resScan[key] || {}; + resScan = resScan[key]; + return; // continue } - const { - lngs, - context, - contextFallback, - contextSeparator, - contextDefaultValues, - plural, - pluralFallback, - pluralSeparator, - defaultLng, - defaultValue - } = this.options; - - lngs.forEach((lng) => { - let resLoad = this.resStore[lng] && this.resStore[lng][ns]; - let resScan = this.resScan[lng] && this.resScan[lng][ns]; - - if (!_.isPlainObject(resLoad)) { // Skip undefined namespace - this.error(`${chalk.yellow(JSON.stringify(ns))} does not exist in the namespaces (${chalk.yellow(JSON.stringify(this.options.ns))}): key=${chalk.yellow(JSON.stringify(key))}, options=${chalk.yellow(JSON.stringify(options))}`); - return; - } - - Object.keys(keys).forEach((index) => { - const key = keys[index]; - - if (index < (keys.length - 1)) { - resLoad[key] = resLoad[key] || {}; - resLoad = resLoad[key]; - resScan[key] = resScan[key] || {}; - resScan = resScan[key]; - return; // continue - } - - // Context & Plural - // http://i18next.com/translate/context/ - // http://i18next.com/translate/pluralSimple/ - // - // Format: - // "[[{{contextSeparator}}]{{pluralSeparator}}]" - // - // Example: - // { - // "translation": { - // "friend": "A friend", - // "friend_male": "A boyfriend", - // "friend_female": "A girlfriend", - // "friend_male_plural": "{{count}} boyfriends", - // "friend_female_plural": "{{count}} girlfriends" - // } - // } - const resKeys = []; - - // http://i18next.com/translate/context/ - const containsContext = (() => { - if (!context) { - return false; - } - if (_.isUndefined(options.context)) { - return false; - } - return _.isFunction(context) - ? context(lng, ns, key, options) - : !!context; - })(); - - // http://i18next.com/translate/pluralSimple/ - const containsPlural = (() => { - if (!plural) { - return false; - } - if (_.isUndefined(options.count)) { - return false; - } - return _.isFunction(plural) - ? plural(lng, ns, key, options) - : !!plural; - })(); - - const contextValues = (() => { - if (options.context !== '') { - return [options.context]; - } - if (ensureArray(contextDefaultValues).length > 0) { - return ensureArray(contextDefaultValues); - } - return []; - })(); - - if (containsPlural) { - let suffixes = pluralFallback - ? this.pluralSuffixes[lng] - : this.pluralSuffixes[lng].slice(1); - - suffixes.forEach((pluralSuffix) => { - resKeys.push(`${key}${pluralSuffix}`); - }); - - if (containsContext && containsPlural) { - suffixes.forEach((pluralSuffix) => { - contextValues.forEach(contextValue => { - resKeys.push(`${key}${contextSeparator}${contextValue}${pluralSuffix}`); - }); - }); - } - } else { - if (!containsContext || (containsContext && contextFallback)) { - resKeys.push(key); - } - - if (containsContext) { - contextValues.forEach(contextValue => { - resKeys.push(`${key}${contextSeparator}${contextValue}`); - }); - } - } + // Context & Plural + // http://i18next.com/translate/context/ + // http://i18next.com/translate/pluralSimple/ + // + // Format: + // "[[{{contextSeparator}}]{{pluralSeparator}}]" + // + // Example: + // { + // "translation": { + // "friend": "A friend", + // "friend_male": "A boyfriend", + // "friend_female": "A girlfriend", + // "friend_male_plural": "{{count}} boyfriends", + // "friend_female_plural": "{{count}} girlfriends" + // } + // } + const resKeys = []; + + // http://i18next.com/translate/context/ + const containsContext = (() => { + if (!context) { + return false; + } + if (_.isUndefined(options.context)) { + return false; + } + return _.isFunction(context) + ? context(lng, ns, key, options) + : !!context; + })(); + + // http://i18next.com/translate/pluralSimple/ + const containsPlural = (() => { + if (!plural) { + return false; + } + if (_.isUndefined(options.count)) { + return false; + } + return _.isFunction(plural) + ? plural(lng, ns, key, options) + : !!plural; + })(); + + const contextValues = (() => { + if (options.context !== '') { + return [options.context]; + } + if (ensureArray(contextDefaultValues).length > 0) { + return ensureArray(contextDefaultValues); + } + return []; + })(); + + if (containsPlural) { + let suffixes = pluralFallback + ? this.pluralSuffixes[lng] + : this.pluralSuffixes[lng].slice(1); + + suffixes.forEach((pluralSuffix) => { + resKeys.push(`${key}${pluralSuffix}`); + }); + + if (containsContext && containsPlural) { + suffixes.forEach((pluralSuffix) => { + contextValues.forEach(contextValue => { + resKeys.push(`${key}${contextSeparator}${contextValue}${pluralSuffix}`); + }); + }); + } + } else { + if (!containsContext || (containsContext && contextFallback)) { + resKeys.push(key); + } - resKeys.forEach((resKey) => { - if (resLoad[resKey] === undefined) { - if (options.defaultValue_plural !== undefined && resKey.endsWith(`${pluralSeparator}plural`)) { - resLoad[resKey] = options.defaultValue_plural; - } else { - // Fallback to `defaultValue` - resLoad[resKey] = _.isFunction(defaultValue) - ? defaultValue(lng, ns, key, options) - : (options.defaultValue || defaultValue); - } - this.log(`Added a new translation key { ${chalk.yellow(JSON.stringify(resKey))}: ${chalk.yellow(JSON.stringify(resLoad[resKey]))} } to ${chalk.yellow(JSON.stringify(this.formatResourceLoadPath(lng, ns)))}`); - } else if (options.defaultValue && (!options.defaultValue_plural || !resKey.endsWith(`${pluralSeparator}plural`))) { - if (!resLoad[resKey]) { - // Use `options.defaultValue` if specified - resLoad[resKey] = options.defaultValue; - } else if ((resLoad[resKey] !== options.defaultValue) && (lng === defaultLng)) { - // A default value has provided but it's different with the expected default - this.log(`The translation key ${chalk.yellow(JSON.stringify(resKey))} has a different default value, you may need to check the translation key of default language (${defaultLng})`); - } - } else if (options.defaultValue_plural && resKey.endsWith(`${pluralSeparator}plural`)) { - if (!resLoad[resKey]) { - // Use `options.defaultValue_plural` if specified - resLoad[resKey] = options.defaultValue_plural; - } else if ((resLoad[resKey] !== options.defaultValue_plural) && (lng === defaultLng)) { - // A default value has provided but it's different with the expected default - this.log(`The translation key ${chalk.yellow(JSON.stringify(resKey))} has a different default value, you may need to check the translation key of default language (${defaultLng})`); - } - } - - resScan[resKey] = resLoad[resKey]; - }); + if (containsContext) { + contextValues.forEach(contextValue => { + resKeys.push(`${key}${contextSeparator}${contextValue}`); }); - }); - } + } + } - // Returns a JSON string containing translation information - // @param {object} [options] The options object - // @param {boolean} [options.sort] True to sort object by key - // @param {function|string[]|number[]} [options.replacer] The same as the JSON.stringify() - // @param {string|number} [options.space] The same as the JSON.stringify() method - // @return {string} - toJSON(options = {}) { - const { replacer, space, ...others } = options; + resKeys.forEach((resKey) => { + if (resLoad[resKey] === undefined) { + if (options.defaultValue_plural !== undefined && resKey.endsWith(`${pluralSeparator}plural`)) { + resLoad[resKey] = options.defaultValue_plural; + } else { + // Fallback to `defaultValue` + resLoad[resKey] = _.isFunction(defaultValue) + ? defaultValue(lng, ns, key, options) + : (options.defaultValue || defaultValue); + } + this.log(`Added a new translation key { ${chalk.yellow(JSON.stringify(resKey))}: ${chalk.yellow(JSON.stringify(resLoad[resKey]))} } to ${chalk.yellow(JSON.stringify(this.formatResourceLoadPath(lng, ns)))}`); + } else if (options.defaultValue && (!options.defaultValue_plural || !resKey.endsWith(`${pluralSeparator}plural`))) { + if (!resLoad[resKey]) { + // Use `options.defaultValue` if specified + resLoad[resKey] = options.defaultValue; + } else if ((resLoad[resKey] !== options.defaultValue) && (lng === defaultLng)) { + // A default value has provided but it's different with the expected default + this.log(`The translation key ${chalk.yellow(JSON.stringify(resKey))} has a different default value, you may need to check the translation key of default language (${defaultLng})`); + } + } else if (options.defaultValue_plural && resKey.endsWith(`${pluralSeparator}plural`)) { + if (!resLoad[resKey]) { + // Use `options.defaultValue_plural` if specified + resLoad[resKey] = options.defaultValue_plural; + } else if ((resLoad[resKey] !== options.defaultValue_plural) && (lng === defaultLng)) { + // A default value has provided but it's different with the expected default + this.log(`The translation key ${chalk.yellow(JSON.stringify(resKey))} has a different default value, you may need to check the translation key of default language (${defaultLng})`); + } + } - return JSON.stringify(this.get(others), replacer, space); - } + resScan[resKey] = resLoad[resKey]; + }); + }); + }); + } + + // Returns a JSON string containing translation information + // @param {object} [options] The options object + // @param {boolean} [options.sort] True to sort object by key + // @param {function|string[]|number[]} [options.replacer] The same as the JSON.stringify() + // @param {string|number} [options.space] The same as the JSON.stringify() method + // @return {string} + toJSON(options = {}) { + const { replacer, space, ...others } = options; + + return JSON.stringify(this.get(others), replacer, space); + } } export default Parser; diff --git a/test/fixtures/trans.render.js b/test/fixtures/trans.render.js new file mode 100644 index 0000000..e69de29 diff --git a/test/jsx-parser.js b/test/jsx-parser.js deleted file mode 100644 index 82166f4..0000000 --- a/test/jsx-parser.js +++ /dev/null @@ -1,36 +0,0 @@ -import { test } from 'tap'; -import { Parser } from 'acorn'; -import jsx from 'acorn-jsx'; -import ensureArray from 'ensure-array'; -import _get from 'lodash/get'; -import nodesToString from '../src/nodes-to-string'; - -const jsxToString = (code) => { - try { - const ast = Parser.extend(jsx()).parse(`${code}`, { ecmaVersion: 2020 }); - - const nodes = ensureArray(_get(ast, 'body[0].expression.children')); - if (nodes.length === 0) { - return ''; - } - - return nodesToString(nodes, code); - } catch (e) { - console.error(e); - return ''; - } -}; - -test('JSX to i18next', (t) => { - t.same(jsxToString('Basic text'), 'Basic text'); - t.same(jsxToString('Hello, {{name}}'), 'Hello, <1>{{name}}'); - t.same(jsxToString('I agree to the terms.'), 'I agree to the <1>terms.'); - t.same(jsxToString('One & two'), 'One & two'); - t.end(); -}); - -test('HTML entities', (t) => { - t.same(jsxToString('Don't do this Dave'), 'Don\'t do this <1>Dave'); - t.end(); -}); - diff --git a/test/jsx-parser.test.js b/test/jsx-parser.test.js new file mode 100644 index 0000000..f7d98a4 --- /dev/null +++ b/test/jsx-parser.test.js @@ -0,0 +1,32 @@ +import { Parser } from 'acorn'; +import jsx from 'acorn-jsx'; +import ensureArray from 'ensure-array'; +import _get from 'lodash/get'; +import nodesToString from '../src/nodes-to-string'; + +const jsxToString = (code) => { + try { + const ast = Parser.extend(jsx()).parse(`${code}`, { ecmaVersion: 2020 }); + + const nodes = ensureArray(_get(ast, 'body[0].expression.children')); + if (nodes.length === 0) { + return ''; + } + + return nodesToString(nodes, code); + } catch (e) { + console.error(e); + return ''; + } +}; + +test('JSX to i18next', () => { + expect(jsxToString('Basic text')).toBe('Basic text'); + expect(jsxToString('Hello, {{name}}')).toBe('Hello, <1>{{name}}'); + expect(jsxToString('I agree to the terms.')).toBe('I agree to the <1>terms.'); + expect(jsxToString('One & two')).toBe('One & two'); +}); + +test('HTML entities', () => { + expect(jsxToString('Don't do this Dave')).toBe('Don\'t do this <1>Dave'); +}); diff --git a/test/parser.js b/test/parser.js deleted file mode 100644 index da30205..0000000 --- a/test/parser.js +++ /dev/null @@ -1,1258 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import sha1 from 'sha1'; -import { test } from 'tap'; -import { Parser } from '../src'; - -test('set merges defaults', (t) => { - const parser = new Parser({ - ns: ['translation'] - }); - parser.set('key1', { defaultValue: 'Default text' }); - parser.set('key1'); - t.same(parser.get('key1'), 'Default text'); - - parser.set('key2'); - parser.set('key2', { defaultValue: 'Default text' }); - t.same(parser.get('key2'), 'Default text'); - t.end(); -}); - -test('set merges defaults (plural case)', (t) => { - const parser = new Parser({ - ns: ['translation'] - }); - parser.set('key1', { defaultValue: 'Default text', defaultValue_plural: 'Default plural text', count: 2 }); - parser.set('key1'); - parser.set('key1_plural'); - t.same(parser.get('key1'), 'Default text'); - t.same(parser.get('key1_plural'), 'Default plural text'); - - parser.set('key2'); - parser.set('key2_plural'); - parser.set('key2', { defaultValue: 'Default text', defaultValue_plural: 'Default plural text', count: 2 }); - t.same(parser.get('key2'), 'Default text'); - t.same(parser.get('key2_plural'), 'Default plural text'); - t.end(); -}); - -test('set merges defaults (plural case without default plural value)', (t) => { - const parser = new Parser({ - ns: ['translation'] - }); - parser.set('key2', { count: 2 }); - t.same(parser.get('key2_plural'), ''); - parser.set('key2', { defaultValue: 'Default text', count: 2 }); - t.same(parser.get('key2_plural'), 'Default text'); - t.end(); -}); - -test('set warns about conflicting defaults', (t) => { - const parser = new Parser({ - ns: ['translation'] - }); - let logText; - parser.log = (msg) => { - logText = msg; - }; - parser.set('key', { defaultValue: 'Default text' }); - parser.set('key', { defaultValue: 'Another text' }); - t.same(parser.get('key'), 'Default text'); - t.match(logText, /different default value/); - t.end(); -}); - -test('set warns about conflicting defaults (plural case)', (t) => { - const parser = new Parser({ - ns: ['translation'] - }); - let logText; - parser.log = (msg) => { - logText = msg; - }; - parser.set('key', { defaultValue: 'Default text', defaultValue_plural: 'Default plural text', count: 2 }); - parser.set('key', { defaultValue: 'Default text', defaultValue_plural: 'Another plural text', count: 2 }); - t.same(parser.get('key'), 'Default text'); - t.same(parser.get('key_plural'), 'Default plural text'); - t.match(logText, /different default value/); - t.end(); -}); - -test('Skip undefined namespace', (t) => { - const parser = new Parser({ - ns: ['translation'] - }); - const content = ` - i18next.t('none:key2'); // "none" does not exist in the namespaces - i18next.t('key1'); - `; - const wanted = { - en: { - translation: { - key1: '' - } - } - }; - - parser.parseFuncFromString(content); - t.same(parser.get(), wanted); - t.end(); -}); - -test('Parse translation function', (t) => { - const parser = new Parser({ - lngs: ['en'], - fallbackLng: 'en' - }); - const customHandler = function(key) { - const defaultValue = '__TRANSLATION__'; // optional default value - parser.set(key, defaultValue); - }; - - // i18next.t('key'); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); - parser - .parseFuncFromString(content, customHandler) // pass a custom handler - .parseFuncFromString(content, { list: ['i18next.t'] }) // override `func.list` - .parseFuncFromString(content, { list: ['i18next.t'] }, customHandler) - .parseFuncFromString(content); // using default options and handler - - t.same(parser.get(), { - en: { - translation: { - 'key2': '__TRANSLATION__', - 'key1': '__TRANSLATION__' - } - } - }); - - // Sort keys in alphabetical order - t.same(JSON.stringify(parser.get({ sort: true })), JSON.stringify({ - en: { - translation: { - 'key1': '__TRANSLATION__', - 'key2': '__TRANSLATION__' - } - } - })); - - t.equal(parser.get('key1', { lng: 'en' }), '__TRANSLATION__'); - t.equal(parser.get('key1', { lng: 'de' }), undefined); - t.equal(parser.get('nokey', { lng: 'en' }), undefined); - - t.end(); -}); - -test('Parse Trans components', (t) => { - const parser = new Parser({ - lngs: ['en'], - ns: [ - 'dev', - 'translation' - ], - trans: { - fallbackKey: true - }, - nsSeparator: false, - keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated - fallbackLng: 'en' - }); - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans.jsx'), 'utf-8'); - parser.parseTransFromString(content); - t.same(parser.get(), { - en: { - dev: { - 'Hello <1>World, you have <3>{{count}} unread message.': 'Hello <1>World, you have <3>{{count}} unread message.', - 'Hello <1>World, you have <3>{{count}} unread message._plural': 'Hello <1>World, you have <3>{{count}} unread message.' - }, - translation: { - // quote style - 'jsx-quotes-double': 'Use double quotes for the i18nKey attribute', - 'jsx-quotes-single': 'Use single quote for the i18nKey attribute', - - // plural - 'plural': 'You have <1>{{count}} apples', - 'plural_plural': 'You have <1>{{count}} apples', - - // context - 'context': 'A boyfriend', - 'context_male': 'A boyfriend', - - // i18nKey - 'multiline-text-string': 'multiline text string', - 'string-literal': 'This is a <1>test', - 'object-expression': 'This is a <1><0>{{test}}', - 'arithmetic-expression': '2 + 2 = <1>{{result}}', - 'components': 'Go to <1>Administration > Tools to download administrative tools.', - 'lorem-ipsum': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - 'lorem-ipsum-nested': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - - // fallback key - 'Hello, World!': 'Hello, World!', - 'multiline text string': 'multiline text string', - 'This is a <1>test': 'This is a <1>test', - 'This is a <1><0>{{test}}': 'This is a <1><0>{{test}}', - '2 + 2 = <1>{{result}}': '2 + 2 = <1>{{result}}', - 'Go to <1>Administration > Tools to download administrative tools.': 'Go to <1>Administration > Tools to download administrative tools.', - '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - - // defaults - 'The component might be self-closing': 'The component might be self-closing', - 'Some <0>{variable}': 'Some <0>{variable}', - 'Hello <1>{{planet}}!': 'Hello <1>{{planet}}!', - - // props - 'translation from props': 'translation from props', - 'translation from nested props': 'translation from nested props', - 'translation from deeply nested props': 'translation from deeply nested props', - 'tooltip1': 'Some tooltip text', - 'tooltip2': 'Some tooltip text' - } - } - }); - t.end(); -}); - -test('Parse Trans components with fallback key', (t) => { - const parser = new Parser({ - lngs: ['en'], - ns: [ - 'dev', - 'translation' - ], - trans: { - fallbackKey: (ns, value) => { - return sha1(value); // return a sha1 as the key - } - }, - nsSeparator: false, - keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated - fallbackLng: 'en' - }); - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans.jsx'), 'utf-8'); - parser.parseTransFromString(content); - t.same(parser.get(), { - en: { - dev: { - '2290678f8f33c49494499fe5e32b4ebd124d9292': 'Hello <1>World, you have <3>{{count}} unread message.', - '2290678f8f33c49494499fe5e32b4ebd124d9292_plural': 'Hello <1>World, you have <3>{{count}} unread message.' - }, - translation: { - // quote style - 'jsx-quotes-double': 'Use double quotes for the i18nKey attribute', - 'jsx-quotes-single': 'Use single quote for the i18nKey attribute', - - // plural - 'plural': 'You have <1>{{count}} apples', - 'plural_plural': 'You have <1>{{count}} apples', - - // context - 'context': 'A boyfriend', - 'context_male': 'A boyfriend', - - // i18nKey - 'multiline-text-string': 'multiline text string', - 'string-literal': 'This is a <1>test', - 'object-expression': 'This is a <1><0>{{test}}', - 'arithmetic-expression': '2 + 2 = <1>{{result}}', - 'components': 'Go to <1>Administration > Tools to download administrative tools.', - 'lorem-ipsum': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - 'lorem-ipsum-nested': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - - // fallback key - '0a0a9f2a6772942557ab5355d76af442f8f65e01': 'Hello, World!', - '32876cbad378f3153c900c297ed2efa06243e0e2': 'multiline text string', - 'e4ca61dff6bc759d214e32c4e37c8ae594ca163d': 'This is a <1>test', - '0ce90193dd25c93cdc12f25a36d31004a74c63de': 'This is a <1><0>{{test}}', - '493781e20cd3cfd5b3137963519571c3d97ab383': '2 + 2 = <1>{{result}}', - '083eac6b4f73ec317824caaaeea57fba3b83c1d9': 'Go to <1>Administration > Tools to download administrative tools.', - '938c04be9e14562b7532a19458fe92b65c6ef941': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - '9c3ca5d5d8089e96135c8c7c9f42ba34a635fb47': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', - - // defaults - '7551746c2d33a1d0a24658c22821c8700fa58a0d': 'Hello <1>{{planet}}!', - '253344d83465052dd6573c8c0abcd76f02fc3a97': 'Some <0>{variable}', - '7e514af8f77b74e74f86dc22a2cb173680462e34': 'The component might be self-closing', - - // props - 'c38f91deba88fc3bb582cc73dc658210324b01ec': 'translation from props', - '5bf216b4068991e3a2f5e55ae36c03add490a63f': 'translation from nested props', - '6fadff01c49d0ebe862a3aa33688735c03728197': 'translation from deeply nested props', - 'tooltip1': 'Some tooltip text', - 'tooltip2': 'Some tooltip text' - } - } - }); - t.end(); -}); - -test('Parse wrapped Trans components', (t) => { - const parser = new Parser({ - lngs: ['en'], - ns: [ - 'dev', - 'translation' - ], - trans: { - component: 'I18n', - i18nKey: '__t', - fallbackKey: true - }, - nsSeparator: false, - keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated - fallbackLng: 'en', - }); - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans.jsx'), 'utf-8'); - parser.parseTransFromString(content); - t.same(parser.get(), { - en: { - dev: {}, - translation: { - 'mykey': 'A wrapper component with key', - 'A wrapper component without key': 'A wrapper component without key' - } - } - }); - t.end(); -}); - -test('Parse Trans components with modern acorn features', (t) => { - const parser = new Parser({ - lngs: ['en'], - trans: { - fallbackKey: true - }, - nsSeparator: false, - keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated - fallbackLng: 'en' - }); - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans-acorn.jsx'), 'utf-8'); - parser.parseTransFromString(content); - t.same(parser.get(), { - en: { - translation: { - // Passing keys to via object spread is not yet supported: - 'Spread i18nKey': 'Spread i18nKey', - // 'spread': 'Spread i18nKey', // this would be expected. - 'simple': 'Simple i18nKey' - } - } - }); - t.end(); -}); - -test('Parse HTML attribute', (t) => { - test('parseAttrFromString(content)', (t) => { - const parser = new Parser({ - lngs: ['en'], - fallbackLng: 'en' - }); - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); - parser.parseAttrFromString(content); - - t.same(parser.get(), { - en: { - translation: { - 'key1': '', - 'key2': '', - 'key3': '', - 'key4': '' - } - } - }); - - t.end(); - }); - - test('parseAttrFromString(content, { list: ["data-i18n"] })', (t) => { - const parser = new Parser({ - lngs: ['en'], - fallbackLng: 'en' - }); - - //
- const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); - parser.parseAttrFromString(content, { list: ['data-i18n'] }); - - t.same(parser.get(), { - en: { - translation: { - 'key1': '', - 'key2': '', - 'key3': '', - 'key4': '' - } - } - }); - - t.end(); - }); - - test('parseAttrFromString(content, customHandler)', (t) => { - const parser = new Parser({ - lngs: ['en'], - fallbackLng: 'en' - }); - const customHandler = function(key) { - const defaultValue = '__TRANSLATION__'; // optional default value - parser.set(key, defaultValue); - }; - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); - parser.parseAttrFromString(content, customHandler); - - t.same(parser.get(), { - en: { - translation: { - 'key4': '__TRANSLATION__', - 'key3': '__TRANSLATION__', - 'key2': '__TRANSLATION__', - 'key1': '__TRANSLATION__' - } - } - }); - - // Sort keys in alphabetical order - t.same(JSON.stringify(parser.get({ sort: true })), JSON.stringify({ - en: { - translation: { - 'key1': '__TRANSLATION__', - 'key2': '__TRANSLATION__', - 'key3': '__TRANSLATION__', - 'key4': '__TRANSLATION__' - } - } - })); - - t.equal(parser.get('key1', { lng: 'en' }), '__TRANSLATION__'); - t.equal(parser.get('key1', { lng: 'de' }), undefined); - t.equal(parser.get('nokey', { lng: 'en' }), undefined); - - t.end(); - }); - - test('parseAttrFromString(content, { list: ["data-i18n"] }, customHandler)', (t) => { - const parser = new Parser({ - lngs: ['en'], - fallbackLng: 'en' - }); - const customHandler = function(key) { - const defaultValue = '__TRANSLATION__'; // optional default value - parser.set(key, defaultValue); - }; - - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); - parser.parseAttrFromString(content, { list: ['data-i18n'] }, customHandler); - - t.same(parser.get(), { - en: { - translation: { - 'key4': '__TRANSLATION__', - 'key3': '__TRANSLATION__', - 'key2': '__TRANSLATION__', - 'key1': '__TRANSLATION__' - } - } - }); - - // Sort keys in alphabetical order - t.same(JSON.stringify(parser.get({ sort: true })), JSON.stringify({ - en: { - translation: { - 'key1': '__TRANSLATION__', - 'key2': '__TRANSLATION__', - 'key3': '__TRANSLATION__', - 'key4': '__TRANSLATION__' - } - } - })); - - t.equal(parser.get('key1', { lng: 'en' }), '__TRANSLATION__'); - t.equal(parser.get('key1', { lng: 'de' }), undefined); - t.equal(parser.get('nokey', { lng: 'en' }), undefined); - - t.end(); - }); - - t.end(); -}); - -test('Gettext style i18n', (t) => { - const parser = new Parser({ - defaultValue: (lng, ns, key) => { - return key; - }, - keySeparator: false, - nsSeparator: false - }); - - // Parse Translation Function - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/modules/index.js'), 'utf8'); - - parser.parseFuncFromString(content, { list: ['_t'] }); - - const resStore = parser.get(); - t.same(resStore, { - en: { - translation: { - 'Loading...': 'Loading...', - 'This value does not exist.': 'This value does not exist.', - 'YouTube has more than {{count}} billion users.': 'YouTube has more than {{count}} billion users.', - 'YouTube has more than {{count}} billion users._plural': 'YouTube has more than {{count}} billion users.', - 'You have {{count}} messages.': 'You have {{count}} messages.', - 'You have {{count}} messages._plural': 'You have {{count}} messages.' - } - } - }); - t.end(); -}); - -test('Quotes', (t) => { - const parser = new Parser({ - defaultValue: function(lng, ns, key) { - if (lng === 'en') { - return key; - } - return '__NOT_TRANSLATED__'; - }, - keySeparator: false, - nsSeparator: false - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/quotes.js'), 'utf8'); - const wanted = { - 'en': { - 'translation': { - 'Primary \'email\' activation': 'Primary \'email\' activation', - 'Primary "email" activation': 'Primary "email" activation', - 'name=\'email\' value=\'{{email}}\'': 'name=\'email\' value=\'{{email}}\'', - 'name="email" value="{{email}}"': 'name="email" value="{{email}}"', - 'name="email" value=\'{{email}}\'': 'name="email" value=\'{{email}}\'', - 'name=\'email\' value="{{email}}"': 'name=\'email\' value="{{email}}"', - } - } - }; - - parser.parseFuncFromString(content); - t.same(parser.get(), wanted); - - t.end(); -}); - -test('Disable nsSeparator', (t) => { - const parser = new Parser({ - defaultValue: '__NOT_TRANSLATED__', - nsSeparator: false, - keySeparator: '.' - }); - parser.set('foo:bar'); - - const resStore = parser.get(); - - t.same(resStore, { - en: { - translation: { - 'foo:bar': '__NOT_TRANSLATED__' - } - } - }, 'The key should not use default nsSeparator : to split'); - t.end(); -}); - -test('Disable keySeparator', (t) => { - const parser = new Parser({ - defaultValue: '__NOT_TRANSLATED__', - nsSeparator: ':', - keySeparator: false - }); - parser.set('Creating...'); - - const resStore = parser.get(); - - t.same(resStore, { - en: { - translation: { - 'Creating...': '__NOT_TRANSLATED__' - } - } - }, 'The key should not use default keySeparator . to split'); - t.end(); -}); - -test('Default nsSeparator', (t) => { - const parser = new Parser({ - defaultValue: '__NOT_TRANSLATED__', - nsSeparator: ':', - keySeparator: '.' - }); - parser.set('translation:key1.key2'); - - const resStore = parser.get(); - - t.same(resStore, { - en: { - translation: { - 'key1': { - 'key2': '__NOT_TRANSLATED__' - } - } - } - }, 'The key should use default nsSeparator : to split'); - t.end(); -}); - -test('Default keySeparator', (t) => { - const parser = new Parser({ - defaultValue: '__NOT_TRANSLATED__', - nsSeparator: ':', - keySeparator: '.' - }); - parser.set('key1.key2'); - - const resStore = parser.get(); - - t.same(resStore, { - en: { - translation: { - 'key1': { - 'key2': '__NOT_TRANSLATED__' - } - } - } - }, 'The key should use default keySeparator . to split'); - t.end(); -}); - -test('Override nsSeparator with a false value', (t) => { - const parser = new Parser({ - defaultValue: '__NOT_TRANSLATED__', - nsSeparator: ':', - keySeparator: '.' - }); - parser.set('translation:key1.key2', { - nsSeparator: false - }); - - const resStore = parser.get(); - - t.same(resStore, { - en: { - translation: { - 'translation:key1': { - 'key2': '__NOT_TRANSLATED__' - } - } - } - }, 'Override nsSeparator with a false value'); - t.end(); -}); - -test('Override keySeparator with a false value', (t) => { - const parser = new Parser({ - defaultValue: '__NOT_TRANSLATED__', - nsSeparator: ':', - keySeparator: '.' - }); - parser.set('translation:key1.key2', { - keySeparator: false - }); - - const resStore = parser.get(); - - t.same(resStore, { - en: { - translation: { - 'key1.key2': '__NOT_TRANSLATED__' - } - } - }, 'Override keySeparator with a false value'); - t.end(); -}); - -test('Multiline (Line Endings: LF)', (t) => { - const parser = new Parser({ - nsSeparator: false - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/multiline-unix.js'), 'utf-8'); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'this is a multiline string': '', - 'this is another multiline string': '' - } - } - }); - t.end(); -}); - -test('Multiline (Line Endings: CRLF)', (t) => { - const parser = new Parser({ - nsSeparator: false - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/multiline-dos.js'), 'utf-8'); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'this is a multiline string': '', - 'this is another multiline string': '' - } - } - }); - t.end(); -}); - -test('Plural', (t) => { - test('Default options', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - en: { - translation: { - 'key': '', - 'key_plural': '', - 'keyWithCountAndDefaultValues': '{{count}} item', - 'keyWithCountAndDefaultValues_plural': '{{count}} items', - 'keyWithCount': '', - 'keyWithCount_plural': '', - 'keyWithDefaultValueAndCount': '{{count}} item', - 'keyWithDefaultValueAndCount_plural': '{{count}} item', - 'keyWithVariable': '', - 'keyWithVariable_plural': '', - 'keyWithDefaultValueAndVariable': '{{count}} item', - 'keyWithDefaultValueAndVariable_plural': '{{count}} item' - } - } - }); - t.end(); - }); - - test('Languages with multiple plurals', (t) => { - const parser = new Parser({ lngs: ['ru'] }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - ru: { - translation: { - 'key_0': '', - 'key_1': '', - 'key_2': '', - 'keyWithCount_0': '', - 'keyWithCount_1': '', - 'keyWithCount_2': '', - 'keyWithVariable_0': '', - 'keyWithVariable_1': '', - 'keyWithVariable_2': '', - 'keyWithCountAndDefaultValues_0': '{{count}} item', - 'keyWithCountAndDefaultValues_1': '{{count}} item', - 'keyWithCountAndDefaultValues_2': '{{count}} item', - 'keyWithDefaultValueAndCount_0': '{{count}} item', - 'keyWithDefaultValueAndCount_1': '{{count}} item', - 'keyWithDefaultValueAndCount_2': '{{count}} item', - 'keyWithDefaultValueAndVariable_0': '{{count}} item', - 'keyWithDefaultValueAndVariable_1': '{{count}} item', - 'keyWithDefaultValueAndVariable_2': '{{count}} item' - } - } - }); - t.end(); - }); - - test('Languages with multiple plurals: non existing language', (t) => { - const parser = new Parser({ lngs: ['zz'] }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - zz: { - translation: {} - } - }); - t.end(); - }); - - test('Languages with multiple plurals: languages with single rule', (t) => { - const parser = new Parser({ lngs: ['ko'] }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - ko: { - translation: { - 'key_0': '', - 'keyWithCount_0': '', - 'keyWithVariable_0': '', - 'keyWithCountAndDefaultValues_0': '{{count}} item', - 'keyWithDefaultValueAndCount_0': '{{count}} item', - 'keyWithDefaultValueAndVariable_0': '{{count}} item', - } - } - }); - t.end(); - }); - - test('User defined function', (t) => { - const parser = new Parser({ - plural: (lng, ns, key, options) => { - if (key === 'key') { - return false; - } - if (key === 'keyWithCount') { - return true; - } - if (key === 'keyWithVariable') { - return true; - } - return true; - } - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - en: { - translation: { - 'key': '', - 'keyWithCount': '', - 'keyWithCountAndDefaultValues': '{{count}} item', - 'keyWithCountAndDefaultValues_plural': '{{count}} items', - 'keyWithCount_plural': '', - 'keyWithDefaultValueAndCount': '{{count}} item', - 'keyWithDefaultValueAndCount_plural': '{{count}} item', - 'keyWithVariable': '', - 'keyWithVariable_plural': '', - 'keyWithDefaultValueAndVariable': '{{count}} item', - 'keyWithDefaultValueAndVariable_plural': '{{count}} item' - } - } - }); - t.end(); - }); - - t.end(); -}); - -test('Namespace', (t) => { - const parser = new Parser({ - ns: ['translation', 'othernamespace'] - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/namespace.js'), 'utf-8'); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - othernamespace: { - 'friend': '' - }, - translation: {} - } - }); - t.end(); -}); - -test('Context', (t) => { - test('Default options', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/context.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - en: { - translation: { - 'friend': '', - 'friend_male': '', - 'friend_female': '', - 'friendDynamic': '', - } - } - }); - t.end(); - }); - - test('User defined function', (t) => { - const parser = new Parser({ - context: (lng, ns, key, options) => { - if (options.context === 'male') { - return true; - } - if (options.context === 'female') { - return false; - } - return true; - } - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/context.js'), 'utf-8'); - parser.parseFuncFromString(content, { propsFilter: props => props }); - t.same(parser.get(), { - en: { - translation: { - 'friend': '', - 'friend_male': '', - 'friendDynamic': '', - } - } - }); - t.end(); - }); - - t.end(); -}); - -test('Context with plural combined', (t) => { - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/context-plural.js'), 'utf-8'); - - test('Default options', (t) => { - const parser = new Parser({ - contextDefaultValues: ['male', 'female'], - }); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'friend': '', - 'friend_plural': '', - 'friend_male': '', - 'friend_male_plural': '', - 'friend_female': '', - 'friend_female_plural': '', - 'friendWithDefaultValue': '{{count}} boyfriend', - 'friendWithDefaultValue_plural': '{{count}} boyfriend', - 'friendWithDefaultValue_male': '{{count}} boyfriend', - 'friendWithDefaultValue_male_plural': '{{count}} boyfriend', - 'friendWithDefaultValue_female': '{{count}} girlfriend', - 'friendWithDefaultValue_female_plural': '{{count}} girlfriend', - 'friendDynamic': '', - 'friendDynamic_plural': '', - 'friendDynamic_male': '', - 'friendDynamic_male_plural': '', - 'friendDynamic_female': '', - 'friendDynamic_female_plural': '', - } - } - }); - t.end(); - }); - - test('Context form only', (t) => { - const parser = new Parser({ - context: true, - plural: false - }); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'friend': '', - 'friend_male': '', - 'friend_female': '', - 'friendWithDefaultValue': '{{count}} boyfriend', - 'friendWithDefaultValue_male': '{{count}} boyfriend', - 'friendWithDefaultValue_female': '{{count}} girlfriend', - 'friendDynamic': '', - } - } - }); - t.end(); - }); - - test('No context fallback', (t) => { - const parser = new Parser({ - context: true, - contextFallback: false, - contextDefaultValues: ['male', 'female'], - plural: false - }); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'friend_male': '', - 'friend_female': '', - 'friendWithDefaultValue_male': '{{count}} boyfriend', - 'friendWithDefaultValue_female': '{{count}} girlfriend', - 'friendDynamic_male': '', - 'friendDynamic_female': '', - } - } - }); - t.end(); - }); - - test('Plural form only', (t) => { - const parser = new Parser({ - context: false, - plural: true - }); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'friend': '', - 'friend_plural': '', - 'friendWithDefaultValue': '{{count}} boyfriend', - 'friendWithDefaultValue_plural': '{{count}} boyfriend', - 'friendDynamic': '', - 'friendDynamic_plural': '', - } - } - }); - t.end(); - }); - - test('No plural fallback', (t) => { - const parser = new Parser({ - context: false, - plural: true, - pluralFallback: false - }); - parser.parseFuncFromString(content); - t.same(parser.get(), { - en: { - translation: { - 'friend_plural': '', - 'friendWithDefaultValue_plural': '{{count}} boyfriend', - 'friendDynamic_plural': '', - } - } - }); - t.end(); - }); - - t.end(); -}); - -test('parser.toJSON()', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); - - parser.parseFuncFromString(content); - - t.same(parser.toJSON(), '{"en":{"translation":{"key2":"","key1":""}}}'); - t.end(); -}); - -test('parser.toJSON({ sort: true })', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); - - parser.parseFuncFromString(content); - - t.same(parser.toJSON({ sort: true }), '{"en":{"translation":{"key1":"","key2":""}}}'); - t.end(); -}); - -test('parser.toJSON({ sort: true, space: 2 })', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); - const wanted = JSON.stringify({ - en: { - translation: { - key1: '', - key2: '' - } - } - }, null, 2); - - parser.parseFuncFromString(content); - t.same(parser.toJSON({ sort: true, space: 2 }), wanted); - t.end(); -}); - -test('Extract properties from optional chaining', (t) => { - const parser = new Parser({ - defaultValue: function(lng, ns, key) { - if (lng === 'en') { - return key; - } - return '__NOT_TRANSLATED__'; - }, - keySeparator: false, - nsSeparator: false - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/optional-chaining.js'), 'utf8'); - const wanted = { - 'en': { - 'translation': { - 'optional chaining: {{value}}': 'optional chaining: {{value}}', - } - } - }; - - parser.parseFuncFromString(content); - t.same(parser.get(), wanted); - - t.end(); -}); - -test('Extract properties from template literals', (t) => { - const parser = new Parser({ - defaultValue: function(lng, ns, key) { - if (lng === 'en') { - return key.replace(/\r\n/g, '\n'); - } - return '__NOT_TRANSLATED__'; - }, - keySeparator: false, - nsSeparator: false, - allowDynamicKeys: false - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/template-literals.js'), 'utf8').replace(/\r\n/g, '\n'); - const wanted = { - 'en': { - 'translation': { - 'property in template literals': 'property in template literals', - 'added {{foo}}\n and {{bar}}': 'added {{foo}}\n and {{bar}}' - } - } - }; - - parser.parseFuncFromString(content); - t.same(parser.get(), wanted); - - t.end(); -}); - -test('Custom keySeparator and nsSeparator', (t) => { - const parser = new Parser({ - ns: ['translation', 'myNamespace'], - defaultValue: function(lng, ns, key) { - if (lng === 'en') { - return key; - } - return '__NOT_TRANSLATED__'; - }, - keySeparator: false, - nsSeparator: false - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/custom-separators.js'), 'utf8'); - const wanted = { - 'en': { - 'translation': { - 'myNamespace|firstKey>secondKey>without custom separators': 'myNamespace|firstKey>secondKey>without custom separators', - 'myNamespace:firstKey.secondKey.without custom separators 2': 'myNamespace:firstKey.secondKey.without custom separators 2' - }, - 'myNamespace': { - 'firstKey': { - 'secondKey': { - 'with custom separators': 'with custom separators', - 'with custom separators 2': 'with custom separators 2', - } - } - } - } - }; - - parser.parseFuncFromString(content); - t.same(parser.get(), wanted); - - t.end(); -}); - -test('Should accept trailing comma in functions', (t) => { - const content = ` - i18next.t( - 'friend', - ) - `; - class ParserMock extends Parser { - log(msg) { - if (msg.startsWith('i18next-scanner: Unable to parse code')) { - Parser.prototype.log = originalLog; // eslint-disable-line no-undef - throw new Error('Should not run into catch'); - } - } - } - const parser = new ParserMock({ debug: true }); - parser.parseFuncFromString(content, {}); - t.same(parser.get(), { - en: { - translation: { - 'friend': '' - } - } - }); - t.end(); -}); - -test('Default values test', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/default-values.js'), 'utf8'); - const wanted = { - 'en': { - 'translation': { - 'product': { - 'bread': 'Bread', - 'milk': 'Milk', - 'boiledEgg': 'Boiled Egg', - 'cheese': 'Cheese', - 'potato': '{{color}} potato', - 'carrot': '{{size}} carrot', - } - } - } - }; - - parser.parseFuncFromString(content); - t.same(parser.get(), wanted); - - t.end(); -}); - -test('metadata', (t) => { - const parser = new Parser(); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/metadata.js'), 'utf-8'); - const customHandler = function(key, options) { - parser.set(key, options); - t.same(options, { - 'metadata': { - 'tags': [ - 'tag1', - 'tag2', - ], - }, - }); - }; - parser.parseFuncFromString(content, customHandler); - t.same(parser.get(), { - en: { - translation: { - 'friend': '', - } - } - }); - t.end(); -}); - -test('allowDynamicKeys', (t) => { - const parser = new Parser({ - allowDynamicKeys: true - }); - const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/dynamic-keys.js'), 'utf-8'); - const customHandler = function(key, options) { - parser.set(key, options); - t.same(options, { - 'metadata': { - 'keys': [ - 'Hard', - 'Normal', - ], - }, - }); - }; - parser.parseFuncFromString(content, customHandler); - t.same(parser.get(), { - en: { - translation: { - 'Activities': { - '':'', - }, - 'LoadoutBuilder': { - 'Select': "" - } - } - } - }); - t.end(); -}); \ No newline at end of file diff --git a/test/parser.test.js b/test/parser.test.js new file mode 100644 index 0000000..b559b9f --- /dev/null +++ b/test/parser.test.js @@ -0,0 +1,1197 @@ +import fs from 'fs'; +import path from 'path'; +import sha1 from 'sha1'; +import Parser from '../src/parser'; + +test('set merges defaults', () => { + const parser = new Parser({ + ns: ['translation'] + }); + parser.set('key1', { defaultValue: 'Default text' }); + parser.set('key1'); + expect(parser.get('key1')).toEqual('Default text'); + + parser.set('key2'); + parser.set('key2', { defaultValue: 'Default text' }); + expect(parser.get('key2')).toEqual('Default text'); +}); + +test('set merges defaults (plural case)', () => { + const parser = new Parser({ + ns: ['translation'] + }); + parser.set('key1', { defaultValue: 'Default text', defaultValue_plural: 'Default plural text', count: 2 }); + parser.set('key1'); + parser.set('key1_plural'); + expect(parser.get('key1')).toEqual('Default text'); + expect(parser.get('key1_plural')).toEqual('Default plural text'); + + parser.set('key2'); + parser.set('key2_plural'); + parser.set('key2', { defaultValue: 'Default text', defaultValue_plural: 'Default plural text', count: 2 }); + expect(parser.get('key2')).toEqual('Default text'); + expect(parser.get('key2_plural')).toEqual('Default plural text'); +}); + +test('set merges defaults (plural case without default plural value)', () => { + const parser = new Parser({ + ns: ['translation'] + }); + parser.set('key2', { count: 2 }); + expect(parser.get('key2_plural')).toEqual(''); + parser.set('key2', { defaultValue: 'Default text', count: 2 }); + expect(parser.get('key2_plural')).toEqual('Default text'); +}); + +test('set warns about conflicting defaults', () => { + const parser = new Parser({ + ns: ['translation'] + }); + let logText; + parser.log = (msg) => { + logText = msg; + }; + parser.set('key', { defaultValue: 'Default text' }); + parser.set('key', { defaultValue: 'Another text' }); + expect(parser.get('key')).toEqual('Default text'); + expect(logText).toMatch(/different default value/); +}); + +test('set warns about conflicting defaults (plural case)', () => { + const parser = new Parser({ + ns: ['translation'] + }); + let logText; + parser.log = (msg) => { + logText = msg; + }; + parser.set('key', { defaultValue: 'Default text', defaultValue_plural: 'Default plural text', count: 2 }); + parser.set('key', { defaultValue: 'Default text', defaultValue_plural: 'Another plural text', count: 2 }); + expect(parser.get('key')).toEqual('Default text'); + expect(parser.get('key_plural')).toEqual('Default plural text'); + expect(logText).toMatch(/different default value/); +}); + +describe('Namespace is undefined', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test('Skip undefined namespace', () => { + const parser = new Parser({ + ns: ['translation'] + }); + const content = ` + i18next.t('none:key2'); // "none" does not exist in the namespaces + i18next.t('key1'); + `; + const wanted = { + en: { + translation: { + key1: '' + } + } + }; + + parser.parseFuncFromString(content); + expect(parser.get()).toEqual(wanted); + }); +}); + +test('Parse translation function', () => { + const parser = new Parser({ + lngs: ['en'], + fallbackLng: 'en' + }); + const customHandler = function(key) { + const defaultValue = '__TRANSLATION__'; // optional default value + parser.set(key, defaultValue); + }; + + // i18next.t('key'); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); + parser + .parseFuncFromString(content, customHandler) // pass a custom handler + .parseFuncFromString(content, { list: ['i18next.t'] }) // override `func.list` + .parseFuncFromString(content, { list: ['i18next.t'] }, customHandler) + .parseFuncFromString(content); // using default options and handler + + expect(parser.get()).toEqual({ + en: { + translation: { + 'key2': '__TRANSLATION__', + 'key1': '__TRANSLATION__' + } + } + }); + + // Sort keys in alphabetical order + expect(JSON.stringify(parser.get({ sort: true }))).toEqual(JSON.stringify({ + en: { + translation: { + 'key1': '__TRANSLATION__', + 'key2': '__TRANSLATION__' + } + } + })); + + expect(parser.get('key1', { lng: 'en' })).toBe('__TRANSLATION__'); + expect(parser.get('key1', { lng: 'de' })).toBe(undefined); + expect(parser.get('nokey', { lng: 'en' })).toBe(undefined); +}); + +test('Parse Trans components', () => { + const parser = new Parser({ + lngs: ['en'], + ns: [ + 'dev', + 'translation' + ], + trans: { + fallbackKey: true + }, + nsSeparator: false, + keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated + fallbackLng: 'en' + }); + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans.jsx'), 'utf-8'); + parser.parseTransFromString(content); + expect(parser.get()).toEqual({ + en: { + dev: { + 'Hello <1>World, you have <3>{{count}} unread message.': 'Hello <1>World, you have <3>{{count}} unread message.', + 'Hello <1>World, you have <3>{{count}} unread message._plural': 'Hello <1>World, you have <3>{{count}} unread message.' + }, + translation: { + // quote style + 'jsx-quotes-double': 'Use double quotes for the i18nKey attribute', + 'jsx-quotes-single': 'Use single quote for the i18nKey attribute', + + // plural + 'plural': 'You have <1>{{count}} apples', + 'plural_plural': 'You have <1>{{count}} apples', + + // context + 'context': 'A boyfriend', + 'context_male': 'A boyfriend', + + // i18nKey + 'multiline-text-string': 'multiline text string', + 'string-literal': 'This is a <1>test', + 'object-expression': 'This is a <1><0>{{test}}', + 'arithmetic-expression': '2 + 2 = <1>{{result}}', + 'components': 'Go to <1>Administration > Tools to download administrative tools.', + 'lorem-ipsum': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + 'lorem-ipsum-nested': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + + // fallback key + 'Hello, World!': 'Hello, World!', + 'multiline text string': 'multiline text string', + 'This is a <1>test': 'This is a <1>test', + 'This is a <1><0>{{test}}': 'This is a <1><0>{{test}}', + '2 + 2 = <1>{{result}}': '2 + 2 = <1>{{result}}', + 'Go to <1>Administration > Tools to download administrative tools.': 'Go to <1>Administration > Tools to download administrative tools.', + '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + + // defaults + 'The component might be self-closing': 'The component might be self-closing', + 'Some <0>{variable}': 'Some <0>{variable}', + 'Hello <1>{{planet}}!': 'Hello <1>{{planet}}!', + + // props + 'translation from props': 'translation from props', + 'translation from nested props': 'translation from nested props', + 'translation from deeply nested props': 'translation from deeply nested props', + 'tooltip1': 'Some tooltip text', + 'tooltip2': 'Some tooltip text' + } + } + }); +}); + +test('Parse Trans components with fallback key', () => { + const parser = new Parser({ + lngs: ['en'], + ns: [ + 'dev', + 'translation' + ], + trans: { + fallbackKey: (ns, value) => { + return sha1(value); // return a sha1 as the key + } + }, + nsSeparator: false, + keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated + fallbackLng: 'en' + }); + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans.jsx'), 'utf-8'); + parser.parseTransFromString(content); + expect(parser.get()).toEqual({ + en: { + dev: { + '2290678f8f33c49494499fe5e32b4ebd124d9292': 'Hello <1>World, you have <3>{{count}} unread message.', + '2290678f8f33c49494499fe5e32b4ebd124d9292_plural': 'Hello <1>World, you have <3>{{count}} unread message.' + }, + translation: { + // quote style + 'jsx-quotes-double': 'Use double quotes for the i18nKey attribute', + 'jsx-quotes-single': 'Use single quote for the i18nKey attribute', + + // plural + 'plural': 'You have <1>{{count}} apples', + 'plural_plural': 'You have <1>{{count}} apples', + + // context + 'context': 'A boyfriend', + 'context_male': 'A boyfriend', + + // i18nKey + 'multiline-text-string': 'multiline text string', + 'string-literal': 'This is a <1>test', + 'object-expression': 'This is a <1><0>{{test}}', + 'arithmetic-expression': '2 + 2 = <1>{{result}}', + 'components': 'Go to <1>Administration > Tools to download administrative tools.', + 'lorem-ipsum': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + 'lorem-ipsum-nested': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + + // fallback key + '0a0a9f2a6772942557ab5355d76af442f8f65e01': 'Hello, World!', + '32876cbad378f3153c900c297ed2efa06243e0e2': 'multiline text string', + 'e4ca61dff6bc759d214e32c4e37c8ae594ca163d': 'This is a <1>test', + '0ce90193dd25c93cdc12f25a36d31004a74c63de': 'This is a <1><0>{{test}}', + '493781e20cd3cfd5b3137963519571c3d97ab383': '2 + 2 = <1>{{result}}', + '083eac6b4f73ec317824caaaeea57fba3b83c1d9': 'Go to <1>Administration > Tools to download administrative tools.', + '938c04be9e14562b7532a19458fe92b65c6ef941': '<0>Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + '9c3ca5d5d8089e96135c8c7c9f42ba34a635fb47': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<1>Lorem Ipsum is simply dummy text of the printing and typesetting industry.<2>Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s', + + // defaults + '7551746c2d33a1d0a24658c22821c8700fa58a0d': 'Hello <1>{{planet}}!', + '253344d83465052dd6573c8c0abcd76f02fc3a97': 'Some <0>{variable}', + '7e514af8f77b74e74f86dc22a2cb173680462e34': 'The component might be self-closing', + + // props + 'c38f91deba88fc3bb582cc73dc658210324b01ec': 'translation from props', + '5bf216b4068991e3a2f5e55ae36c03add490a63f': 'translation from nested props', + '6fadff01c49d0ebe862a3aa33688735c03728197': 'translation from deeply nested props', + 'tooltip1': 'Some tooltip text', + 'tooltip2': 'Some tooltip text' + } + } + }); +}); + +test('Parse wrapped Trans components', () => { + const parser = new Parser({ + lngs: ['en'], + ns: [ + 'dev', + 'translation' + ], + trans: { + component: 'I18n', + i18nKey: '__t', + fallbackKey: true + }, + nsSeparator: false, + keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated + fallbackLng: 'en', + }); + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans.jsx'), 'utf-8'); + parser.parseTransFromString(content); + expect(parser.get()).toEqual({ + en: { + dev: {}, + translation: { + 'mykey': 'A wrapper component with key', + 'A wrapper component without key': 'A wrapper component without key' + } + } + }); +}); + +test('Parse Trans components with modern acorn features', () => { + const parser = new Parser({ + lngs: ['en'], + trans: { + fallbackKey: true + }, + nsSeparator: false, + keySeparator: '.', // Specify the keySeparator for this test to make sure the fallbackKey won't be separated + fallbackLng: 'en' + }); + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/trans-acorn.jsx'), 'utf-8'); + parser.parseTransFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + // Passing keys to via object spread is not yet supported: + 'Spread i18nKey': 'Spread i18nKey', + // 'spread': 'Spread i18nKey', // this would be expected. + 'simple': 'Simple i18nKey' + } + } + }); +}); + +describe('Parse HTML attribute', () => { + test('parseAttrFromString(content)', () => { + const parser = new Parser({ + lngs: ['en'], + fallbackLng: 'en' + }); + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); + parser.parseAttrFromString(content); + + expect(parser.get()).toEqual({ + en: { + translation: { + 'key1': '', + 'key2': '', + 'key3': '', + 'key4': '' + } + } + }); + }); + + test('parseAttrFromString(content, { list: ["data-i18n"] })', () => { + const parser = new Parser({ + lngs: ['en'], + fallbackLng: 'en' + }); + + //
+ const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); + parser.parseAttrFromString(content, { list: ['data-i18n'] }); + + expect(parser.get()).toEqual({ + en: { + translation: { + 'key1': '', + 'key2': '', + 'key3': '', + 'key4': '' + } + } + }); + }); + + test('parseAttrFromString(content, customHandler)', () => { + const parser = new Parser({ + lngs: ['en'], + fallbackLng: 'en' + }); + const customHandler = function(key) { + const defaultValue = '__TRANSLATION__'; // optional default value + parser.set(key, defaultValue); + }; + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); + parser.parseAttrFromString(content, customHandler); + + expect(parser.get()).toEqual({ + en: { + translation: { + 'key4': '__TRANSLATION__', + 'key3': '__TRANSLATION__', + 'key2': '__TRANSLATION__', + 'key1': '__TRANSLATION__' + } + } + }); + + // Sort keys in alphabetical order + expect(JSON.stringify(parser.get({ sort: true }))).toEqual(JSON.stringify({ + en: { + translation: { + 'key1': '__TRANSLATION__', + 'key2': '__TRANSLATION__', + 'key3': '__TRANSLATION__', + 'key4': '__TRANSLATION__' + } + } + })); + + expect(parser.get('key1', { lng: 'en' })).toBe('__TRANSLATION__'); + expect(parser.get('key1', { lng: 'de' })).toBe(undefined); + expect(parser.get('nokey', { lng: 'en' })).toBe(undefined); + }); + + test('parseAttrFromString(content, { list: ["data-i18n"] }, customHandler)', () => { + const parser = new Parser({ + lngs: ['en'], + fallbackLng: 'en' + }); + const customHandler = function(key) { + const defaultValue = '__TRANSLATION__'; // optional default value + parser.set(key, defaultValue); + }; + + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.html'), 'utf-8'); + parser.parseAttrFromString(content, { list: ['data-i18n'] }, customHandler); + + expect(parser.get()).toEqual({ + en: { + translation: { + 'key4': '__TRANSLATION__', + 'key3': '__TRANSLATION__', + 'key2': '__TRANSLATION__', + 'key1': '__TRANSLATION__' + } + } + }); + + // Sort keys in alphabetical order + expect(JSON.stringify(parser.get({ sort: true }))).toEqual(JSON.stringify({ + en: { + translation: { + 'key1': '__TRANSLATION__', + 'key2': '__TRANSLATION__', + 'key3': '__TRANSLATION__', + 'key4': '__TRANSLATION__' + } + } + })); + + expect(parser.get('key1', { lng: 'en' })).toBe('__TRANSLATION__'); + expect(parser.get('key1', { lng: 'de' })).toBe(undefined); + expect(parser.get('nokey', { lng: 'en' })).toBe(undefined); + }); +}); + +test('Gettext style i18n', () => { + const parser = new Parser({ + defaultValue: (lng, ns, key) => { + return key; + }, + keySeparator: false, + nsSeparator: false + }); + + // Parse Translation Function + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/modules/index.js'), 'utf8'); + + parser.parseFuncFromString(content, { list: ['_t'] }); + + const resStore = parser.get(); + expect(resStore).toEqual({ + en: { + translation: { + 'Loading...': 'Loading...', + 'This value does not exist.': 'This value does not exist.', + 'YouTube has more than {{count}} billion users.': 'YouTube has more than {{count}} billion users.', + 'YouTube has more than {{count}} billion users._plural': 'YouTube has more than {{count}} billion users.', + 'You have {{count}} messages.': 'You have {{count}} messages.', + 'You have {{count}} messages._plural': 'You have {{count}} messages.' + } + } + }); +}); + +test('Quotes', () => { + const parser = new Parser({ + defaultValue: function(lng, ns, key) { + if (lng === 'en') { + return key; + } + return '__NOT_TRANSLATED__'; + }, + keySeparator: false, + nsSeparator: false + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/quotes.js'), 'utf8'); + const wanted = { + 'en': { + 'translation': { + 'Primary \'email\' activation': 'Primary \'email\' activation', + 'Primary "email" activation': 'Primary "email" activation', + 'name=\'email\' value=\'{{email}}\'': 'name=\'email\' value=\'{{email}}\'', + 'name="email" value="{{email}}"': 'name="email" value="{{email}}"', + 'name="email" value=\'{{email}}\'': 'name="email" value=\'{{email}}\'', + 'name=\'email\' value="{{email}}"': 'name=\'email\' value="{{email}}"', + } + } + }; + + parser.parseFuncFromString(content); + expect(parser.get()).toEqual(wanted); +}); + +test('Disable nsSeparator', () => { + const parser = new Parser({ + defaultValue: '__NOT_TRANSLATED__', + nsSeparator: false, + keySeparator: '.' + }); + parser.set('foo:bar'); + + const resStore = parser.get(); + + expect(resStore).toEqual({ + en: { + translation: { + 'foo:bar': '__NOT_TRANSLATED__' + } + } + }); +}); + +test('Disable keySeparator', () => { + const parser = new Parser({ + defaultValue: '__NOT_TRANSLATED__', + nsSeparator: ':', + keySeparator: false + }); + parser.set('Creating...'); + + const resStore = parser.get(); + + expect(resStore).toEqual({ + en: { + translation: { + 'Creating...': '__NOT_TRANSLATED__' + } + } + }); +}); + +test('Default nsSeparator', () => { + const parser = new Parser({ + defaultValue: '__NOT_TRANSLATED__', + nsSeparator: ':', + keySeparator: '.' + }); + parser.set('translation:key1.key2'); + + const resStore = parser.get(); + + expect(resStore).toEqual({ + en: { + translation: { + 'key1': { + 'key2': '__NOT_TRANSLATED__' + } + } + } + }); +}); + +test('Default keySeparator', () => { + const parser = new Parser({ + defaultValue: '__NOT_TRANSLATED__', + nsSeparator: ':', + keySeparator: '.' + }); + parser.set('key1.key2'); + + const resStore = parser.get(); + + expect(resStore).toEqual({ + en: { + translation: { + 'key1': { + 'key2': '__NOT_TRANSLATED__' + } + } + } + }); +}); + +test('Override nsSeparator with a false value', () => { + const parser = new Parser({ + defaultValue: '__NOT_TRANSLATED__', + nsSeparator: ':', + keySeparator: '.' + }); + parser.set('translation:key1.key2', { + nsSeparator: false + }); + + const resStore = parser.get(); + + expect(resStore).toEqual({ + en: { + translation: { + 'translation:key1': { + 'key2': '__NOT_TRANSLATED__' + } + } + } + }); +}); + +test('Override keySeparator with a false value', () => { + const parser = new Parser({ + defaultValue: '__NOT_TRANSLATED__', + nsSeparator: ':', + keySeparator: '.' + }); + parser.set('translation:key1.key2', { + keySeparator: false + }); + + const resStore = parser.get(); + + expect(resStore).toEqual({ + en: { + translation: { + 'key1.key2': '__NOT_TRANSLATED__' + } + } + }); +}); + +test('Multiline (Line Endings: LF)', () => { + const parser = new Parser({ + nsSeparator: false + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/multiline-unix.js'), 'utf-8'); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'this is a multiline string': '', + 'this is another multiline string': '' + } + } + }); +}); + +test('Multiline (Line Endings: CRLF)', () => { + const parser = new Parser({ + nsSeparator: false + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/multiline-dos.js'), 'utf-8'); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'this is a multiline string': '', + 'this is another multiline string': '' + } + } + }); +}); + +describe('Plural', () => { + test('Default options', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + en: { + translation: { + 'key': '', + 'key_plural': '', + 'keyWithCountAndDefaultValues': '{{count}} item', + 'keyWithCountAndDefaultValues_plural': '{{count}} items', + 'keyWithCount': '', + 'keyWithCount_plural': '', + 'keyWithDefaultValueAndCount': '{{count}} item', + 'keyWithDefaultValueAndCount_plural': '{{count}} item', + 'keyWithVariable': '', + 'keyWithVariable_plural': '', + 'keyWithDefaultValueAndVariable': '{{count}} item', + 'keyWithDefaultValueAndVariable_plural': '{{count}} item' + } + } + }); + }); + + test('Languages with multiple plurals', () => { + const parser = new Parser({ lngs: ['ru'] }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + ru: { + translation: { + 'key_0': '', + 'key_1': '', + 'key_2': '', + 'keyWithCount_0': '', + 'keyWithCount_1': '', + 'keyWithCount_2': '', + 'keyWithVariable_0': '', + 'keyWithVariable_1': '', + 'keyWithVariable_2': '', + 'keyWithCountAndDefaultValues_0': '{{count}} item', + 'keyWithCountAndDefaultValues_1': '{{count}} item', + 'keyWithCountAndDefaultValues_2': '{{count}} item', + 'keyWithDefaultValueAndCount_0': '{{count}} item', + 'keyWithDefaultValueAndCount_1': '{{count}} item', + 'keyWithDefaultValueAndCount_2': '{{count}} item', + 'keyWithDefaultValueAndVariable_0': '{{count}} item', + 'keyWithDefaultValueAndVariable_1': '{{count}} item', + 'keyWithDefaultValueAndVariable_2': '{{count}} item' + } + } + }); + }); + + test('Languages with multiple plurals: non existing language', () => { + const parser = new Parser({ lngs: ['zz'] }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + zz: { + translation: {} + } + }); + }); + + test('Languages with multiple plurals: languages with single rule', () => { + const parser = new Parser({ lngs: ['ko'] }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + ko: { + translation: { + 'key_0': '', + 'keyWithCount_0': '', + 'keyWithVariable_0': '', + 'keyWithCountAndDefaultValues_0': '{{count}} item', + 'keyWithDefaultValueAndCount_0': '{{count}} item', + 'keyWithDefaultValueAndVariable_0': '{{count}} item', + } + } + }); + }); + + test('User defined function', () => { + const parser = new Parser({ + plural: (lng, ns, key, options) => { + if (key === 'key') { + return false; + } + if (key === 'keyWithCount') { + return true; + } + if (key === 'keyWithVariable') { + return true; + } + return true; + } + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/plural.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + en: { + translation: { + 'key': '', + 'keyWithCount': '', + 'keyWithCountAndDefaultValues': '{{count}} item', + 'keyWithCountAndDefaultValues_plural': '{{count}} items', + 'keyWithCount_plural': '', + 'keyWithDefaultValueAndCount': '{{count}} item', + 'keyWithDefaultValueAndCount_plural': '{{count}} item', + 'keyWithVariable': '', + 'keyWithVariable_plural': '', + 'keyWithDefaultValueAndVariable': '{{count}} item', + 'keyWithDefaultValueAndVariable_plural': '{{count}} item' + } + } + }); + }); +}); + +test('Namespace', () => { + const parser = new Parser({ + ns: ['translation', 'othernamespace'] + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/namespace.js'), 'utf-8'); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + othernamespace: { + 'friend': '' + }, + translation: {} + } + }); +}); + +describe('Context', () => { + test('Default options', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/context.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '', + 'friend_male': '', + 'friend_female': '', + 'friendDynamic': '', + } + } + }); + }); + + test('User defined function', () => { + const parser = new Parser({ + context: (lng, ns, key, options) => { + if (options.context === 'male') { + return true; + } + if (options.context === 'female') { + return false; + } + return true; + } + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/context.js'), 'utf-8'); + parser.parseFuncFromString(content, { propsFilter: props => props }); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '', + 'friend_male': '', + 'friendDynamic': '', + } + } + }); + }); +}); + +describe('Context with plural combined', () => { + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/context-plural.js'), 'utf-8'); + + test('Default options', () => { + const parser = new Parser({ + contextDefaultValues: ['male', 'female'], + }); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '', + 'friend_plural': '', + 'friend_male': '', + 'friend_male_plural': '', + 'friend_female': '', + 'friend_female_plural': '', + 'friendWithDefaultValue': '{{count}} boyfriend', + 'friendWithDefaultValue_plural': '{{count}} boyfriend', + 'friendWithDefaultValue_male': '{{count}} boyfriend', + 'friendWithDefaultValue_male_plural': '{{count}} boyfriend', + 'friendWithDefaultValue_female': '{{count}} girlfriend', + 'friendWithDefaultValue_female_plural': '{{count}} girlfriend', + 'friendDynamic': '', + 'friendDynamic_plural': '', + 'friendDynamic_male': '', + 'friendDynamic_male_plural': '', + 'friendDynamic_female': '', + 'friendDynamic_female_plural': '', + } + } + }); + }); + + test('Context form only', () => { + const parser = new Parser({ + context: true, + plural: false + }); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '', + 'friend_male': '', + 'friend_female': '', + 'friendWithDefaultValue': '{{count}} boyfriend', + 'friendWithDefaultValue_male': '{{count}} boyfriend', + 'friendWithDefaultValue_female': '{{count}} girlfriend', + 'friendDynamic': '', + } + } + }); + }); + + test('No context fallback', () => { + const parser = new Parser({ + context: true, + contextFallback: false, + contextDefaultValues: ['male', 'female'], + plural: false + }); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend_male': '', + 'friend_female': '', + 'friendWithDefaultValue_male': '{{count}} boyfriend', + 'friendWithDefaultValue_female': '{{count}} girlfriend', + 'friendDynamic_male': '', + 'friendDynamic_female': '', + } + } + }); + }); + + test('Plural form only', () => { + const parser = new Parser({ + context: false, + plural: true + }); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '', + 'friend_plural': '', + 'friendWithDefaultValue': '{{count}} boyfriend', + 'friendWithDefaultValue_plural': '{{count}} boyfriend', + 'friendDynamic': '', + 'friendDynamic_plural': '', + } + } + }); + }); + + test('No plural fallback', () => { + const parser = new Parser({ + context: false, + plural: true, + pluralFallback: false + }); + parser.parseFuncFromString(content); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend_plural': '', + 'friendWithDefaultValue_plural': '{{count}} boyfriend', + 'friendDynamic_plural': '', + } + } + }); + }); +}); + +test('parser.toJSON()', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); + + parser.parseFuncFromString(content); + + expect(parser.toJSON()).toEqual('{"en":{"translation":{"key2":"","key1":""}}}'); +}); + +test('parser.toJSON({ sort: true })', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); + + parser.parseFuncFromString(content); + + expect(parser.toJSON({ sort: true })).toEqual('{"en":{"translation":{"key1":"","key2":""}}}'); +}); + +test('parser.toJSON({ sort: true, space: 2 })', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/app.js'), 'utf-8'); + const wanted = JSON.stringify({ + en: { + translation: { + key1: '', + key2: '' + } + } + }, null, 2); + + parser.parseFuncFromString(content); + expect(parser.toJSON({ sort: true, space: 2 })).toEqual(wanted); +}); + +test('Extract properties from optional chaining', () => { + const parser = new Parser({ + defaultValue: function(lng, ns, key) { + if (lng === 'en') { + return key; + } + return '__NOT_TRANSLATED__'; + }, + keySeparator: false, + nsSeparator: false + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/optional-chaining.js'), 'utf8'); + const wanted = { + 'en': { + 'translation': { + 'optional chaining: {{value}}': 'optional chaining: {{value}}', + } + } + }; + + parser.parseFuncFromString(content); + expect(parser.get()).toEqual(wanted); +}); + +test('Extract properties from template literals', () => { + const parser = new Parser({ + defaultValue: function(lng, ns, key) { + if (lng === 'en') { + return key.replace(/\r\n/g, '\n'); + } + return '__NOT_TRANSLATED__'; + }, + keySeparator: false, + nsSeparator: false, + allowDynamicKeys: false + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/template-literals.js'), 'utf8').replace(/\r\n/g, '\n'); + const wanted = { + 'en': { + 'translation': { + 'property in template literals': 'property in template literals', + 'added {{foo}}\n and {{bar}}': 'added {{foo}}\n and {{bar}}' + } + } + }; + + parser.parseFuncFromString(content); + expect(parser.get()).toEqual(wanted); +}); + +test('Custom keySeparator and nsSeparator', () => { + const parser = new Parser({ + ns: ['translation', 'myNamespace'], + defaultValue: function(lng, ns, key) { + if (lng === 'en') { + return key; + } + return '__NOT_TRANSLATED__'; + }, + keySeparator: false, + nsSeparator: false + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/custom-separators.js'), 'utf8'); + const wanted = { + 'en': { + 'translation': { + 'myNamespace|firstKey>secondKey>without custom separators': 'myNamespace|firstKey>secondKey>without custom separators', + 'myNamespace:firstKey.secondKey.without custom separators 2': 'myNamespace:firstKey.secondKey.without custom separators 2' + }, + 'myNamespace': { + 'firstKey': { + 'secondKey': { + 'with custom separators': 'with custom separators', + 'with custom separators 2': 'with custom separators 2', + } + } + } + } + }; + + parser.parseFuncFromString(content); + expect(parser.get()).toEqual(wanted); +}); + +test('Should accept trailing comma in functions', () => { + const content = ` + i18next.t( + 'friend', + ) + `; + class ParserMock extends Parser { + log(msg) { + if (msg.startsWith('i18next-scanner: Unable to parse code')) { + Parser.prototype.log = originalLog; // eslint-disable-line no-undef + throw new Error('Should not run into catch'); + } + } + } + const parser = new ParserMock({ debug: true }); + parser.parseFuncFromString(content, {}); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '' + } + } + }); +}); + +test('Default values test', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/default-values.js'), 'utf8'); + const wanted = { + 'en': { + 'translation': { + 'product': { + 'bread': 'Bread', + 'milk': 'Milk', + 'boiledEgg': 'Boiled Egg', + 'cheese': 'Cheese', + 'potato': '{{color}} potato', + 'carrot': '{{size}} carrot', + } + } + } + }; + + parser.parseFuncFromString(content); + expect(parser.get()).toEqual(wanted); +}); + +test('metadata', () => { + const parser = new Parser(); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/metadata.js'), 'utf-8'); + const customHandler = function(key, options) { + parser.set(key, options); + expect(options).toEqual({ + 'metadata': { + 'tags': [ + 'tag1', + 'tag2', + ], + }, + }); + }; + parser.parseFuncFromString(content, customHandler); + expect(parser.get()).toEqual({ + en: { + translation: { + 'friend': '', + } + } + }); +}); + +test('allowDynamicKeys', () => { + const parser = new Parser({ + allowDynamicKeys: true + }); + const content = fs.readFileSync(path.resolve(__dirname, 'fixtures/dynamic-keys.js'), 'utf-8'); + const customHandler = function(key, options) { + parser.set(key, options); + expect(options).toEqual({ + 'metadata': { + 'keys': [ + 'Hard', + 'Normal', + ], + }, + }); + }; + parser.parseFuncFromString(content, customHandler); + expect(parser.get()).toEqual({ + en: { + translation: { + 'Activities': { + '': '', + }, + 'LoadoutBuilder': { + 'Select': '' + } + } + } + }); +}); diff --git a/test/react-i18next/i18n.js b/test/react-i18next/i18n.js new file mode 100644 index 0000000..5cfcda3 --- /dev/null +++ b/test/react-i18next/i18n.js @@ -0,0 +1,68 @@ +import i18n from 'i18next'; + +// set instance on hooks stuff +import { setI18n } from 'react-i18next'; + +setI18n(i18n); + +i18n.init({ + lng: 'en', + fallbackLng: 'en', + + resources: { + en: { + translation: { + key1: 'test', + interpolateKey: 'add {{insert}} {{up, uppercase}}', + interpolateKey2: 'add {{insert}} {{up, uppercase}}', + transTest1: 'Go <1>there.', + transTest1_noParent: '<0>Go <1>there.', + transTest1_customHtml: 'Go
<1>there.', + transTest1_customHtml2: 'Go
there.', + transTest1_customHtml3: + 'Go