├── .npmignore ├── tests ├── fixtures │ ├── test_context.js │ ├── expected_resolve_if_no_extractor_match.js.src │ ├── backtick_test.js │ ├── test_file_with_gettext.js │ ├── test_require_discover.js │ ├── backtick_with_expressions.js │ ├── sort_by_msgid_input2.js │ ├── sort_by_msgid_input.js │ ├── expected_gettext_simple_literal.pot │ ├── expected_gettext_literal_with_formatting.pot │ ├── expected_ngettext_simple_literal.pot │ ├── expected_sort.pot │ ├── expected_sort_by_msgctx.pot │ ├── expected_sort_by_msgctxt_and_msgid.pot │ ├── contexts_translations.po │ ├── expected_sort_by_msgid_withou_reference_line_num.pot │ ├── ua.po │ ├── resolve_numbered_expressions.po │ ├── expected_sort_by_msgid_sorted.pot │ └── resolve_simple_gettext.po ├── unit │ ├── test_jsxtag_gettext_extractor.js │ ├── test_config.js │ ├── test_tag_gettext_extractor.js │ ├── test_gettext_extractor.js │ ├── test_context.js │ ├── test_ngettext_extractor.js │ ├── test_po-helpers.js │ └── test_utils.js └── functional │ ├── test_resolve_strip_polyglot_tags.js │ ├── test_contexts_extract_from_file.js │ ├── test_extract_filename.js │ ├── test_empty_config_mode.js │ ├── test_extract_fn_gettext.js │ ├── test_entries_sort.js │ ├── test_npm_specifiers_extract.js │ ├── test_macro_extract.js │ ├── test_resolve_when_validation_fails.js │ ├── test_sorted_entries_sort.js │ ├── test_extract_gettext_with_formatting.js │ ├── test_resolve_ngettext_default_for_locale.js │ ├── test_alias_discover.js │ ├── test_extract_js_format.js │ ├── test_macro_resolve.js │ ├── test_sorted_entries_without_reference_line_num.js │ ├── test_unresolved.js │ ├── test_resolve_gettext_default.js │ ├── test_alias_extract.js │ ├── test_alias_resolve.js │ ├── test_resolve_default.js │ ├── test_extract_numbered_expressions.js │ ├── test_resolve_fn_gettext.js │ ├── test_extract_gettext_simple.js │ ├── test_resolve_ngettext_default.js │ ├── test_resolve_contexts.js │ ├── test_discover_by_require.js │ ├── test_extract_ngettext.js │ ├── test_entries_sort_by_msgctxt.js │ ├── test_resolve_jsxtag_gettext.js │ ├── test_extract_developer_comments.js │ ├── test_disabled_scope.js │ ├── test_resolve_numbered_expressions.js │ ├── test_extract_developer_comments_by_tag.js │ ├── test_contexts_extract.js │ ├── test_po_resolve.js │ ├── test_resolve_gettext.js │ └── test_resolve_ngettext.js ├── .gitignore ├── .babelrc ├── .travis.yml ├── coverage_run.sh ├── .eslintrc ├── src ├── errors.js ├── extractors │ ├── jsxtag-gettext.js │ ├── gettext.js │ ├── tag-gettext.js │ └── ngettext.js ├── defaults.js ├── extract.js ├── resolve.js ├── config.js ├── gettext-context.js ├── ttag.macro.js ├── context.js ├── utils.js ├── po-helpers.js └── plugin.js ├── LICENSE ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── Makefile /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tests 3 | Makefile 4 | coverage 5 | .idea 6 | -------------------------------------------------------------------------------- /tests/fixtures/test_context.js: -------------------------------------------------------------------------------- 1 | import { c } from 'ttag'; 2 | 3 | c('test ctx').t`test`; 4 | -------------------------------------------------------------------------------- /tests/fixtures/expected_resolve_if_no_extractor_match.js.src: -------------------------------------------------------------------------------- 1 | console.log(gtt`simple string literal ${a}`); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | npm-debug.log 4 | debug 5 | dist 6 | coverage 7 | .DS_Store 8 | .vscode 9 | -------------------------------------------------------------------------------- /tests/fixtures/backtick_test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { t } from 'ttag'; 3 | t`test with \` backtick`; -------------------------------------------------------------------------------- /tests/fixtures/test_file_with_gettext.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { t } from 'ttag'; 3 | function test() { 4 | return t`test`; 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/test_require_discover.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const { t } = require('ttag'); 4 | console.log(t`starting count up to`); 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-proposal-class-properties" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/backtick_with_expressions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { t } from 'ttag'; 3 | const a = 5; 4 | t`test with \` backtick with ${a}`; -------------------------------------------------------------------------------- /tests/fixtures/sort_by_msgid_input2.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | t`a test`; 4 | 5 | t`s test`; 6 | 7 | t`b test`; 8 | 9 | t`c test`; 10 | 11 | t`s test`; 12 | t`s test`; -------------------------------------------------------------------------------- /tests/fixtures/sort_by_msgid_input.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | t`a test`; 3 | 4 | t`s test`; 5 | t`s test`; 6 | t`s test`; 7 | t`s test`; 8 | t`s test`; 9 | t`s test`; 10 | 11 | t`b test`; 12 | 13 | -------------------------------------------------------------------------------- /tests/fixtures/expected_gettext_simple_literal.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | msgid "simple string literal" 7 | msgstr "" 8 | -------------------------------------------------------------------------------- /tests/fixtures/expected_gettext_literal_with_formatting.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | #, javascript-format 7 | msgid "literal with formatting ${ a }" 8 | msgstr "" 9 | -------------------------------------------------------------------------------- /tests/fixtures/expected_ngettext_simple_literal.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | msgid "${ a } banana" 7 | msgid_plural "${ a } banana" 8 | msgstr[0] "" 9 | msgstr[1] "" 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 10 5 | - node 6 | 7 | before_install: 8 | - chmod +x coverage_run.sh 9 | 10 | before_script: 11 | - npm install 12 | - npm install -g codecov 13 | 14 | script: 15 | - make lint 16 | - make test 17 | - ./coverage_run.sh 18 | 19 | after_success: 20 | - codecov 21 | -------------------------------------------------------------------------------- /tests/fixtures/expected_sort.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | msgid "aaaaa" 7 | msgstr "" 8 | 9 | msgid "bbbb" 10 | msgstr "" 11 | 12 | msgid "ccccc" 13 | msgstr "" 14 | 15 | msgid "eeeee" 16 | msgstr "" 17 | 18 | msgid "fffff" 19 | msgstr "" 20 | -------------------------------------------------------------------------------- /tests/fixtures/expected_sort_by_msgctx.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | msgctxt "aa" 7 | msgid "aa" 8 | msgstr "" 9 | 10 | msgctxt "bb" 11 | msgid "aa" 12 | msgstr "" 13 | 14 | msgctxt "cc" 15 | msgid "aa" 16 | msgstr "" 17 | 18 | msgctxt "dd" 19 | msgid "aa" 20 | msgstr "" 21 | 22 | msgctxt "ee" 23 | msgid "cc" 24 | msgstr "" 25 | 26 | msgctxt "ee" 27 | msgid "aa" 28 | msgstr "" 29 | -------------------------------------------------------------------------------- /tests/fixtures/expected_sort_by_msgctxt_and_msgid.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | msgctxt "aa" 7 | msgid "aa" 8 | msgstr "" 9 | 10 | msgctxt "bb" 11 | msgid "aa" 12 | msgstr "" 13 | 14 | msgctxt "cc" 15 | msgid "aa" 16 | msgstr "" 17 | 18 | msgctxt "dd" 19 | msgid "aa" 20 | msgstr "" 21 | 22 | msgctxt "ee" 23 | msgid "aa" 24 | msgstr "" 25 | 26 | msgctxt "ee" 27 | msgid "cc" 28 | msgstr "" 29 | -------------------------------------------------------------------------------- /tests/unit/test_jsxtag_gettext_extractor.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import gettext from 'src/extractors/jsxtag-gettext'; 3 | import template from '@babel/template'; 4 | import Context from 'src/context'; 5 | 6 | const enConfig = new Context(); 7 | 8 | describe('jsxtag-gettext match', () => { 9 | it('should match gettext', () => { 10 | const node = template('jt`${n} banana`')().expression; 11 | const result = gettext.match(node, enConfig); 12 | expect(result).to.be.true; 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /coverage_run.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | export PATH=`pwd`/node_modules/.bin:$PATH 3 | export NODE_PATH=`pwd`:$NODE_MODULES 4 | 5 | echo "functional tests run" 6 | 7 | # Runs all tests inside tests/functional 8 | ls tests/functional | \ 9 | tr '\n' '\0' | \ 10 | xargs -0 -n1 -I {} istanbul cover ./node_modules/mocha/bin/_mocha --dir coverage/fun_{} -- -r @babel/register ./tests/functional/{} 11 | 12 | echo "unit tests run" 13 | 14 | # Runs all unit tests 15 | istanbul cover ./node_modules/mocha/bin/_mocha --dir coverage/unit -- -r @babel/register ./tests/unit 16 | 17 | # Merges report 18 | istanbul report 19 | -------------------------------------------------------------------------------- /tests/fixtures/contexts_translations.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | msgid "test" 7 | msgstr "test default context" 8 | 9 | msgctxt "email" 10 | msgid "test" 11 | msgstr "test email context" 12 | 13 | msgid "${ a } banana" 14 | msgid_plural "${ a } bananas" 15 | msgstr[0] "${ a } banana default" 16 | msgstr[1] "${ a } bananas default" 17 | 18 | msgctxt "email" 19 | msgid "${ a } banana" 20 | msgid_plural "${ a } bananas" 21 | msgstr[0] "${ a } banana email context" 22 | msgstr[1] "${ a } bananas email context" 23 | -------------------------------------------------------------------------------- /tests/fixtures/expected_sort_by_msgid_withou_reference_line_num.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | #: tests/fixtures/sort_by_msgid_input.js 7 | #: tests/fixtures/sort_by_msgid_input2.js 8 | msgid "a test" 9 | msgstr "" 10 | 11 | #: tests/fixtures/sort_by_msgid_input.js 12 | #: tests/fixtures/sort_by_msgid_input2.js 13 | msgid "b test" 14 | msgstr "" 15 | 16 | #: tests/fixtures/sort_by_msgid_input2.js 17 | msgid "c test" 18 | msgstr "" 19 | 20 | #: tests/fixtures/sort_by_msgid_input.js 21 | #: tests/fixtures/sort_by_msgid_input2.js 22 | msgid "s test" 23 | msgstr "" 24 | -------------------------------------------------------------------------------- /tests/fixtures/ua.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Language: uk\n" 4 | "MIME-Version: 1.0\n" 5 | "Content-Type: text/plain; charset=UTF-8\n" 6 | "Content-Transfer-Encoding: 8bit\n" 7 | "X-Bugs: Report translation errors to the Language-Team address.\n" 8 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 9 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 10 | 11 | #: tests/fixtures/fixture.js:6 12 | msgid "plural form with ${ n } plural" 13 | msgid_plural "plural form with ${ n } plurals" 14 | msgstr[0] "plural form with ${ n } plural" 15 | msgstr[1] "plural form with ${ n } plurals" 16 | msgstr[2] "plural form with ${ n } plurals" 17 | 18 | 19 | #: tests/fixtures/fixture.js:6 20 | msgid "test" 21 | msgstr "test [translated]" 22 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_strip_polyglot_tags.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | describe('Resolve strip tags by default', () => { 7 | before(() => { 8 | rmDirSync('debug'); 9 | }); 10 | 11 | it('should strip polyglot tags if translations: default (without resolve config)', () => { 12 | const input = 'console.log(t`simple string literal`);'; 13 | const customOpts = { 14 | presets: ['@babel/preset-env'], 15 | plugins: [[c3poPlugin, { discover: ['t'], resolve: { translations: 'default' } }]], 16 | }; 17 | const result = babel.transform(input, customOpts).code; 18 | expect(result).to.contain('console.log("simple string literal");'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": {"mocha": true}, 3 | "globals": { 4 | "_": true 5 | }, 6 | "extends": "airbnb", 7 | "parser": "babel-eslint", 8 | "rules": { 9 | "no-unused-expressions": "off", 10 | "no-unused-vars": ["error", { "varsIgnorePattern": "^_$" }], 11 | "prefer-const": ["error", {"destructuring": "all"}], 12 | "no-underscore-dangle": ["error", {"allow": [ 13 | "_C3PO_visited", 14 | "_C3PO_GETTEXT_CONTEXT", 15 | "_ORIGINAL_NODE" 16 | ]}], 17 | "indent": [2, 4, {"SwitchCase": 1}], 18 | "import/no-unresolved": 0, 19 | "no-template-curly-in-string": 0, 20 | "class-methods-use-this": 0, 21 | "no-plusplus": 0, 22 | "no-param-reassign": 0, 23 | "max-classes-per-file": 0, 24 | "no-restricted-syntax": 0, 25 | "no-useless-escape": 0, 26 | "no-continue": 0, 27 | "import/prefer-default-export": 0 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/fixtures/resolve_numbered_expressions.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | "Content-Transfer-Encoding: 8bit\n" 5 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 6 | 7 | #: tests/fixtures/fixture.js:10 8 | msgid "Hello ${ 0 }" 9 | msgstr "Hello ${ 0 } [translated]" 10 | 11 | #: tests/fixtures/fixture.js:11 12 | msgid "reverse ${ 0 } ${ 1 }" 13 | msgstr "reverse ${ 1 } ${ 0 } [translated]" 14 | 15 | #: tests/fixtures/fixture.js:20 16 | msgid "${ 0 } banana" 17 | msgid_plural "${ 0 } bananas" 18 | msgstr[0] "${ 0 } banana [translated]" 19 | msgstr[1] "${ 0 } bananas [translated]" 20 | 21 | #: tests/fixtures/fixture.js:21 22 | msgid "${ 0 } ${ 1 } banana" 23 | msgid_plural "${ 0 } ${ 1 } bananas" 24 | msgstr[0] "${ 1 } ${ 0 } banana [translated]" 25 | msgstr[1] "${ 1 } ${ 0 } bananas [translated]" 26 | 27 | #: tests/fixtures/fixture.js:30 28 | msgid "react comp - ${ 0 }" 29 | msgstr "react comp - ${ 0 } [translated]" 30 | -------------------------------------------------------------------------------- /tests/functional/test_contexts_extract_from_file.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import c3po from 'src/plugin'; 6 | import { rmDirSync } from 'src/utils'; 7 | 8 | describe('Contexts extract', () => { 9 | before(() => { 10 | rmDirSync('debug'); 11 | }); 12 | 13 | it('should extract context from file with filename', () => { 14 | const output = 'debug/translations.pot'; 15 | const options = { 16 | plugins: [[c3po, { extract: { output } }]], 17 | }; 18 | const inputFile = 'tests/fixtures/test_context.js'; 19 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 20 | const result = fs.readFileSync(output).toString(); 21 | expect(result).to.contain( 22 | '#: tests/fixtures/test_context.js:3\nmsgctxt "test ctx"\nmsgid "test"\nmsgstr ""', 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/fixtures/expected_sort_by_msgid_sorted.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | #: tests/fixtures/sort_by_msgid_input.js:2 7 | #: tests/fixtures/sort_by_msgid_input2.js:3 8 | msgid "a test" 9 | msgstr "" 10 | 11 | #: tests/fixtures/sort_by_msgid_input.js:11 12 | #: tests/fixtures/sort_by_msgid_input2.js:7 13 | msgid "b test" 14 | msgstr "" 15 | 16 | #: tests/fixtures/sort_by_msgid_input2.js:9 17 | msgid "c test" 18 | msgstr "" 19 | 20 | #: tests/fixtures/sort_by_msgid_input.js:4 21 | #: tests/fixtures/sort_by_msgid_input.js:5 22 | #: tests/fixtures/sort_by_msgid_input.js:6 23 | #: tests/fixtures/sort_by_msgid_input.js:7 24 | #: tests/fixtures/sort_by_msgid_input.js:8 25 | #: tests/fixtures/sort_by_msgid_input.js:9 26 | #: tests/fixtures/sort_by_msgid_input2.js:5 27 | #: tests/fixtures/sort_by_msgid_input2.js:11 28 | #: tests/fixtures/sort_by_msgid_input2.js:12 29 | msgid "s test" 30 | msgstr "" 31 | -------------------------------------------------------------------------------- /tests/functional/test_extract_filename.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import c3poPlugin from 'src/plugin'; 6 | import { rmDirSync } from 'src/utils'; 7 | 8 | describe('Extract comments', () => { 9 | before(() => { 10 | rmDirSync('debug'); 11 | }); 12 | 13 | it('should extract relative filename path to source file in comments', () => { 14 | const output = 'debug/translations.pot'; 15 | const options = { 16 | presets: ['@babel/preset-env'], 17 | plugins: [[c3poPlugin, { extract: { output } }]], 18 | }; 19 | const inputFile = 'tests/fixtures/test_file_with_gettext.js'; 20 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 21 | const result = fs.readFileSync(output).toString(); 22 | expect(result).to.include('#: tests/fixtures/test_file_with_gettext.js:4'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export class ConfigValidationError extends Error {} 2 | export class ConfigError extends Error {} 3 | 4 | export function ValidationError(message) { 5 | this.name = 'ValidationError'; 6 | this.message = message; 7 | this.stack = (new Error()).stack; 8 | } 9 | 10 | ValidationError.prototype = Object.create(Error.prototype); 11 | ValidationError.prototype.constructor = ValidationError; 12 | 13 | export function NoTranslationError(message) { 14 | this.name = 'NoTranslationError'; 15 | this.message = message; 16 | this.stack = (new Error()).stack; 17 | } 18 | 19 | NoTranslationError.prototype = Object.create(Error.prototype); 20 | NoTranslationError.prototype.constructor = NoTranslationError; 21 | 22 | export function NoExpressionError(message) { 23 | this.name = 'NoExpressionError'; 24 | this.message = message; 25 | this.stack = (new Error()).stack; 26 | } 27 | NoExpressionError.prototype = Object.create(Error.prototype); 28 | NoExpressionError.prototype.constructor = NoExpressionError; 29 | -------------------------------------------------------------------------------- /tests/functional/test_empty_config_mode.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const options = { 7 | plugins: [c3poPlugin], 8 | }; 9 | 10 | describe('Empty config', () => { 11 | before(() => { 12 | rmDirSync('debug'); 13 | }); 14 | it('should not resolve if no resolve option', () => { 15 | const input = ` 16 | import { t } from 'ttag'; 17 | fun1(t\`test\`); 18 | `; 19 | const result = babel.transform(input, options).code; 20 | expect(result).to.contain('fun1(t`test`);'); 21 | }); 22 | it('validation should work for empty config', () => { 23 | const input = ` 24 | import { t } from 'ttag'; 25 | fun1(t\`test \${ a() }\`); 26 | `; 27 | const fn = () => babel.transform(input, options).code; 28 | expect(fn).to.throw('You can not use CallExpression \'${a()}\' in localized strings'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/functional/test_extract_fn_gettext.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttagPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | const output = 'debug/translations.pot'; 8 | const options = { 9 | plugins: [[ttagPlugin, { extract: { output }, discover: ['gettext', '_'] }]], 10 | }; 11 | 12 | describe('Extract tag-gettext', () => { 13 | before(() => { 14 | rmDirSync('debug'); 15 | }); 16 | 17 | it('should extract from gettext fn', () => { 18 | const input = 'console.log(gettext("gettext test"));'; 19 | babel.transform(input, options); 20 | const result = fs.readFileSync(output).toString(); 21 | expect(result).to.contain('msgid "gettext test"'); 22 | }); 23 | 24 | it('should extract from _ fn', () => { 25 | const input = 'console.log(_("_ test"));'; 26 | babel.transform(input, options); 27 | const result = fs.readFileSync(output).toString(); 28 | expect(result).to.contain('msgid "_ test"'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/functional/test_entries_sort.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import c3poPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | const output = 'debug/translations.pot'; 8 | const options = { 9 | presets: ['@babel/preset-env'], 10 | plugins: [[c3poPlugin, { 11 | extract: { output }, 12 | discover: ['t'], 13 | sortByMsgid: true, 14 | }]], 15 | }; 16 | 17 | describe('Sorting entries by msgid', () => { 18 | beforeEach(() => { 19 | rmDirSync('debug'); 20 | }); 21 | 22 | it('should sort entries by msgid', () => { 23 | const expectedPath = 'tests/fixtures/expected_sort.pot'; 24 | const input = ` 25 | t\`bbbb\`; 26 | t\`aaaaa\`; 27 | t\`ccccc\`; 28 | t\`fffff\`; 29 | t\`eeeee\`; 30 | `; 31 | babel.transform(input, options); 32 | const result = fs.readFileSync(output).toString(); 33 | const expected = fs.readFileSync(expectedPath).toString(); 34 | expect(result).to.eql(expected); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/unit/test_config.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { configSchema, validateConfig } from 'src/config'; 3 | 4 | describe('config validateConfig', () => { 5 | it('should be valid', () => { 6 | const config = { 7 | extract: { 8 | output: 'translations.pot', 9 | location: 'file', 10 | }, 11 | resolve: { translations: 'i18n/en.po' }, 12 | }; 13 | const expected = [true, 'No errors', null]; 14 | expect(validateConfig(config, configSchema)).to.eql(expected); 15 | }); 16 | 17 | it('should not be valid', () => { 18 | const config = { 19 | extract: { 20 | output: 'translations.pot', 21 | location: 'bad-location', 22 | }, 23 | resolve: { translations: 'i18n/en.po' }, 24 | }; 25 | const [isValid, errorsText, errors] = validateConfig(config, configSchema); 26 | expect(isValid).to.eql(false); 27 | expect(errorsText).to.not.equal('No errors'); 28 | expect(errors[0].data).to.eql('bad-location'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 alexmost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/functional/test_npm_specifiers_extract.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttag from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | plugins: [[ttag, { extract: { output } }]], 11 | }; 12 | 13 | const expect2 = `msgid "" 14 | msgstr "" 15 | "Content-Type: text/plain; charset=utf-8\\n" 16 | "Plural-Forms: nplurals=2; plural=(n!=1);\\n" 17 | 18 | msgid "test" 19 | msgstr "" 20 | `; 21 | 22 | describe('Deno npm: specifiers extract', () => { 23 | before(() => { 24 | rmDirSync('debug'); 25 | }); 26 | 27 | it('should extract translations from specifiers', () => { 28 | const input = dedent(` 29 | import { t } from 'npm:ttag@1.8.6'; 30 | console.log(t\`test\`); 31 | `); 32 | 33 | const babelResult = babel.transform(input, options); 34 | expect(babelResult.code).to.eql(input); 35 | 36 | const result = fs.readFileSync(output).toString(); 37 | expect(result).to.eql(expect2); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/functional/test_macro_extract.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttag from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | plugins: [[ttag, { extract: { output } }]], 11 | }; 12 | 13 | const expect2 = `msgid "" 14 | msgstr "" 15 | "Content-Type: text/plain; charset=utf-8\\n" 16 | "Plural-Forms: nplurals=2; plural=(n!=1);\\n" 17 | 18 | msgid "test" 19 | msgstr "" 20 | 21 | msgid "test2" 22 | msgstr "" 23 | `; 24 | 25 | describe('Macro extract', () => { 26 | before(() => { 27 | rmDirSync('debug'); 28 | }); 29 | 30 | it('should extract translations from macro', () => { 31 | const input = dedent(` 32 | import { t, jt } from 'babel-plugin-ttag/dist/ttag.macro'; 33 | console.log(t\`test\`); 34 | console.log(jt\`test2\`); 35 | `); 36 | 37 | const babelResult = babel.transform(input, options); 38 | expect(babelResult.code).to.eql(input); 39 | 40 | const result = fs.readFileSync(output).toString(); 41 | expect(result).to.eql(expect2); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_when_validation_fails.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | presets: ['@babel/preset-env'], 10 | plugins: [[c3poPlugin, { 11 | resolve: { translations }, 12 | extractors: { 13 | gettext: { 14 | invalidFormat: 'skip', 15 | }, 16 | }, 17 | discover: ['gettext', 't'], 18 | }]], 19 | }; 20 | 21 | describe('Test resolve when validation fails', () => { 22 | before(() => { 23 | rmDirSync('debug'); 24 | }); 25 | 26 | it('should not throw for fn-gettext', () => { 27 | const input = 'console.log(gettext(fn()));'; 28 | const fun = () => babel.transform(input, options).code; 29 | expect(fun).to.not.throw(); 30 | }); 31 | 32 | it('should throw for tag-gettext', () => { 33 | const input = 'console.log(t`${fn()} random string`);'; 34 | const fun = () => babel.transform(input, options).code; 35 | expect(fun).to.throw('You can not use CallExpression \'${fn()}\' in localized strings'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/functional/test_sorted_entries_sort.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { expect } from 'chai'; 3 | import * as babel from '@babel/core'; 4 | import fs from 'fs'; 5 | import c3poPlugin from 'src/plugin'; 6 | import { rmDirSync } from 'src/utils'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | presets: ['@babel/preset-env'], 11 | plugins: [[c3poPlugin, { 12 | extract: { output }, 13 | discover: ['t'], 14 | sortByMsgid: true, 15 | }]], 16 | }; 17 | 18 | describe('Sorting entries by msgid', () => { 19 | beforeEach(() => { 20 | rmDirSync('debug'); 21 | }); 22 | 23 | it('should not duplicate reference filenames', () => { 24 | const inputFile = 'tests/fixtures/sort_by_msgid_input.js'; 25 | const inputFile2 = 'tests/fixtures/sort_by_msgid_input2.js'; 26 | const expectedPath = 'tests/fixtures/expected_sort_by_msgid_sorted.pot'; 27 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 28 | babel.transformFileSync(path.join(process.cwd(), inputFile2), options); 29 | const result = fs.readFileSync(output).toString(); 30 | const expected = fs.readFileSync(expectedPath).toString(); 31 | expect(result).to.eql(expected); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/functional/test_extract_gettext_with_formatting.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import c3poPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | const output = 'debug/translations.pot'; 8 | const options = { 9 | presets: ['@babel/preset-env'], 10 | plugins: [[c3poPlugin, { extract: { output }, discover: ['t'] }]], 11 | }; 12 | 13 | describe('Extract tag-gettext', () => { 14 | before(() => { 15 | rmDirSync('debug'); 16 | }); 17 | 18 | it('should extract gettext literal with formatting', () => { 19 | const expectedPath = 'tests/fixtures/expected_gettext_literal_with_formatting.pot'; 20 | const input = 'console.log(t`literal with formatting ${a}`);'; 21 | babel.transform(input, options); 22 | const result = fs.readFileSync(output).toString(); 23 | const expected = fs.readFileSync(expectedPath).toString(); 24 | expect(result).to.eql(expected); 25 | }); 26 | 27 | it('should throw if has invalid expressions', () => { 28 | const input = 't`banana ${ n + 1}`'; 29 | const fn = () => babel.transform(input, options).code; 30 | expect(fn).to.throw('You can not use BinaryExpression \'${n + 1}\' in localized strings'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_ngettext_default_for_locale.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | 5 | const translations = 'tests/fixtures/ua.po'; 6 | 7 | const options = { 8 | plugins: [[c3poPlugin, { 9 | resolve: { translations }, 10 | discover: ['ngettext'], 11 | }]], 12 | }; 13 | 14 | describe('Resolve ngettext default for locale', () => { 15 | it('should resolve original strings if no translator notes', () => { 16 | const input = 'console.log(ngettext(msgid`no translation plural`, `no translation plurals`, n));'; 17 | const result = babel.transform(input, options).code; 18 | expect(result).to.contain('_tag_ngettext(n, [`no translation plural`, `no translation plurals`,' 19 | + ' `no translation plurals`])'); 20 | }); 21 | 22 | it('should resolve original strings with expressions if no translator notes', () => { 23 | const input = 'console.log(ngettext(msgid`no translation plural ${n}`, `no translation plurals ${n}`, n));'; 24 | const result = babel.transform(input, options).code; 25 | expect(result).to.contain( 26 | '_tag_ngettext(n, [`no translation plural ${n}`, `no translation plurals ${n}`,' 27 | + ' `no translation plurals ${n}`])', 28 | ); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/extractors/jsxtag-gettext.js: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import gettext from './tag-gettext'; 3 | 4 | const NAME = 'jsxtag-gettext'; 5 | 6 | function match(node, context) { 7 | return t.isTaggedTemplateExpression(node) && context.hasAliasForFunc(NAME, node.tag.name); 8 | } 9 | 10 | function templateLiteral2Array({ quasis, expressions }) { 11 | const items = []; 12 | 13 | quasis.forEach((quasi, i) => { 14 | if (quasi.value.cooked !== '') { 15 | items.push(t.stringLiteral(quasi.value.cooked)); 16 | } 17 | if (expressions[i]) { 18 | items.push(expressions[i]); 19 | } 20 | }); 21 | 22 | return items; 23 | } 24 | 25 | function resolveDefault(node, context) { 26 | const resolved = gettext.resolveDefault(node, context); 27 | if (t.isTemplateLiteral(resolved)) { 28 | return t.arrayExpression(templateLiteral2Array(resolved)); 29 | } 30 | return t.arrayExpression([resolved]); 31 | } 32 | 33 | function resolve(node, translation, context) { 34 | const resolved = gettext.resolve(node, translation, context); 35 | if (t.isTemplateLiteral(resolved)) { 36 | return t.arrayExpression(templateLiteral2Array(resolved)); 37 | } 38 | return t.arrayExpression([resolved]); 39 | } 40 | 41 | export default { 42 | ...gettext, 43 | resolve, 44 | resolveDefault, 45 | match, 46 | name: NAME, 47 | }; 48 | -------------------------------------------------------------------------------- /tests/functional/test_alias_discover.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | plugins: [[c3poPlugin, { 10 | resolve: { translations }, 11 | discover: ['gettext'], 12 | }]], 13 | }; 14 | 15 | describe('Alias discover', () => { 16 | before(() => { 17 | rmDirSync('debug'); 18 | }); 19 | 20 | it('should not translate without import', () => { 21 | const input = ` 22 | console.log(t\`simple string literal\`); 23 | `; 24 | const result = babel.transform(input, options).code; 25 | expect(result).to.not.contain('simple string literal translated'); 26 | }); 27 | it('should translate with import', () => { 28 | const input = ` 29 | import { t } from 'ttag'; 30 | console.log(t\`simple string literal\`); 31 | `; 32 | const result = babel.transform(input, options).code; 33 | expect(result).to.contain('simple string literal translated'); 34 | }); 35 | it('should translate with discover', () => { 36 | const input = 'console.log(gettext(\'simple string literal\'));'; 37 | const result = babel.transform(input, options).code; 38 | expect(result).to.contain('simple string literal translated'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_HEADERS = { 2 | 'content-type': 'text/plain; charset=UTF-8', 3 | 'plural-forms': 'nplurals=2; plural=(n!=1);', 4 | }; 5 | 6 | // TODO: setup default aliases from extractors 7 | export const FUNC_TO_ALIAS_MAP = { 8 | 'tag-gettext': 't', 9 | 'jsxtag-gettext': 'jt', 10 | gettext: ['gettext', '_'], 11 | ngettext: 'ngettext', 12 | msgid: 'msgid', 13 | context: 'c', 14 | }; 15 | 16 | export const ALIAS_TO_FUNC_MAP = Object.keys(FUNC_TO_ALIAS_MAP).reduce((obj, key) => { 17 | const value = FUNC_TO_ALIAS_MAP[key]; 18 | if (Array.isArray(value)) { 19 | value.forEach((alias) => { 20 | obj[alias] = key; 21 | }); 22 | } else { 23 | obj[value] = key; 24 | } 25 | return obj; 26 | }, {}); 27 | 28 | export const PO_PRIMITIVES = { 29 | MSGSTR: 'msgstr', 30 | MSGID: 'msgid', 31 | MSGCTXT: 'msgctxt', 32 | MSGID_PLURAL: 'msgid_plural', 33 | }; 34 | 35 | export const UNRESOLVED_ACTION = { 36 | FAIL: 'fail', 37 | WARN: 'warn', 38 | SKIP: 'skip', 39 | }; 40 | 41 | export const DISABLE_COMMENT = 'disable ttag'; 42 | 43 | export const TTAGID = 'ttag'; 44 | export const TTAG_MACRO_ID = 'ttag.macro'; 45 | export const INTERNAL_TTAG_MACRO_ID = 'babel-plugin-ttag/dist/ttag.macro'; 46 | 47 | export const DEFAULT_POT_OUTPUT = 'polyglot_result.pot'; 48 | 49 | export const LOCATION = { 50 | FULL: 'full', 51 | FILE: 'file', 52 | NEVER: 'never', 53 | }; 54 | -------------------------------------------------------------------------------- /tests/functional/test_extract_js_format.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttagPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | describe('javascript-format extract', () => { 8 | before(() => { 9 | rmDirSync('debug'); 10 | }); 11 | 12 | it('should add js format', () => { 13 | const output = 'debug/translations1.pot'; 14 | const options = { 15 | plugins: [[ttagPlugin, { extract: { output } }]], 16 | }; 17 | const input = ` 18 | import { t } from "ttag"; 19 | t\`use js formatting test \${a}\` 20 | `; 21 | babel.transform(input, options); 22 | const result = fs.readFileSync(output).toString(); 23 | expect(result).to.contain('#, javascript-format\nmsgid "use js formatting test ${ a }"'); 24 | }); 25 | it('should not add js format', () => { 26 | const output = 'debug/translations2.pot'; 27 | const options = { 28 | plugins: [[ttagPlugin, { extract: { output } }]], 29 | }; 30 | const input = ` 31 | import { t } from "ttag"; 32 | t\`don't use js formatting test\` 33 | `; 34 | babel.transform(input, options); 35 | const result = fs.readFileSync(output).toString(); 36 | expect(result).to.not.contain('#, javascript-format\nmsgid "don\'t use js formatting test"'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/functional/test_macro_resolve.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { expect } from 'chai'; 3 | import * as babel from '@babel/core'; 4 | import dedent from 'dedent'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | const options = { 8 | plugins: ['macros'], 9 | filename: __filename, 10 | }; 11 | 12 | const macroConfig = `{ 13 | "ttag": { 14 | "resolve": { 15 | "translations": "tests/fixtures/resolve_simple_gettext.po" 16 | } 17 | } 18 | }`; 19 | 20 | describe('Macro resolve', () => { 21 | before(() => { 22 | fs.writeFileSync('.babel-plugin-macrosrc', macroConfig); 23 | }); 24 | 25 | after(() => { 26 | rmDirSync('.babel-plugin-macrosrc'); 27 | }); 28 | 29 | it('should resolve translation', () => { 30 | const input = dedent(` 31 | import { t } from "../../src/ttag.macro"; 32 | console.log(t\`simple string literal\`); 33 | `); 34 | const babelResult = babel.transform(input, options); 35 | expect(babelResult.code).to.contain('"simple string literal translated"'); 36 | }); 37 | 38 | it('should throw if meet unrecognized import', () => { 39 | const input = dedent(` 40 | import { tt } from "../../src/ttag.macro"; 41 | console.log(tt\`simple string literal\`); 42 | `); 43 | const fn = () => babel.transform(input, options); 44 | expect(fn).to.throw('Invalid import: tt'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/extractors/gettext.js: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import { ast2Str } from '../utils'; 3 | import { ValidationError } from '../errors'; 4 | import { PO_PRIMITIVES } from '../defaults'; 5 | import { hasUsefulInfo } from '../po-helpers'; 6 | 7 | const { MSGSTR } = PO_PRIMITIVES; 8 | const NAME = 'gettext'; 9 | 10 | function getMsgid(node) { 11 | return node.arguments[0].value; 12 | } 13 | 14 | const validate = (node) => { 15 | const arg = node.arguments[0]; 16 | if (!t.isLiteral(arg)) { 17 | throw new ValidationError(`You can not use ${arg.type} '${ast2Str(arg)}' as an argument to gettext`); 18 | } 19 | if (arg.type === 'TemplateLiteral') { 20 | throw new ValidationError('You can not use template literal as an argument to gettext'); 21 | } 22 | if (!hasUsefulInfo(arg.value)) { 23 | throw new ValidationError(`Can not translate '${arg.value}'`); 24 | } 25 | }; 26 | 27 | function match(node, context) { 28 | return (t.isCallExpression(node) 29 | && t.isIdentifier(node.callee) 30 | && context.hasAliasForFunc(NAME, node.callee.name) 31 | && node.arguments.length > 0); 32 | } 33 | 34 | function resolveDefault(node) { 35 | return node.arguments[0]; 36 | } 37 | 38 | function resolve(node, translation) { 39 | const transStr = translation[MSGSTR][0]; 40 | return t.stringLiteral(transStr); 41 | } 42 | 43 | export default { 44 | match, resolve, resolveDefault, validate, name: NAME, getMsgid, 45 | }; 46 | -------------------------------------------------------------------------------- /tests/functional/test_sorted_entries_without_reference_line_num.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { expect } from 'chai'; 3 | import * as babel from '@babel/core'; 4 | import fs from 'fs'; 5 | import c3poPlugin from 'src/plugin'; 6 | import { rmDirSync } from 'src/utils'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | presets: ['@babel/preset-env'], 11 | plugins: [[c3poPlugin, { 12 | extract: { 13 | output, 14 | location: 'file', 15 | }, 16 | discover: ['t'], 17 | sortByMsgid: true, 18 | }]], 19 | }; 20 | 21 | describe('Sorting entries by msgid (with file location, but without line number)', () => { 22 | beforeEach(() => { 23 | rmDirSync('debug'); 24 | }); 25 | 26 | it('should sort message identifiers and file location comments', () => { 27 | const inputFile = 'tests/fixtures/sort_by_msgid_input.js'; 28 | const inputFile2 = 'tests/fixtures/sort_by_msgid_input2.js'; 29 | const expectedPath = 'tests/fixtures/expected_sort_by_msgid_withou_reference_line_num.pot'; 30 | // here we use reverse order of files, expected that references will be sorted 31 | babel.transformFileSync(path.join(process.cwd(), inputFile2), options); 32 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 33 | const result = fs.readFileSync(output).toString(); 34 | const expected = fs.readFileSync(expectedPath).toString(); 35 | expect(result).to.eql(expected); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/functional/test_unresolved.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import ttagPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | presets: ['@babel/preset-env'], 10 | plugins: [[ttagPlugin, { 11 | resolve: { translations, unresolved: 'fail' }, 12 | discover: ['t', 'ngettext'], 13 | }]], 14 | }; 15 | 16 | const optionsResolveDefault = { 17 | plugins: [[ttagPlugin, { 18 | resolve: { translations: 'default', unresolved: 'fail' }, 19 | discover: ['t'], 20 | }]], 21 | }; 22 | 23 | describe('Unresolved', () => { 24 | before(() => { 25 | rmDirSync('debug'); 26 | }); 27 | 28 | it('should throw for tag-gettext', () => { 29 | const input = 'console.log(t`random string`);'; 30 | const fun = () => babel.transform(input, options).code; 31 | expect(fun).to.throw('No "random string" in "tests/fixtures/resolve_simple_gettext.po" file'); 32 | }); 33 | it('should throw for tag-ngettext', () => { 34 | const input = 'console.log(ngettext(msgid`random string`, `random string`, n));'; 35 | const fun = () => babel.transform(input, options).code; 36 | expect(fun).to.throw('No "random string" in "tests/fixtures/resolve_simple_gettext.po" file'); 37 | }); 38 | it('should not fail if resolve: "default"', () => { 39 | const input = 'console.log(t`some random string`);'; 40 | const { code } = babel.transform(input, optionsResolveDefault); 41 | expect(code).to.equal('console.log("some random string");'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_gettext_default.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const options = { 7 | presets: ['@babel/preset-env'], 8 | plugins: [[c3poPlugin, { 9 | discover: ['t', 'c'], 10 | resolve: { translations: 'default' }, 11 | }]], 12 | }; 13 | 14 | describe('Resolve tag-gettext default', () => { 15 | before(() => { 16 | rmDirSync('debug'); 17 | }); 18 | it('should not resolve if no extractors match (without expressions)', () => { 19 | const input = 'console.log(t`no translator notes`);'; 20 | const result = babel.transform(input, options).code; 21 | expect(result).to.contain('console.log("no translator notes");'); 22 | }); 23 | 24 | it('should not resolve if no extractors match (without expressions)', () => { 25 | const input = 'console.log(t`simple string literal without translation ${a}`);'; 26 | const result = babel.transform(input, options).code; 27 | expect(result).to.contain('console.log("simple string literal without translation ".concat(a));'); 28 | }); 29 | 30 | it('should not strip indent if has no \\n', () => { 31 | const input = 'console.log(t` www`);'; 32 | const result = babel.transform(input, options).code; 33 | expect(result).to.contain('console.log(" www");'); 34 | }); 35 | 36 | it('should not resolve with context', () => { 37 | const input = 'console.log(c("foo").t`www`);'; 38 | const result = babel.transform(input, options).code; 39 | expect(result).to.contain('console.log("www");'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/functional/test_alias_extract.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttag from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | 10 | const options = { 11 | plugins: [[ttag, { extract: { output } }]], 12 | }; 13 | 14 | describe('Contexts extract', () => { 15 | before(() => { 16 | rmDirSync('debug'); 17 | }); 18 | 19 | it('should extract by alias from import', () => { 20 | const input = dedent(` 21 | import { t as alias } from 'ttag'; 22 | alias\`alias extract test\` 23 | `); 24 | babel.transform(input, options); 25 | const result = fs.readFileSync(output).toString(); 26 | expect(result).to.contain('alias extract test'); 27 | }); 28 | 29 | it('should extract by alias from require', () => { 30 | const input = dedent(` 31 | const { t: alias } = require('ttag'); 32 | alias\`alias extract test\` 33 | `); 34 | babel.transform(input, options); 35 | const result = fs.readFileSync(output).toString(); 36 | expect(result).to.contain('alias extract test'); 37 | }); 38 | 39 | it('should extract with multiple aliases for the same func', () => { 40 | const input = dedent(` 41 | import { t as alias } from 'ttag'; 42 | import { t as alias2 } from 'ttag'; 43 | alias\`alias1 extract test\` 44 | alias2\`alias2 extract test\` 45 | `); 46 | babel.transform(input, options); 47 | const result = fs.readFileSync(output).toString(); 48 | expect(result).to.contain('alias1 extract test'); 49 | expect(result).to.contain('alias2 extract test'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/functional/test_alias_resolve.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | plugins: [[c3poPlugin, { 10 | resolve: { translations }, 11 | }]], 12 | }; 13 | 14 | describe('Alias resolve', () => { 15 | before(() => { 16 | rmDirSync('debug'); 17 | }); 18 | 19 | it('should be able to create alias on import for tag-gettext', () => { 20 | const input = ` 21 | import { t as i18n } from 'ttag'; 22 | console.log(i18n\`simple string literal\`); 23 | `; 24 | const result = babel.transform(input, options).code; 25 | expect(result).to.contain('simple string literal translated'); 26 | }); 27 | 28 | it('should be able to create alias on import for tag-ngettext', () => { 29 | const input = ` 30 | import { ngettext as ungettext } from 'ttag'; 31 | console.log(ungettext(msgid\`plural form with \${ n } plural\`, \`plural form with \${ n } plural\`, n)); 32 | `; 33 | const result = babel.transform(input, options).code; 34 | expect(result).to.contain( 35 | '_tag_ngettext(n, [`plural form with ${n} plural [translated]`, ' 36 | + '`plural form with ${n} plurals [translated]`])', 37 | ); 38 | }); 39 | 40 | it('should be able to create alias on import for gettext', () => { 41 | const input = ` 42 | import { gettext as i18n } from 'ttag'; 43 | console.log(i18n('simple string literal')); 44 | `; 45 | const result = babel.transform(input, options).code; 46 | expect(result).to.contain('simple string literal translated'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_default.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttagPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 8 | 9 | const options = { 10 | plugins: [[ttagPlugin, { resolve: { translations } }]], 11 | }; 12 | 13 | describe('Resolve default', () => { 14 | before(() => { 15 | rmDirSync('debug'); 16 | }); 17 | it('should not resolve if no extractors match', () => { 18 | const expectedPath = 'tests/fixtures/expected_resolve_if_no_extractor_match.js.src'; 19 | const input = 'import { t } from "ttag";\n' 20 | + 'console.log(gtt`simple string literal ${a}`);'; 21 | const result = babel.transform(input, options).code; 22 | const expected = fs.readFileSync(expectedPath).toString(); 23 | expect(result).to.eql(expected); 24 | }); 25 | 26 | it('should not resolve fuzzy translations', () => { 27 | const input = 'import { t } from "ttag";console.log(t`{name} fuzzy name`);'; 28 | const result = babel.transform(input, options).code; 29 | expect(result).to.not.contain('{surname} fuzzy name'); 30 | expect(result).to.contain('{name} fuzzy name'); 31 | }); 32 | 33 | it('should resolve fuzzy translations when allowed', () => { 34 | const fuzzyOptions = { 35 | plugins: [[ttagPlugin, { resolve: { translations }, allowFuzzy: true }]], 36 | }; 37 | 38 | const input = 'import { t } from "ttag";console.log(t`{name} fuzzy name`);'; 39 | const result = babel.transform(input, fuzzyOptions).code; 40 | expect(result).to.contain('{surname} fuzzy name'); 41 | expect(result).to.not.contain('{name} fuzzy name'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babel-plugin-ttag", 3 | "version": "1.8.16", 4 | "description": "", 5 | "main": "dist/plugin.js", 6 | "keywords": [ 7 | "gettext", 8 | "translations", 9 | "babel-plugin", 10 | "i18n" 11 | ], 12 | "scripts": { 13 | "test": "make test", 14 | "lint": "eslint ./src ./tests", 15 | "fix": "eslint --fix ./src ./tests", 16 | "build": "babel src --out-dir dist", 17 | "preversion": "npm run lint && npm run build && make test", 18 | "prepublish": "npm run build" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/ttag-org/babel-plugin-ttag.git" 23 | }, 24 | "author": "alexander", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/ttag-org/babel-plugin-ttag/issues" 28 | }, 29 | "homepage": "https://github.com/ttag-org/babel-plugin-ttag#readme", 30 | "dependencies": { 31 | "@babel/generator": "^7.12.5", 32 | "@babel/template": "^7.10.4", 33 | "@babel/types": "^7.12.6", 34 | "ajv": "6.12.3", 35 | "babel-plugin-macros": "^3.1.0", 36 | "dedent": "1.5.1", 37 | "gettext-parser": "6.0.0", 38 | "mkdirp": "^1.0.4", 39 | "plural-forms": "^0.5.3" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "7.22.6", 43 | "@babel/core": "7.22.8", 44 | "@babel/plugin-proposal-class-properties": "^7.12.1", 45 | "@babel/preset-env": "7.22.7", 46 | "@babel/preset-react": "^7.12.5", 47 | "@babel/register": "7.22.5", 48 | "babel-eslint": "^10.1.0", 49 | "chai": "3.5.0", 50 | "eslint": "^7.2.0", 51 | "eslint-config-airbnb": "^18.2.1", 52 | "eslint-plugin-import": "^2.22.1", 53 | "eslint-plugin-jsx-a11y": "^6.4.1", 54 | "eslint-plugin-react": "^7.21.5", 55 | "eslint-plugin-react-hooks": "^4.0.0", 56 | "istanbul": "1.1.0-alpha.1", 57 | "mocha": "^7.2.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/extract.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { applyReference, applyExtractedComments, applyFormat } from './po-helpers'; 3 | import { dedentStr } from './utils'; 4 | 5 | import { PO_PRIMITIVES } from './defaults'; 6 | 7 | const { MSGID, MSGSTR, MSGCTXT } = PO_PRIMITIVES; 8 | 9 | function defaultExtract(msgid) { 10 | return { 11 | [MSGID]: msgid, 12 | [MSGSTR]: '', 13 | }; 14 | } 15 | 16 | export function getExtractor(nodePath, context) { 17 | const extractors = context.getExtractors(); 18 | return extractors.find((ext) => ext.match(nodePath.node, context)); 19 | } 20 | 21 | export const extractPoEntry = (extractor, nodePath, context, state) => { 22 | const { node } = nodePath; 23 | const { filename } = state.file.opts; 24 | let poEntry; 25 | 26 | if (extractor.extract) { 27 | poEntry = extractor.extract(nodePath.node, context); 28 | } else { 29 | const msgid = context.isDedent() 30 | ? dedentStr(extractor.getMsgid(nodePath.node, context)) 31 | : extractor.getMsgid(nodePath.node, context); 32 | poEntry = defaultExtract(msgid); 33 | } 34 | 35 | if (nodePath._C3PO_GETTEXT_CONTEXT) { 36 | poEntry[MSGCTXT] = nodePath._C3PO_GETTEXT_CONTEXT; 37 | } 38 | 39 | const location = context.getLocation(); 40 | 41 | if (filename && filename !== 'unknown') { 42 | const base = `${process.cwd()}${path.sep}`; 43 | applyReference(poEntry, node, filename.replace(base, ''), location); 44 | } 45 | 46 | if (context.devCommentsEnabled()) { 47 | const maybeTag = context.getAddComments(); 48 | let tag = null; 49 | if (typeof maybeTag === 'string') { 50 | tag = maybeTag; 51 | } 52 | applyExtractedComments(poEntry, nodePath, tag); 53 | } 54 | applyFormat(poEntry); 55 | return poEntry; 56 | }; 57 | -------------------------------------------------------------------------------- /src/extractors/tag-gettext.js: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import tpl from '@babel/template'; 3 | import { 4 | template2Msgid, validateAndFormatMsgid, 5 | hasExpressions, ast2Str, getQuasiStr, strToQuasi, 6 | } from '../utils'; 7 | import { PO_PRIMITIVES } from '../defaults'; 8 | import { ValidationError } from '../errors'; 9 | import { hasUsefulInfo } from '../po-helpers'; 10 | 11 | const { MSGSTR } = PO_PRIMITIVES; 12 | const NAME = 'tag-gettext'; 13 | 14 | const validate = (node, context) => { 15 | const msgid = template2Msgid(node, context); 16 | if (!hasUsefulInfo(msgid)) { 17 | throw new ValidationError(`Can not translate '${getQuasiStr(node)}'`); 18 | } 19 | }; 20 | 21 | function match(node, context) { 22 | return t.isTaggedTemplateExpression(node) && context.hasAliasForFunc(NAME, node.tag.name); 23 | } 24 | 25 | function resolveDefault(node) { 26 | const transStr = getQuasiStr(node); 27 | if (hasExpressions(node)) { 28 | return node.quasi; 29 | } 30 | return t.stringLiteral(transStr); 31 | } 32 | 33 | function resolve(node, translation, context) { 34 | const transStr = translation[MSGSTR][0]; 35 | 36 | if (hasExpressions(node)) { 37 | const transExpr = tpl.ast(strToQuasi(transStr)); 38 | if (context.isNumberedExpressions()) { 39 | const exprs = transExpr.expression.expressions 40 | .map(({ value }) => value) 41 | .map((i) => node.quasi.expressions[i]); 42 | return t.templateLiteral(transExpr.expression.quasis, exprs); 43 | } 44 | const exprs = node.quasi.expressions.map(ast2Str); 45 | return tpl.ast(validateAndFormatMsgid(transStr, exprs)).expression; 46 | } 47 | return t.stringLiteral(transStr); 48 | } 49 | 50 | export default { 51 | match, resolve, resolveDefault, validate, name: NAME, getMsgid: template2Msgid, 52 | }; 53 | -------------------------------------------------------------------------------- /tests/functional/test_extract_numbered_expressions.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import c3poPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | const output = 'debug/translations.pot'; 8 | const options = { 9 | presets: ['@babel/preset-react'], 10 | plugins: [[c3poPlugin, { 11 | extract: { output }, 12 | numberedExpressions: true, 13 | }]], 14 | }; 15 | 16 | describe('Numbered expressions extract', () => { 17 | before(() => { 18 | rmDirSync('debug'); 19 | }); 20 | 21 | it('should extract from t tag', () => { 22 | const input = ` 23 | import { t } from 'ttag'; 24 | console.log(t\`Hello \${ fn() } \${ fn2() }\`); 25 | `; 26 | babel.transform(input, options); 27 | const result = fs.readFileSync(output).toString(); 28 | expect(result).to.contain('Hello ${ 0 } ${ 1 }'); 29 | }); 30 | 31 | it('should extract from ngettext func', () => { 32 | const input = ` 33 | import { ngettext, msgid } from 'ttag'; 34 | ngettext(msgid\`\${ fn() } banana\`, \`\${ fn2() }\ bananas\`, fn()); 35 | `; 36 | babel.transform(input, options); 37 | const result = fs.readFileSync(output).toString(); 38 | expect(result).to.contain('${ 0 } banana'); 39 | expect(result).to.contain('${ 0 } bananas'); 40 | }); 41 | it('should extract from jt tag', () => { 42 | const input = ` 43 | import { jt } from 'ttag'; 44 | import React from 'react'; 45 | const component = () => { 46 | return
{ jt\`react comp - \${fn()}\` }
47 | } 48 | `; 49 | babel.transform(input, options); 50 | const result = fs.readFileSync(output).toString(); 51 | expect(result).to.contain('react comp - ${ 0 }'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/unit/test_tag_gettext_extractor.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import gettext from 'src/extractors/tag-gettext'; 3 | import template from '@babel/template'; 4 | import Context from 'src/context'; 5 | 6 | const enConfig = new Context(); 7 | 8 | describe('tag-gettext validate', () => { 9 | it('should not throw if numeric literal', () => { 10 | const node = template('t`banana ${ 1 }`')().expression; 11 | const fn = () => gettext.validate(node, enConfig); 12 | expect(fn).to.not.throw(); 13 | }); 14 | 15 | it('should not throw if member expression literal', () => { 16 | const node = template('t`banana ${ this.props.number }`')().expression; 17 | const fn = () => gettext.validate(node, enConfig); 18 | expect(fn).to.not.throw(); 19 | }); 20 | 21 | it('should throw if has invalid expressions', () => { 22 | const node = template('t`banana ${ n + 1}`')().expression; 23 | const fn = () => gettext.validate(node, enConfig); 24 | expect(fn).to.throw('You can not use BinaryExpression \'${n + 1}\' in localized strings'); 25 | }); 26 | 27 | it('should throw if translation string is an empty string', () => { 28 | const node = template('t``')().expression; 29 | const fn = () => gettext.validate(node, enConfig); 30 | expect(fn).to.throw('Can not translate \'\''); 31 | }); 32 | 33 | it('should throw if has no meaningful information', () => { 34 | const node = template('t`${user} ${name}`')().expression; 35 | const fn = () => gettext.validate(node, enConfig); 36 | expect(fn).to.throw('Can not translate \'${user} ${name}\''); 37 | }); 38 | }); 39 | 40 | describe('tag-gettext match', () => { 41 | it('should match gettext', () => { 42 | const node = template('t`${n} banana`')().expression; 43 | const result = gettext.match(node, enConfig); 44 | expect(result).to.be.true; 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/unit/test_gettext_extractor.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import gettext from 'src/extractors/gettext'; 3 | import template from '@babel/template'; 4 | import Context from 'src/context'; 5 | 6 | const enConfig = new Context(); 7 | 8 | describe('gettext validate', () => { 9 | it('should throw if has invalid argument', () => { 10 | const node = template('gettext(fn())')().expression; 11 | const fn = () => gettext.validate(node, enConfig); 12 | expect(fn).to.throw('You can not use CallExpression \'fn()\' as an argument to gettext'); 13 | }); 14 | 15 | it('should throw validation if has empty string argument', () => { 16 | const node = template('gettext("")')().expression; 17 | const fn = () => gettext.validate(node, enConfig); 18 | expect(fn).to.throw('Can not translate \'\''); 19 | }); 20 | 21 | it('should throw validation if has no meaningful information', () => { 22 | const node = template('gettext(" 2")')().expression; 23 | const fn = () => gettext.validate(node, enConfig); 24 | expect(fn).to.throw('Can not translate \' 2\''); 25 | }); 26 | 27 | it('should throw if has template argument', () => { 28 | const node = template('gettext(`www`)')().expression; 29 | const fn = () => gettext.validate(node, enConfig); 30 | expect(fn).to.throw('You can not use template literal as an argument to gettext'); 31 | }); 32 | }); 33 | 34 | describe('gettext match', () => { 35 | it('should match gettext', () => { 36 | const node = template('gettext("banana")')().expression; 37 | const result = gettext.match(node, enConfig); 38 | expect(result).to.be.true; 39 | }); 40 | 41 | it('should match gettext with zero arguments', () => { 42 | const node = template('gettext()')().expression; 43 | const result = gettext.match(node, enConfig); 44 | expect(result).to.be.false; 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/resolve.js: -------------------------------------------------------------------------------- 1 | import { NoTranslationError } from './errors'; 2 | import { dedentStr } from './utils'; 3 | import { hasTranslations, isFuzzy } from './po-helpers'; 4 | 5 | function replaceNode(nodePath, resultNode) { 6 | if (resultNode !== undefined) { 7 | if (nodePath._C3PO_GETTEXT_CONTEXT) { 8 | nodePath.node = nodePath._ORIGINAL_NODE; 9 | } 10 | nodePath.replaceWith(resultNode); 11 | } 12 | } 13 | 14 | export function resolveEntries(extractor, nodePath, context, state) { 15 | try { 16 | const gettextContext = nodePath._C3PO_GETTEXT_CONTEXT || ''; 17 | const translations = context.getTranslations(gettextContext); 18 | const msgid = context.isDedent() ? dedentStr(extractor.getMsgid(nodePath.node, context)) 19 | : extractor.getMsgid(nodePath.node, context); 20 | const translationObj = translations[msgid]; 21 | if (!translationObj) { 22 | throw new NoTranslationError(`No "${msgid}" in "${context.getPoFilePath()}" file`); 23 | } 24 | 25 | if (!hasTranslations(translationObj)) { 26 | throw new NoTranslationError(`No translation for "${msgid}" in "${context.getPoFilePath()}" file`); 27 | } 28 | 29 | if (!context.isAllowFuzzy() && isFuzzy(translationObj)) { 30 | throw new NoTranslationError(`Fuzzy translation for "${msgid}" in "${context.getPoFilePath()}" file`); 31 | } 32 | 33 | const resultNode = extractor.resolve(nodePath.node, translationObj, context, state); 34 | replaceNode(nodePath, resultNode); 35 | } catch (err) { 36 | if (err instanceof NoTranslationError) { 37 | context.noTranslationAction(err.message); 38 | if (extractor.resolveDefault) { 39 | const resultNode = extractor.resolveDefault(nodePath.node, context, state); 40 | replaceNode(nodePath, resultNode); 41 | } 42 | return; 43 | } 44 | throw err; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { getAvailLangs } from 'plural-forms'; 3 | import { UNRESOLVED_ACTION, LOCATION } from './defaults'; 4 | 5 | const { FAIL, WARN, SKIP } = UNRESOLVED_ACTION; 6 | const { FULL, FILE, NEVER } = LOCATION; 7 | 8 | const extractConfigSchema = { 9 | type: ['object', 'null'], 10 | properties: { 11 | output: { type: 'string' }, 12 | location: { enum: [FULL, FILE, NEVER] }, 13 | }, 14 | required: ['output'], 15 | additionalProperties: false, 16 | }; 17 | 18 | const resolveConfigSchema = { 19 | type: ['object', 'null'], 20 | properties: { 21 | translations: { type: 'string' }, 22 | unresolved: { enum: [FAIL, WARN, SKIP] }, 23 | }, 24 | required: ['translations'], 25 | additionalProperties: false, 26 | }; 27 | 28 | const extractorsSchema = { 29 | type: 'object', 30 | additionalProperties: { 31 | type: 'object', 32 | properties: { 33 | invalidFormat: { enum: [FAIL, WARN, SKIP] }, 34 | }, 35 | additionalProperties: false, 36 | }, 37 | }; 38 | 39 | export const configSchema = { 40 | type: 'object', 41 | properties: { 42 | extract: extractConfigSchema, 43 | resolve: resolveConfigSchema, 44 | extractors: extractorsSchema, 45 | dedent: { type: 'boolean' }, 46 | discover: { type: 'array' }, 47 | defaultLang: { enum: getAvailLangs() }, 48 | addComments: { oneOf: [{ type: 'boolean' }, { type: 'string' }] }, 49 | sortByMsgid: { type: 'boolean' }, 50 | sortByMsgctxt: { type: 'boolean' }, 51 | numberedExpressions: { type: 'boolean' }, 52 | allowFuzzy: { type: 'boolean' }, 53 | }, 54 | additionalProperties: false, 55 | }; 56 | 57 | export function validateConfig(config, schema) { 58 | const ajv = new Ajv({ allErrors: true, verbose: true, v5: true }); 59 | const isValid = ajv.validate(schema, config); 60 | return [isValid, ajv.errorsText(), ajv.errors]; 61 | } 62 | -------------------------------------------------------------------------------- /src/gettext-context.js: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import { ast2Str } from './utils'; 3 | 4 | const NAME = 'context'; 5 | 6 | export function isContextTagCall(node, context) { 7 | return ( 8 | t.isTaggedTemplateExpression(node) 9 | && t.isMemberExpression(node.tag) 10 | && t.isCallExpression(node.tag.object) 11 | && t.isIdentifier(node.tag.object.callee) 12 | && context.hasAliasForFunc(NAME, node.tag.object.callee.name)); 13 | } 14 | 15 | export function isContextFnCall(node, context) { 16 | return ( 17 | t.isCallExpression(node) 18 | && t.isMemberExpression(node.callee) 19 | && t.isCallExpression(node.callee.object) 20 | && t.isIdentifier(node.callee.object.callee) 21 | && context.hasAliasForFunc(NAME, node.callee.object.callee.name) 22 | ); 23 | } 24 | 25 | export function isValidFnCallContext(nodePath) { 26 | const { node } = nodePath; 27 | const argsLength = node.callee.object.arguments.length; 28 | 29 | if (argsLength !== 1) { 30 | throw nodePath.buildCodeFrameError(`Context function accepts only 1 argument but has ${argsLength} instead.`); 31 | } 32 | 33 | const contextStr = node.callee.object.arguments[0]; 34 | 35 | if (!t.isLiteral(contextStr)) { 36 | throw nodePath.buildCodeFrameError(`Expected string as a context argument. Actual - "${ast2Str(contextStr)}".`); 37 | } 38 | 39 | return true; 40 | } 41 | 42 | export function isValidTagContext(nodePath) { 43 | const { node } = nodePath; 44 | const argsLength = node.tag.object.arguments.length; 45 | 46 | if (argsLength !== 1) { 47 | throw nodePath.buildCodeFrameError(`Context function accepts only 1 argument but has ${argsLength} instead.`); 48 | } 49 | 50 | const contextStr = node.tag.object.arguments[0]; 51 | 52 | if (!t.isLiteral(contextStr)) { 53 | throw nodePath.buildCodeFrameError(`Expected string as a context argument. Actual - "${ast2Str(contextStr)}".`); 54 | } 55 | 56 | return true; 57 | } 58 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_fn_gettext.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import ttagPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | plugins: [[ttagPlugin, { 10 | resolve: { translations }, 11 | discover: ['gettext', '_'], 12 | }]], 13 | }; 14 | 15 | describe('Resolve gettext', () => { 16 | before(() => { 17 | rmDirSync('debug'); 18 | }); 19 | 20 | it('should resolve gettext fn', () => { 21 | const input = 'console.log(gettext("simple string literal"));'; 22 | const result = babel.transform(input, options).code; 23 | expect(result).to.contain('console.log("simple string literal translated");'); 24 | }); 25 | 26 | it('should resolve _ fn', () => { 27 | const input = 'console.log(_("simple string literal"));'; 28 | const result = babel.transform(input, options).code; 29 | expect(result).to.contain('console.log("simple string literal translated");'); 30 | }); 31 | 32 | it('should resolve original string if no translation is found', () => { 33 | const input = 'console.log(gettext("simple string literal without translation"));'; 34 | const result = babel.transform(input, options).code; 35 | expect(result).to.contain('console.log("simple string literal without translation");'); 36 | }); 37 | 38 | it('should resolve original string if no translator notes', () => { 39 | const input = 'console.log(gettext("no translator notes"));'; 40 | const result = babel.transform(input, options).code; 41 | expect(result).to.contain('console.log("no translator notes");'); 42 | }); 43 | 44 | it('should throw if has invalid expressions', () => { 45 | const input = 'console.log(gettext(fn()));'; 46 | const func = () => babel.transform(input, options).code; 47 | expect(func).to.throw('You can not use CallExpression \'fn()\' as an argument to gettext'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/functional/test_extract_gettext_simple.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | import ttagPlugin from 'src/plugin'; 6 | import { rmDirSync } from 'src/utils'; 7 | 8 | describe('Extract tag-gettext', () => { 9 | beforeEach(() => { 10 | rmDirSync('debug'); 11 | }); 12 | 13 | it('should extract simple gettext literal (without formatting)', () => { 14 | const output = 'debug/translations.pot'; 15 | const expectedPath = 'tests/fixtures/expected_gettext_simple_literal.pot'; 16 | const options = { 17 | presets: ['@babel/preset-env'], 18 | plugins: [[ttagPlugin, { extract: { output }, discover: ['t'] }]], 19 | }; 20 | const input = 'console.log(t`simple string literal`);'; 21 | babel.transform(input, options); 22 | const result = fs.readFileSync(output).toString(); 23 | const expected = fs.readFileSync(expectedPath).toString(); 24 | expect(result).to.eql(expected); 25 | }); 26 | 27 | it('should escape backticks properly', () => { 28 | const output = 'debug/translations.pot'; 29 | const inputFile = 'tests/fixtures/backtick_test.js'; 30 | const options = { 31 | plugins: [[ttagPlugin, { extract: { output } }]], 32 | }; 33 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 34 | const result = fs.readFileSync(output).toString(); 35 | expect(result).to.contain('msgid "test with ` backtick"'); 36 | }); 37 | 38 | it('should escape backticks with expressions properly', () => { 39 | const output = 'debug/translations.pot'; 40 | const inputFile = 'tests/fixtures/backtick_with_expressions.js'; 41 | const options = { 42 | plugins: [[ttagPlugin, { extract: { output } }]], 43 | }; 44 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 45 | const result = fs.readFileSync(output).toString(); 46 | expect(result).to.contain('"test with ` backtick with ${ a }"'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_ngettext_default.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | 5 | const options = { 6 | plugins: [[c3poPlugin, { discover: ['ngettext', 'c'], resolve: { translations: 'default' } }]], 7 | }; 8 | 9 | describe('Resolve ngettext default', () => { 10 | it('should resolve original strings if no translator notes', () => { 11 | const input = 'console.log(ngettext(msgid`no translation plural`, `no translation plurals`, n));'; 12 | const result = babel.transform(input, options).code; 13 | expect(result).to.contain('_tag_ngettext(n, [`no translation plural`, `no translation plurals`])'); 14 | }); 15 | 16 | it('should resolve original formatted strings if no translator notes', () => { 17 | const input = 'console.log(ngettext(msgid`no translation plural ${n}`, `no translation plurals ${n}`, n));'; 18 | const result = babel.transform(input, options).code; 19 | expect(result).to.contain( 20 | '_tag_ngettext(n, [`no translation plural ${n}`, `no translation plurals ${n}`])', 21 | ); 22 | }); 23 | 24 | it('should resolve default strings with indent', () => { 25 | const input = 'ngettext(msgid` test\n test`, ` test\n tests`, n)'; 26 | const result = babel.transform(input, options).code; 27 | expect(result).to.contain('_tag_ngettext(n, [`test\ntest`, `test\ntests`]);'); 28 | }); 29 | 30 | it('should resolve default strings with indent with expressions', () => { 31 | const input = 'ngettext(msgid` test test \n ${name} line `, ` test tests`, n)'; 32 | const result = babel.transform(input, options).code; 33 | expect(result).to.contain('_tag_ngettext(n, [`test test \n${name} line`, ` test tests`]);'); 34 | }); 35 | 36 | it('should resolve original strings with context', () => { 37 | const input = 'console.log(c("foo").ngettext(msgid`no translation plural`, `no translation plurals`, n));'; 38 | const result = babel.transform(input, options).code; 39 | expect(result).to.contain('_tag_ngettext(n, [`no translation plural`, `no translation plurals`])'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_contexts.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/contexts_translations.po'; 7 | 8 | const options = { 9 | plugins: [[c3poPlugin, { 10 | resolve: { translations }, 11 | }]], 12 | }; 13 | 14 | describe('Context resolve', () => { 15 | before(() => { 16 | rmDirSync('debug'); 17 | }); 18 | 19 | it('should resolve simple gettext with the context', () => { 20 | const input = ` 21 | import { gettext, c } from 'ttag'; 22 | c('email').gettext('test'); 23 | `; 24 | const result = babel.transform(input, options).code; 25 | expect(result).to.contain('test email context'); 26 | }); 27 | 28 | it('should resolve t tag', () => { 29 | const input = ` 30 | import { t, c } from 'ttag'; 31 | c('email').t\`test\` 32 | `; 33 | const result = babel.transform(input, options).code; 34 | expect(result).to.contain('test email context'); 35 | }); 36 | 37 | it('should not remove wrapper function', () => { 38 | const input = ` 39 | import { t, c } from 'ttag'; 40 | make_it_work(c('email').t\`test\`) 41 | `; 42 | const result = babel.transform(input, options).code; 43 | expect(result).to.contain('make_it_work'); 44 | expect(result).to.contain('test email context'); 45 | }); 46 | 47 | it('should resolve ngettext', () => { 48 | const input = 'import { ngettext, msgid, c } from "ttag";\n' 49 | + 'const a = 2;\n' 50 | + 'c("email").ngettext(msgid`${ a } banana`, `${ a } bananas`, a);\n'; 51 | 52 | const result = babel.transform(input, options).code; 53 | expect(result).to.contain('${a} banana email context'); 54 | expect(result).to.contain('${a} bananas email context'); 55 | }); 56 | 57 | it('should resolve jt', () => { 58 | const input = ` 59 | import { jt, c } from 'ttag'; 60 | c('email').jt\`test\` 61 | `; 62 | const result = babel.transform(input, options).code; 63 | expect(result).to.contain('test email context'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/functional/test_discover_by_require.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import dedent from 'dedent'; 5 | import path from 'path'; 6 | import c3po from 'src/plugin'; 7 | import { rmDirSync } from 'src/utils'; 8 | 9 | const output = 'debug/translations.pot'; 10 | const options = { 11 | plugins: [[c3po, { extract: { output }, addComments: true }]], 12 | }; 13 | 14 | describe('Extract developer comments', () => { 15 | beforeEach(() => { 16 | rmDirSync('debug'); 17 | }); 18 | 19 | it('should extract t from require', () => { 20 | const input = dedent(` 21 | const { t } = require('ttag'); 22 | t\`test\` 23 | `); 24 | babel.transform(input, options); 25 | const result = fs.readFileSync(output).toString(); 26 | expect(result).to.contain('msgid "test"'); 27 | }); 28 | 29 | it('should extreact t from require from file', () => { 30 | const inputFile = 'tests/fixtures/test_require_discover.js'; 31 | babel.transformFileSync(path.join(process.cwd(), inputFile), options); 32 | const result = fs.readFileSync(output).toString(); 33 | expect(result).to.include('starting count up to'); 34 | }); 35 | 36 | it('should extract jt from require', () => { 37 | const input = dedent(` 38 | const { jt } = require('ttag'); 39 | jt\`test jt\` 40 | `); 41 | babel.transform(input, options); 42 | const result = fs.readFileSync(output).toString(); 43 | expect(result).to.contain('msgid "test jt"'); 44 | }); 45 | 46 | it('should extract context from require', () => { 47 | const input = dedent(` 48 | const { c } = require('ttag'); 49 | c('context').t\`test context\` 50 | `); 51 | babel.transform(input, options); 52 | const result = fs.readFileSync(output).toString(); 53 | expect(result).to.contain('msgid "test context"'); 54 | }); 55 | 56 | it('should recognize alias from require', () => { 57 | const input = dedent(` 58 | const { t: i18n } = require('ttag'); 59 | i18n\`test alias\` 60 | `); 61 | babel.transform(input, options); 62 | const result = fs.readFileSync(output).toString(); 63 | expect(result).to.contain('msgid "test alias"'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/ttag.macro.js: -------------------------------------------------------------------------------- 1 | import { createMacro, MacroError } from 'babel-plugin-macros'; 2 | import { FUNC_TO_ALIAS_MAP, ALIAS_TO_FUNC_MAP } from './defaults'; 3 | import { default as plugin, isStarted } from './plugin'; // eslint-disable-line 4 | 5 | function ttagMacro({ 6 | references, state, babel: { types: t }, config = {}, 7 | }) { 8 | const babelPluginTtag = plugin(); 9 | if (isStarted()) { 10 | return { keepImports: true }; 11 | } 12 | const program = state.file.path; 13 | 14 | // replace `babel-plugin-ttag/macro` by `ttag`, add create a node for ttag's imports 15 | const imports = t.importDeclaration([], t.stringLiteral('ttag')); 16 | // then add it to top of the document 17 | program.node.body.unshift(imports); 18 | 19 | // references looks like: 20 | // { default: [path, path], t: [path], ... } 21 | Object.keys(references).forEach((refName) => { 22 | if (!ALIAS_TO_FUNC_MAP[refName]) { 23 | const allowedMethods = Object.keys(FUNC_TO_ALIAS_MAP).map((k) => { 24 | const funcName = FUNC_TO_ALIAS_MAP[k]; 25 | 26 | return Array.isArray(funcName) 27 | ? funcName.join(', ') 28 | : funcName; 29 | }); 30 | throw new MacroError( 31 | `Invalid import: ${refName}. You can only import ${ 32 | allowedMethods.join(', ')} from 'babel-plugin-ttag/dist/ttag.macro'.`, 33 | ); 34 | } 35 | 36 | // generate new identifier and add to imports 37 | let id; 38 | if (refName === 'default') { 39 | id = program.scope.generateUidIdentifier('ttag'); 40 | imports.specifiers.push(t.importDefaultSpecifier(id)); 41 | } else { 42 | id = program.scope.generateUidIdentifier(refName); 43 | imports.specifiers.push(t.importSpecifier(id, t.identifier(refName))); 44 | } 45 | 46 | // update references with the new identifiers 47 | references[refName].forEach((referencePath) => { 48 | referencePath.node.name = id.name; 49 | }); 50 | }); 51 | 52 | // apply babel-plugin-ttag to the file 53 | const stateWithOpts = { ...state, opts: config }; 54 | program.traverse(babelPluginTtag.visitor, stateWithOpts); 55 | return {}; 56 | } 57 | 58 | export default createMacro(ttagMacro, { configName: 'ttag' }); 59 | -------------------------------------------------------------------------------- /tests/functional/test_extract_ngettext.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttagPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | 7 | describe('Extract ngettext with multiple presets', () => { 8 | before(() => { 9 | rmDirSync('debug'); 10 | }); 11 | 12 | it('should extract ngettext with multiple presets', () => { 13 | const output = 'debug/translations.pot'; 14 | const options = { 15 | presets: ['@babel/preset-env', '@babel/preset-react'], 16 | plugins: [[ttagPlugin, { extract: { output }, discover: ['ngettext'] }]], 17 | }; 18 | const input = '
{ngettext(msgid`test`, `test`, n)}
'; 19 | babel.transform(input, options); 20 | const result = fs.readFileSync(output).toString(); 21 | expect(result).to.contain( 22 | 'msgid "test"\nmsgid_plural "test"\nmsgstr[0] ""\nmsgstr[1] ""', 23 | ); 24 | }); 25 | 26 | it('regression test failed msigd validation on multiple traverse', () => { 27 | const output = 'debug/translations.pot'; 28 | const options = { 29 | presets: ['@babel/preset-env'], 30 | plugins: [[ttagPlugin, { extract: { output } }]], 31 | }; 32 | const input = ` 33 | import { ngettext, msgid } from 'ttag'; 34 | 35 | export function* foo(length) { 36 | yield bar(ngettext( 37 | msgid\`Foo \${length}\`, 38 | \`Foo \${length}\`, 39 | length, 40 | )); 41 | } 42 | `; 43 | babel.transform(input, options); 44 | const result = fs.readFileSync(output).toString(); 45 | expect(result).to.contain('msgid_plural "Foo ${ length }"'); 46 | }); 47 | it('regression test failed msigd validation on multiple traverse without resolve and extract config', () => { 48 | const options = { 49 | presets: ['@babel/preset-env'], 50 | plugins: [[ttagPlugin, {}]], 51 | }; 52 | const input = ` 53 | import { ngettext, msgid } from 'ttag'; 54 | 55 | export function* foo(length) { 56 | yield bar(ngettext( 57 | msgid\`Foo \${length}\`, 58 | \`Foo \${length}\`, 59 | length, 60 | )); 61 | } 62 | `; 63 | const fn = () => babel.transform(input, options); 64 | expect(fn).to.not.throw(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/functional/test_entries_sort_by_msgctxt.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttag from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | 10 | describe('Sorting entries by msgctxt', () => { 11 | beforeEach(() => { 12 | rmDirSync('debug'); 13 | }); 14 | 15 | it('should sort entries by msgctxt', () => { 16 | const options = { 17 | presets: ['@babel/preset-env'], 18 | plugins: [ 19 | [ 20 | ttag, 21 | { 22 | extract: { output }, 23 | sortByMsgctxt: true, 24 | sortByMsgid: false, 25 | }, 26 | ], 27 | ], 28 | }; 29 | 30 | const expectedPath = 'tests/fixtures/expected_sort_by_msgctx.pot'; 31 | const input = dedent(` 32 | import { c, t } from 'ttag'; 33 | c('ee').t\`cc\`; 34 | c('bb').t\`aa\`; 35 | c('ee').t\`aa\`; 36 | c('dd').t\`aa\`; 37 | c('cc').t\`aa\`; 38 | c('aa').t\`aa\`; 39 | `); 40 | babel.transform(input, options); 41 | const result = fs.readFileSync(output).toString(); 42 | const expected = fs.readFileSync(expectedPath).toString(); 43 | expect(result).to.eql(expected); 44 | }); 45 | 46 | it('should sort entries by msgctxt and msgid', () => { 47 | const options = { 48 | presets: ['@babel/preset-env'], 49 | plugins: [ 50 | [ 51 | ttag, 52 | { 53 | extract: { output }, 54 | sortByMsgctxt: true, 55 | sortByMsgid: true, 56 | }, 57 | ], 58 | ], 59 | }; 60 | 61 | const expectedPath = 'tests/fixtures/expected_sort_by_msgctxt_and_msgid.pot'; 62 | const input = dedent(` 63 | import { c, t } from 'ttag'; 64 | c('ee').t\`cc\`; 65 | c('bb').t\`aa\`; 66 | c('ee').t\`aa\`; 67 | c('dd').t\`aa\`; 68 | c('cc').t\`aa\`; 69 | c('aa').t\`aa\`; 70 | `); 71 | babel.transform(input, options); 72 | const result = fs.readFileSync(output).toString(); 73 | const expected = fs.readFileSync(expectedPath).toString(); 74 | expect(result).to.eql(expected); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_jsxtag_gettext.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import polyglotPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | presets: ['@babel/preset-env'], 10 | plugins: [[polyglotPlugin, { 11 | resolve: { translations }, 12 | discover: ['jt'], 13 | }]], 14 | }; 15 | 16 | describe('Resolve jsxtag-gettext', () => { 17 | before(() => { 18 | rmDirSync('debug'); 19 | }); 20 | 21 | it('should resolve simple gettext literal (without formatting)', () => { 22 | const input = 'console.log(jt`simple string literal`);'; 23 | const result = babel.transform(input, options).code; 24 | expect(result).to.contain('console.log(["simple string literal translated"]);'); 25 | }); 26 | 27 | it('should resolve gettext literal (with formatting)', () => { 28 | const input = 'console.log(jt`${ a } simple string ${ b } literal with formatting`);'; 29 | const result = babel.transform(input, options).code; 30 | expect(result).to.contain( 31 | 'console.log([a, " simple string ", b, " literal with formatting [translated]"]);', 32 | ); 33 | }); 34 | 35 | it('should resolve original string if no translation is found', () => { 36 | const input = 'console.log(jt`simple string literal without translation`);'; 37 | const result = babel.transform(input, options).code; 38 | expect(result).to.contain('console.log(["simple string literal without translation"]);'); 39 | }); 40 | 41 | it('should resolve original string if no translator notes', () => { 42 | const input = 'console.log(jt`no translator notes`);'; 43 | const result = babel.transform(input, options).code; 44 | expect(result).to.contain('console.log(["no translator notes"]);'); 45 | }); 46 | 47 | it('should resolve original formatted string if no translator notes', () => { 48 | const input = 'console.log(jt`simple string literal without translation ${a}`);'; 49 | const result = babel.transform(input, options).code; 50 | expect(result).to.contain('console.log(["simple string literal without translation ", a]);'); 51 | }); 52 | 53 | it('should resolve original formatted string if msgid is not found in po', () => { 54 | const input = 'console.log(jt`some random string`);'; 55 | const result = babel.transform(input, options).code; 56 | expect(result).to.contain('console.log(["some random string"]);'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/unit/test_context.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import C3poContext from 'src/context'; 3 | 4 | const DEFAULT_PO_DATA = { 5 | headers: 6 | { 7 | 'content-type': 'text/plain; charset=UTF-8', 8 | 'plural-forms': 'nplurals = 2; plural = (n != 1)', 9 | }, 10 | translations: { '': {} }, 11 | }; 12 | 13 | const ukPluralFormula = ' (n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)'; // eslint-disable-line 14 | const ukHeaders = { 15 | 'content-type': 'text/plain; charset=UTF-8', 16 | 'plural-forms': `nplurals = 3; plural =${ukPluralFormula}`, 17 | }; 18 | 19 | const ukPoData = { 20 | headers: ukHeaders, 21 | translations: { '': {} }, 22 | }; 23 | 24 | describe('C3poContext.getDefaultHeaders', () => { 25 | it('should set correct default plural headers', () => { 26 | const config = { defaultLang: 'uk' }; 27 | const context = new C3poContext(config); 28 | expect(context.getDefaultHeaders()).to.eql(ukHeaders); 29 | }); 30 | 31 | it('should set default po data if translations: "default"', () => { 32 | const config = { 33 | resolve: { translations: 'default' }, 34 | }; 35 | const context = new C3poContext(config); 36 | expect(context.poData).to.eql(DEFAULT_PO_DATA); 37 | }); 38 | 39 | it('should get proper headers with translations: "default" and defaultLang', () => { 40 | const config = { 41 | defaultLang: 'uk', 42 | resolve: { translations: 'default' }, 43 | }; 44 | const context = new C3poContext(config); 45 | expect(context.poData).to.eql(ukPoData); 46 | }); 47 | }); 48 | 49 | describe('C3poContext.getPluralsCount', () => { 50 | it('should be 2 by default for the en language', () => { 51 | const config = {}; 52 | const context = new C3poContext(config); 53 | expect(context.getPluralsCount()).to.eql(2); 54 | }); 55 | 56 | it('should be 3 for the uk language', () => { 57 | const config = { defaultLang: 'uk' }; 58 | const context = new C3poContext(config); 59 | expect(context.getPluralsCount()).to.eql(3); 60 | }); 61 | }); 62 | 63 | describe('C3poContext.getPluralFormula', () => { 64 | it('should work with en language by default', () => { 65 | const config = {}; 66 | const context = new C3poContext(config); 67 | expect(context.getPluralFormula()).to.eql(' (n != 1)'); 68 | }); 69 | 70 | it('should work with uk language', () => { 71 | const config = { defaultLang: 'uk' }; 72 | const context = new C3poContext(config); 73 | expect(context.getPluralFormula()).to.eql(ukPluralFormula); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/functional/test_extract_developer_comments.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import c3po from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | plugins: [[c3po, { extract: { output }, addComments: true }]], 11 | }; 12 | 13 | describe('Extract developer comments', () => { 14 | before(() => { 15 | rmDirSync('debug'); 16 | }); 17 | 18 | it('should extract single comment', () => { 19 | const input = dedent(` 20 | import { t } from 'ttag'; 21 | 22 | //test1 23 | t\`test\` 24 | `); 25 | babel.transform(input, options); 26 | const result = fs.readFileSync(output).toString(); 27 | expect(result).to.contain('#. test1'); 28 | }); 29 | 30 | it('should extract multiple comments', () => { 31 | const input = dedent(` 32 | import { t } from 'ttag'; 33 | 34 | //comment1 35 | //comment2 36 | t\`test2\` 37 | `); 38 | babel.transform(input, options); 39 | const result = fs.readFileSync(output).toString(); 40 | expect(result).to.contain('#. comment1\n#. comment2'); 41 | }); 42 | 43 | it('should extract each level of comments', () => { 44 | const input = dedent(` 45 | import { t } from 'ttag'; 46 | 47 | //comment3-3 48 | dispatch( /*comment3-2*/ someAction( /*comment3-1*/ t\`test3-1\` )); 49 | 50 | //comment3-6 51 | const formatted = /*comment3-5*/\`foo \${/*comment3-4*/ t\`test3-2\`} bar\`; 52 | 53 | //comment3-8 54 | const firstInExpression = /*comment3-7*/ t\`test3-3\` + 'bar'; 55 | 56 | //comment3-10 57 | const middleOfExpression = 'foo' + /*comment3-9*/ t\`test3-4\`; 58 | 59 | //comment3-12 60 | foo(); foo2(); /*comment3-11*/ console.log(t\`test3-5\`) 61 | `); 62 | babel.transform(input, options); 63 | const result = fs.readFileSync(output).toString(); 64 | expect(result).to.contain('#. comment3-3\n#. comment3-2\n#. comment3-1'); 65 | expect(result).to.contain('#. comment3-6\n#. comment3-5\n#. comment3-4'); 66 | expect(result).to.contain('#. comment3-8\n#. comment3-7'); 67 | expect(result).to.contain('#. comment3-10\n#. comment3-9'); 68 | expect(result).to.contain('#. comment3-11'); // 3-12 is too far away, ignore 69 | }); 70 | 71 | it('should not fail if no comments', () => { 72 | const input = dedent(` 73 | import { t } from 'ttag'; 74 | 75 | t\`test4\` 76 | `); 77 | babel.transform(input, options); 78 | const fn = () => fs.readFileSync(output).toString(); 79 | expect(fn).to.not.throw; 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/functional/test_disabled_scope.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | import { DISABLE_COMMENT } from 'src/defaults'; 6 | 7 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 8 | 9 | const options = { 10 | plugins: [[c3poPlugin, { 11 | resolve: { translations }, 12 | }]], 13 | }; 14 | 15 | describe('Resolve default', () => { 16 | before(() => { 17 | rmDirSync('debug'); 18 | }); 19 | it('should not strip gettext tag if has disabling comment in scope', () => { 20 | const input = ` 21 | import { t } from 'ttag'; 22 | function test() { 23 | /* ${DISABLE_COMMENT} */ 24 | console.log(t\`test\`); 25 | } 26 | `; 27 | const result = babel.transform(input, options).code; 28 | expect(result).to.not.contain('console.log("test");'); 29 | expect(result).to.contain('console.log(t`test`);'); 30 | }); 31 | it('should not strip gettext tag if has disabling comment in parent scope', () => { 32 | const input = ` 33 | import { t } from 'ttag'; 34 | function test() { 35 | /* ${DISABLE_COMMENT} */ 36 | function test2() { 37 | console.log(t\`test\`); 38 | } 39 | } 40 | `; 41 | const result = babel.transform(input, options).code; 42 | expect(result).to.not.contain('console.log("test");'); 43 | expect(result).to.contain('console.log(t`test`);'); 44 | }); 45 | it('should not strip gettext tag if has disabling comment after some expressions', () => { 46 | const input = ` 47 | import { t } from 'ttag'; 48 | const trans = function() { 49 | const a = 5; 50 | /* ${DISABLE_COMMENT} */ 51 | for (index = i = 0, len = pieces.length; i < len; index = ++i) { 52 | console.log(t\`test\`); 53 | } 54 | return result; 55 | }; 56 | `; 57 | const result = babel.transform(input, options).code; 58 | expect(result).to.not.contain('console.log("test");'); 59 | expect(result).to.contain('console.log(t`test`);'); 60 | }); 61 | 62 | it('should strip gettext tag if has eslint disable for eslint-plugin-ttag', () => { 63 | const input = ` 64 | import { t } from 'ttag'; 65 | const trans = function() { 66 | const a = 5; 67 | /* eslint-disable ttag/no-start-and-trailing-spaces-in-translations */ 68 | for (index = i = 0, len = pieces.length; i < len; index = ++i) { 69 | console.log(t\`test\`); 70 | } 71 | return result; 72 | }; 73 | `; 74 | const result = babel.transform(input, options).code; 75 | expect(result).to.contain('console.log("test");'); 76 | expect(result).not.to.contain('console.log(t`test`);'); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_numbered_expressions.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_numbered_expressions.po'; 7 | 8 | const options = { 9 | presets: ['@babel/preset-react'], 10 | plugins: [[c3poPlugin, { 11 | resolve: { translations }, 12 | numberedExpressions: true, 13 | }]], 14 | }; 15 | 16 | describe('Resolve tag-gettext', () => { 17 | before(() => { 18 | rmDirSync('debug'); 19 | }); 20 | 21 | it('should resolve t tag', () => { 22 | const input = ` 23 | import { t } from 'ttag'; 24 | console.log(t\`Hello \${ name }\`); 25 | `; 26 | const result = babel.transform(input, options).code; 27 | expect(result).to.contain('Hello ${name} [translated]'); 28 | }); 29 | 30 | it('should resolve t tag default', () => { 31 | const input = ` 32 | import { t } from 'ttag'; 33 | console.log(t\`Hello not translated \${ name }\`); 34 | `; 35 | const result = babel.transform(input, options).code; 36 | expect(result).to.contain('`Hello not translated ${name}`'); 37 | }); 38 | 39 | it('should resolve correct positions for expressions in t tag', () => { 40 | const input = ` 41 | import { t } from 'ttag'; 42 | console.log(t\`reverse \${ name }\ \${ surname }\`); 43 | `; 44 | const result = babel.transform(input, options).code; 45 | expect(result).to.contain('reverse ${surname} ${name} [translated]'); 46 | }); 47 | 48 | it('should resolve ngettext func', () => { 49 | const input = ` 50 | import { ngettext, msgid } from 'ttag'; 51 | ngettext(msgid\`\${ fn() } banana\`, \`\${ fn() }\ bananas\`, fn()); 52 | `; 53 | const result = babel.transform(input, options).code; 54 | expect(result).to.contain('${fn()} banana [translated]'); 55 | expect(result).to.contain('${fn()} bananas [translated]'); 56 | }); 57 | 58 | it('should resolve correct positions for ngettext func', () => { 59 | const input = ` 60 | import { ngettext, msgid } from 'ttag'; 61 | ngettext(msgid\`\${ a() } \${ b() } banana\`, 62 | \`\${ a() }\ \${ b() }\ bananas\`, n()); 63 | `; 64 | const result = babel.transform(input, options).code; 65 | expect(result).to.contain('${b()} ${a()} banana [translated]'); 66 | expect(result).to.contain('${b()} ${a()} bananas [translated]'); 67 | }); 68 | 69 | it('should resolve jt func', () => { 70 | const input = ` 71 | import { jt } from 'ttag'; 72 | import React from 'react'; 73 | const component = () => { 74 | return
{ jt\`react comp - \${fn()}\` }
75 | } 76 | `; 77 | const result = babel.transform(input, options).code; 78 | expect(result).to.contain('["react comp - ", fn(), " [translated]"]'); 79 | }); 80 | 81 | it('should resolve jt func default', () => { 82 | const input = ` 83 | import { jt } from 'ttag'; 84 | import React from 'react'; 85 | const component = () => { 86 | return
{ jt\`react comp2 - \${fn()}\` }
87 | } 88 | `; 89 | const result = babel.transform(input, options).code; 90 | expect(result).to.contain('["react comp2 - ", fn()]'); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/fixtures/resolve_simple_gettext.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | "Content-Transfer-Encoding: 8bit\n" 5 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 6 | 7 | #: tests/fixtures/fixture.js:4 8 | msgid "simple string literal" 9 | msgstr "simple string literal translated" 10 | 11 | #: tests/fixtures/fixture.js:10 12 | msgid "${ a } simple string ${ b } literal with formatting" 13 | msgstr "${ a } simple string ${ b } literal with formatting [translated]" 14 | 15 | #: tests/fixtures/fixture.js:100 16 | msgid "${ item.name.value } simple string ${ item.age.value } literal with formatting" 17 | msgstr "${ item.name.value } simple string ${ item.age.value } literal with formatting [translated]" 18 | 19 | #: tests/fixtures/fixture.js:5 20 | msgid "no translator notes" 21 | msgstr "" 22 | 23 | #: tests/fixtures/fixture.js:6 24 | msgid "plural form with ${ n } plural" 25 | msgid_plural "plural form with ${ n } plurals" 26 | msgstr[0] "plural form with ${ n } plural [translated]" 27 | msgstr[1] "plural form with ${ n } plurals [translated]" 28 | 29 | #: tests/fixtures/fixture.js:66 30 | msgid "plural form with ${ item.n } plural" 31 | msgid_plural "plural form with ${ item.n } plurals" 32 | msgstr[0] "plural form with ${ item.n } plural [translated]" 33 | msgstr[1] "plural form with ${ item.n } plurals [translated]" 34 | 35 | #: tests/fixtures/fixture.js:7 36 | msgid "no translator notes plural" 37 | msgid_plural "no translator notes plural" 38 | msgstr[0] "" 39 | msgstr[1] "" 40 | 41 | #: tests/fixtures/fixture.js:7 42 | msgid "no translation plural" 43 | msgid_plural "no translation plural" 44 | msgstr[0] "" 45 | msgstr[1] "" 46 | 47 | #: tests/fixtures/fixture.js:7 48 | msgid "no translator notes plural formatted ${ a }" 49 | msgid_plural "no translator notes plural formatted ${ a }" 50 | msgstr[0] "" 51 | msgstr[1] "" 52 | 53 | #: tests/fixtures/fixture.js:8 54 | msgid "" 55 | "first line\n" 56 | "second line\n" 57 | "third line" 58 | msgstr "translation" 59 | 60 | #: tests/fixtures/fixture.js:11 61 | msgid "" 62 | "first line plural\n" 63 | "second line plural\n" 64 | "third line plural" 65 | msgid_plural "" 66 | "first line plural\n" 67 | "second line plural\n" 68 | "third line plural" 69 | msgstr[0] "translation plural" 70 | msgstr[1] "translation plurals" 71 | 72 | #: tests/fixtures/fixture.js:20 73 | msgid "${ a } spaces test" 74 | msgstr "${a} spaces test [translated]" 75 | 76 | #: tests/fixtures/fixture.js:11 77 | msgid "" 78 | "first line plural\n" 79 | "second line plural" 80 | msgid_plural "" 81 | "first line plural\n" 82 | "second line plural" 83 | msgstr[0] "translation plural" 84 | msgstr[1] "translation plurals" 85 | 86 | #: tests/fixtures/fixture.js:20 87 | msgid "Typo test ${ mississipi }" 88 | msgstr "Typo test ${ missingpi }" 89 | 90 | #: tests/fixtures/fixture.js:20 91 | msgid "${ appleCount } apple" 92 | msgid_plural "${ appleCount } apple" 93 | msgstr[0] "${ appleCount } apple (translated)" 94 | msgstr[1] "${ count } apples (translated)" 95 | 96 | #: tests/fixtures/fixture.js:22 97 | msgid "test computed ${ a['computed'] }" 98 | msgstr "test computed ${ a['computed'] } translated" 99 | 100 | #: tests/fixtures/fixture.js:223 101 | #, fuzzy 102 | msgid "{name} fuzzy name" 103 | msgstr "{surname} fuzzy name" 104 | 105 | msgid "test test ${ MAX_AMOUNT }" 106 | msgstr "test test ${ MAX_AMOUNT } translate" 107 | 108 | #, fuzzy, javascript-format 109 | msgid "fuzzy with multiple flags ${ MAX_AMOUNT }" 110 | msgstr "fuzzy with multiple flags ${ MAX_AMOUNT }" 111 | -------------------------------------------------------------------------------- /tests/functional/test_extract_developer_comments_by_tag.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttag from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | presets: ['@babel/preset-env', '@babel/preset-react'], 11 | plugins: [[ttag, { extract: { output }, addComments: 'translator:' }]], 12 | }; 13 | 14 | describe('Extract developer comments by tag', () => { 15 | beforeEach(() => { 16 | rmDirSync('debug'); 17 | }); 18 | 19 | it('should extract comment comment', () => { 20 | const input = dedent(` 21 | import { t } from 'ttag'; 22 | 23 | //translator: test1 24 | t\`test\` 25 | `); 26 | babel.transform(input, options); 27 | const result = fs.readFileSync(output).toString(); 28 | expect(result).to.contain('#. test1'); 29 | }); 30 | 31 | it('should not extract comment', () => { 32 | const input = dedent(` 33 | import { t } from 'ttag'; 34 | 35 | //comment2 36 | t\`test2\` 37 | `); 38 | babel.transform(input, options); 39 | const result = fs.readFileSync(output).toString(); 40 | expect(result).to.not.contain('#. comment2'); 41 | }); 42 | 43 | it('should match with spaces after //', () => { 44 | const input = dedent(` 45 | import { t } from 'ttag'; 46 | 47 | // translator: test-comment 48 | t\`test3\` 49 | `); 50 | babel.transform(input, options); 51 | const result = fs.readFileSync(output).toString(); 52 | expect(result).to.contain('#. test-comment'); 53 | }); 54 | it('should extract comments inside JSX tags', () => { 55 | const input = dedent(` 56 | import { c } from 'ttag'; 57 | 58 | 59 | function render() { 60 | return (<> 61 |

{ 62 | // translator: this comment is for you AND IT WILL WORK 63 | c('valid-comment-1').t\`Title de ouf\` 64 | }

65 | 66 |

{ 67 | // this comment is NOT for you 68 | c('invalid-comment').t\`Title de ouf pas ok?\` 69 | }

70 |

{ 71 | /* translator: OKAY this comment is for you */ 72 | c('valid-comment-2').t\`Title de ouf ok?\` 73 | }

74 | 75 |

{c('no-comment').t\`hahaha\`}

76 | ) 77 | 78 | } 79 | `); 80 | babel.transform(input, options); 81 | const result = fs.readFileSync(output).toString(); 82 | expect(result).to.contain('#. OKAY this comment is for you'); 83 | expect(result).to.contain('#. this comment is for you AND IT WILL WORK'); 84 | expect(result).not.to.contain('#. this comment is NOT for you'); 85 | 86 | const entries = result.split('\n\n'); 87 | 88 | const validComment1 = entries.find((text) => text.includes('valid-comment-1')); 89 | const noComment = entries.find((text) => text.includes('no-comment')); 90 | const invalidComment = entries.find((text) => text.includes('invalid-comment')); 91 | expect(noComment).not.to.contain('#.'); 92 | expect(invalidComment).not.to.contain('#.'); 93 | expect(validComment1).to.contain('#. this comment is for you AND IT WILL WORK'); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/functional/test_contexts_extract.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import ttag from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import dedent from 'dedent'; 7 | 8 | const output = 'debug/translations.pot'; 9 | const options = { 10 | plugins: [[ttag, { extract: { output } }]], 11 | }; 12 | 13 | const expect1 = `import { c, t } from 'ttag'; 14 | c('email').t\`test\`; 15 | console.log(t\`test\`); 16 | c('email').t\`test2\`; 17 | console.log(t\`test2\`);`; 18 | 19 | const expect2 = `msgid "" 20 | msgstr "" 21 | "Content-Type: text/plain; charset=utf-8\\n" 22 | "Plural-Forms: nplurals=2; plural=(n!=1);\\n" 23 | 24 | msgid "test" 25 | msgstr "" 26 | 27 | msgid "test2" 28 | msgstr "" 29 | 30 | msgctxt "email" 31 | msgid "test" 32 | msgstr "" 33 | 34 | msgctxt "email" 35 | msgid "test2" 36 | msgstr "" 37 | `; 38 | 39 | describe('Contexts extract', () => { 40 | before(() => { 41 | rmDirSync('debug'); 42 | }); 43 | 44 | it('should extract "t" with context', () => { 45 | const input = dedent(` 46 | import { c, t } from 'ttag'; 47 | c('email').t\`test\`; 48 | console.log(t\`test\`); 49 | c('email').t\`test2\`; 50 | console.log(t\`test2\`); 51 | `); 52 | 53 | const babelResult = babel.transform(input, options); 54 | expect(babelResult.code).to.eql(expect1); 55 | 56 | const result = fs.readFileSync(output).toString(); 57 | expect(result).to.eql(expect2); 58 | }); 59 | 60 | it('should throw if context argument is not string', () => { 61 | const input = dedent(` 62 | import { c, t } from 'ttag'; 63 | c(aaa).t\`test\`; 64 | `); 65 | 66 | const fn = () => babel.transform(input, options); 67 | expect(fn).to.throw('Expected string as a context argument. Actual - "aaa"'); 68 | }); 69 | 70 | it('should throw if has more than 1 argument', () => { 71 | const input = dedent(` 72 | import { c, t } from 'ttag'; 73 | c('email', 'profile').t\`test\`; 74 | `); 75 | 76 | const fn = () => babel.transform(input, options); 77 | expect(fn).to.throw('Context function accepts only 1 argument but has 2 instead'); 78 | }); 79 | 80 | it('should discover context by alias', () => { 81 | const input = dedent(` 82 | import { c as msgctxt, t } from 'ttag'; 83 | msgctxt('phone').t\`test\`; 84 | `); 85 | babel.transform(input, options); 86 | const result = fs.readFileSync(output).toString(); 87 | expect(result).to.contain('msgctxt "phone"'); 88 | }); 89 | 90 | it('should extract ngettext', () => { 91 | const input = dedent(` 92 | import { c, ngettext, msgid } from 'ttag'; 93 | c('ngettext_ctx').ngettext(msgid\`banana\`, \`bananas\`, n); 94 | `); 95 | babel.transform(input, options); 96 | const result = fs.readFileSync(output).toString(); 97 | expect(result).to.contain('msgctxt "ngettext_ctx"'); 98 | expect(result).to.contain('msgid_plural "bananas"'); 99 | }); 100 | 101 | it('fn call should throw if has more than 1 argument', () => { 102 | const input = dedent(` 103 | import { c, ngettext, msgid } from 'ttag'; 104 | c('ngettext_ctx', 1).ngettext(msgid\`banana\`, \`bananas\`, n); 105 | `); 106 | 107 | const fn = () => babel.transform(input, options); 108 | expect(fn).to.throw('Context function accepts only 1 argument but has 2 instead'); 109 | }); 110 | 111 | it('fn call should throw if context argument is not string', () => { 112 | const input = dedent(` 113 | import { c, ngettext, msgid } from 'ttag'; 114 | c(aaa).ngettext(msgid\`banana\`, \`bananas\`, n); 115 | `); 116 | 117 | const fn = () => babel.transform(input, options); 118 | expect(fn).to.throw('Expected string as a context argument. Actual - "aaa"'); 119 | }); 120 | 121 | it('should extract gettext', () => { 122 | const input = dedent(` 123 | import { c, gettext } from 'ttag'; 124 | c('gettext_ctx').gettext('gettext test'); 125 | `); 126 | babel.transform(input, options); 127 | const result = fs.readFileSync(output).toString(); 128 | expect(result).to.contain('msgctxt "gettext_ctx"'); 129 | expect(result).to.contain('msgid "gettext test"'); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /tests/functional/test_po_resolve.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import ttagPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/ua.po'; 7 | 8 | describe('Test po resolve', () => { 9 | before(() => { 10 | rmDirSync('debug'); 11 | }); 12 | 13 | it('should resolve proper plural form of n', () => { 14 | const options = { 15 | presets: ['@babel/preset-env'], 16 | plugins: [[ttagPlugin, { 17 | resolve: { translations }, 18 | discover: ['ngettext'], 19 | }]], 20 | }; 21 | const expected = 'n % 10 == 1 && n % 100 != 11 ? 0 : ' 22 | + 'n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2'; 23 | const input = 'const n = 1; ' 24 | + 'console.log(ngettext(msgid`plural form with ${n} plural`, `plural form with ${n} plurals`, n));'; 25 | const result = babel.transform(input, options).code; 26 | expect(result).to.contain(expected); 27 | }); 28 | it('should remove imports on resolve', () => { 29 | const options = { 30 | plugins: [[ttagPlugin, { 31 | resolve: { translations }, 32 | }]], 33 | }; 34 | const input = ` 35 | import { t } from "ttag" 36 | t\`test\` 37 | `; 38 | const result = babel.transform(input, options).code; 39 | expect(result).not.to.contain('import'); 40 | expect(result).to.contain('test [translated]'); 41 | }); 42 | it('should remove require on resolve', () => { 43 | const options = { 44 | plugins: [[ttagPlugin, { 45 | resolve: { translations }, 46 | }]], 47 | }; 48 | const input = ` 49 | const { t } = require("ttag"); 50 | t\`test\` 51 | `; 52 | const result = babel.transform(input, options).code; 53 | expect(result).not.to.contain('require'); 54 | expect(result).to.contain('test [translated]'); 55 | }); 56 | it('should add stub for addLocale for require', () => { 57 | const options = { 58 | plugins: [[ttagPlugin, { 59 | resolve: { translations }, 60 | }]], 61 | }; 62 | const input = ` 63 | const { t, addLocale } = require("ttag"); 64 | addLocale('en', {}); 65 | t\`test\` 66 | `; 67 | const result = babel.transform(input, options).code; 68 | expect(result).not.to.contain('require'); 69 | expect(result).to.contain('test [translated]'); 70 | expect(result).to.contain('function addLocale()'); 71 | }); 72 | it('should add stub for addLocale for require alias', () => { 73 | const options = { 74 | plugins: [[ttagPlugin, { 75 | resolve: { translations }, 76 | }]], 77 | }; 78 | const input = ` 79 | const { t, addLocale: addi18nLocale } = require("ttag"); 80 | addLocale('en', {}); 81 | t\`test\` 82 | `; 83 | const result = babel.transform(input, options).code; 84 | expect(result).not.to.contain('require'); 85 | expect(result).to.contain('test [translated]'); 86 | expect(result).to.contain('function addi18nLocale()'); 87 | }); 88 | it('should add stub for addLocale fun for import', () => { 89 | const options = { 90 | plugins: [[ttagPlugin, { 91 | resolve: { translations }, 92 | }]], 93 | }; 94 | const input = ` 95 | import { t, addLocale } from "ttag" 96 | t\`test\` 97 | addLocale('en', {}); 98 | `; 99 | const result = babel.transform(input, options).code; 100 | expect(result).not.to.contain('import'); 101 | expect(result).to.contain('test [translated]'); 102 | expect(result).to.contain('function addLocale()'); 103 | }); 104 | it('should add stub for addLocale if import specifier', () => { 105 | const options = { 106 | plugins: [[ttagPlugin, { 107 | resolve: { translations }, 108 | }]], 109 | }; 110 | const input = ` 111 | import { t, addLocale as addi18nLocale } from "ttag" 112 | t\`test\` 113 | addi18nLocale('en', {}); 114 | `; 115 | const result = babel.transform(input, options).code; 116 | expect(result).not.to.contain('import'); 117 | expect(result).to.contain('test [translated]'); 118 | expect(result).to.contain('function addi18nLocale()'); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua) 2 | 3 | 4 | # babel-plugin-ttag 5 | [![travis](https://api.travis-ci.org/ttag-org/babel-plugin-ttag.svg)](https://travis-ci.org/ttag-org) 6 | [![codecov](https://codecov.io/gh/ttag-org/babel-plugin-ttag/branch/master/graph/badge.svg)](https://codecov.io/gh/ttag-org/babel-plugin-ttag) 7 | 8 | [![NPM](https://nodei.co/npm/babel-plugin-ttag.png?downloads=true)](https://nodei.co/npm/babel-plugin-ttag/) 9 | 10 | > :warning: This project [was previously named `babel-plugin-c-3po`](https://github.com/ttag-org/ttag/issues/105). 11 | > Some of the talks, presentations, and documentation _may_ reference it with both names. 12 | 13 | ## project description 14 | Solution for providing gettext like translations into your project. Uses es6 native template syntax. 15 | 16 | ## documentation - [c-3po.js.org](http://c-3po.js.org) 17 | 18 | Plugin functions: 19 | - extracting translations from es6 tagged templates to .pot 20 | - resolving translations from .po files right into your sources at compile time. 21 | 22 | Key features: 23 | The core features of this tool are: 24 | 25 | - Works with GNU gettext tool (.po files). 26 | - Use es6 tagged templates syntax for string formatting (no extra formatting rules, no sprintf e.t.c). 27 | - The most intelligent gettext functions extraction from javascript sources (babel plugin). 28 | - Resolves translations from .po files right into your code (no runtime extra work in browser). 29 | - Works with everything that works with babel (.jsx syntax for instance). 30 | - Fast feedback loop (alerts when some string is missing translation right at compile time) 31 | - Designed to work with universal apps (works on a backend and a frontend). 32 | 33 | ## Tutorials 34 | * [Quick Start](https://c-3po.js.org/quick-start.html) 35 | * [Localization with webpack and ttag](https://c-3po.js.org/localization-with-webpack-and-c-3po.html) 36 | 37 | Installation 38 | ============ 39 | 40 | `npm install --save-dev babel-plugin-ttag && npm install --save ttag` 41 | 42 | 43 | gettext example 44 | =============== 45 | Here is how you code will look like while using this plugin: 46 | 47 | ```javascript 48 | import { t } from 'ttag'; 49 | const name = 'Mike'; 50 | console.log(t`Hello ${name}`); 51 | ``` 52 | So you can see that you can use native es6 template formatting. To make your string translatable, all you need to do is to place 't' tag. 53 | 54 | Translator will see this inside .po files: 55 | ```po 56 | #: src/page.js:8 57 | msgid "Hello ${ name }" 58 | msgstr "" 59 | ``` 60 | Plural example 61 | ============== 62 | Here is how you can handle plural forms: 63 | > This function has something similar with standart ngettext but behaves a little bit different. It assumes that you have only one form in your sources and other forms will be added in .po files. This is because different languages has different number of plural forms, and there are cases when your default language is not english, so it doesn't make sense to specify 2 plural forms at all. 64 | 65 | ```javascript 66 | import { ngettext, msgid } from 'ttag'; 67 | const name = 'Mike'; 68 | const n = 5; 69 | console.log(ngettext(msgid`Mike has ${n} banana`, `Mike has ${n} bananas`, n)); 70 | ``` 71 | 72 | Output in .po files: 73 | ```po 74 | #: src/PluralDemo.js:18 75 | msgid "Mike has ${ n } banana" 76 | msgid_plural "Mike has ${ n } bananas" 77 | msgstr[0] "" 78 | msgstr[1] "" 79 | ``` 80 | 81 | Use case with jsx (react): 82 | ========================== 83 | There are no additional setup for making this plugin work inside jsx. (just add babel-plugin-react plugin to your .babelrc) 84 | 85 | ```javascript 86 | import React from 'react'; 87 | import { t, ngettext, msgid } from 'ttag'; 88 | 89 | class PluralDemo extends React.Component { 90 | constructor(props) { 91 | super(props); 92 | this.state = { count: 0 }; 93 | this.countInc = this.countInc.bind(this); 94 | } 95 | countInc() { 96 | this.setState({ count: this.state.count + 1 }); 97 | } 98 | render() { 99 | const n = this.state.count; 100 | return ( 101 |
102 |

{ t`Deadly boring counter demo (but with plurals)` }

103 |
{ ngettext(msgid`You have clicked ${n} time`, `You have clicked ${n} times`, n) }
104 | 105 |
106 | ) 107 | } 108 | } 109 | 110 | export default PluralDemo; 111 | ``` 112 | 113 | Disabling some code parts 114 | ========================= 115 | If for some reason you need to disable ttag plugin transformation for some code block 116 | you can use special comment globally to disable the whole file or inside some code block (function): 117 | ```javascript 118 | /* disable ttag */ 119 | 120 | // or 121 | function test() { 122 | /* disable ttag */ 123 | } 124 | ``` 125 | 126 | Contribution 127 | ============ 128 | Feel free to contribute, make sure to cover your contributions with tests. 129 | Test command: 130 | ``` 131 | make test 132 | ``` 133 | 134 | License 135 | ======= 136 | 137 | [MIT License](LICENSE). 138 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_gettext.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import c3poPlugin from 'src/plugin'; 4 | import { rmDirSync } from 'src/utils'; 5 | 6 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 7 | 8 | const options = { 9 | presets: ['@babel/preset-env'], 10 | plugins: [[c3poPlugin, { 11 | resolve: { translations }, 12 | discover: ['t'], 13 | }]], 14 | }; 15 | 16 | describe('Resolve tag-gettext', () => { 17 | before(() => { 18 | rmDirSync('debug'); 19 | }); 20 | 21 | it('should resolve simple gettext literal (without formatting)', () => { 22 | const input = 'console.log(t`simple string literal`);'; 23 | const result = babel.transform(input, options).code; 24 | expect(result).to.contain('console.log("simple string literal translated");'); 25 | }); 26 | 27 | it('should resolve gettext literal (with formatting)', () => { 28 | const input = 'console.log(t`${ a } simple string ${ b } literal with formatting`);'; 29 | const result = babel.transform(input, options).code; 30 | expect(result).to.contain( 31 | 'console.log("".concat(a, " simple string ").concat(b, " literal with formatting [translated]"))', 32 | ); 33 | }); 34 | 35 | it('should work with upper case characters as variables (regression)', () => { 36 | // https://github.com/babel/babel/issues/8723 37 | const input = 't`test test ${MAX_AMOUNT}`'; 38 | const result = babel.transform(input, options).code; 39 | expect(result).to.contain( 40 | '"test test ".concat(MAX_AMOUNT, " translate");', 41 | ); 42 | }); 43 | 44 | it('should resolve gettext literal (with formatting) for member expressions', () => { 45 | const input = ( 46 | 'console.log(t`${ item.name.value } simple string ' 47 | + '${ item.age.value } literal with formatting`);' 48 | ); 49 | const result = babel.transform(input, options).code; 50 | expect(result).to.contain( 51 | 'console.log("".concat(item.name.value, " simple string ").concat(item.age.value, " ' 52 | + 'literal with formatting [translated]"));', 53 | ); 54 | }); 55 | 56 | it('should resolve original string if no translation is found', () => { 57 | const input = 'console.log(t`simple string literal without translation`);'; 58 | const result = babel.transform(input, options).code; 59 | expect(result).to.contain('console.log("simple string literal without translation");'); 60 | }); 61 | 62 | it('should resolve original string if no translator notes', () => { 63 | const input = 'console.log(t`no translator notes`);'; 64 | const result = babel.transform(input, options).code; 65 | expect(result).to.contain('console.log("no translator notes");'); 66 | }); 67 | 68 | it('should resolve original formatted string if no translator notes', () => { 69 | const input = 'console.log(t`simple string literal without translation ${a}`);'; 70 | const result = babel.transform(input, options).code; 71 | expect(result).to.contain( 72 | 'console.log("simple string literal without translation ".concat(a));', 73 | ); 74 | }); 75 | 76 | it('should resolve original formatted string if msgid is not found in po', () => { 77 | const input = 'console.log(t`some random string`);'; 78 | const result = babel.transform(input, options).code; 79 | expect(result).to.contain('console.log("some random string");'); 80 | }); 81 | 82 | it('should throw if has invalid expressions', () => { 83 | const input = 'console.log(t`some random string ${ n + 1 }`);'; 84 | const func = () => babel.transform(input, options).code; 85 | expect(func).to.throw('You can not use BinaryExpression \'${n + 1}\' in localized strings'); 86 | }); 87 | 88 | it('should resolve with indent', () => { 89 | const input = `console.log(t\` 90 | first line 91 | second line 92 | third line\`);`; 93 | const result = babel.transform(input, options).code; 94 | expect(result).to.contain('console.log("translation");'); 95 | }); 96 | 97 | it('should skip spaces inside expressions', () => { 98 | const input = 'console.log(t`${ a } spaces test`);'; 99 | const result = babel.transform(input, options).code; 100 | expect(result).to.contain('console.log("".concat(a, " spaces test [translated]"));'); 101 | }); 102 | 103 | it('should throw if expression contains typo', () => { 104 | const input = 'console.log(t`Typo test ${ mississipi }`);'; 105 | const func = () => babel.transform(input, options).code; 106 | expect(func).to.throw( 107 | 'Expression \'mississipi\' is not found in the localized string \'Typo test ${ missingpi }\'.', 108 | ); 109 | }); 110 | it('should resolve computed properties', () => { 111 | const input = 'console.log(t`test computed ${ a[\'computed\'] }`);'; 112 | const result = babel.transform(input, options).code; 113 | expect(result).to.contain('console.log("test computed ".concat(a[\'computed\'], " translated"));'); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /tests/functional/test_resolve_ngettext.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as babel from '@babel/core'; 3 | import fs from 'fs'; 4 | import c3poPlugin from 'src/plugin'; 5 | import { rmDirSync } from 'src/utils'; 6 | import mkdirp from 'mkdirp'; 7 | import childProcess from 'child_process'; 8 | 9 | const translations = 'tests/fixtures/resolve_simple_gettext.po'; 10 | 11 | const options = { 12 | plugins: [[c3poPlugin, { 13 | resolve: { translations }, 14 | discover: ['ngettext'], 15 | }]], 16 | }; 17 | 18 | describe('Resolve ngettext', () => { 19 | before(() => { 20 | rmDirSync('debug'); 21 | }); 22 | 23 | it('should resolve proper plural form of n', () => { 24 | const expected = '_tag_ngettext(n, [`plural form with ${n} plural [translated]`, `plural form with ${n} plurals [translated]`])'; 25 | const input = 'const n = 1; ' 26 | + 'console.log(ngettext(msgid`plural form with ${n} plural`, `plural form with ${n} plurals`, n));'; 27 | const result = babel.transform(input, options).code; 28 | expect(result).to.contain(expected); 29 | }); 30 | 31 | it('should resolve proper plural form for member expression', () => { 32 | const expected = '_tag_ngettext(item.n, [`plural form with ${item.n} plural [translated]`,' 33 | + ' `plural form with ${item.n} plurals [translated]`])'; 34 | const input = 'const n = 1; ' 35 | + 'console.log(ngettext(msgid`plural form with ${item.n} plural`, ' 36 | + '`plural form with ${item.n} plurals`, item.n));'; 37 | const result = babel.transform(input, options).code; 38 | expect(result).to.contain(expected); 39 | }); 40 | 41 | it('should not include ngettext function multiple times', () => { 42 | const input = 'const n = 1;\n' 43 | + 'console.log(ngettext(msgid`plural form with ${n} plural`, `plural form with ${n} plurals`, n));\n' 44 | + 'console.log(ngettext(msgid`plural form with ${n} plural`, `plural form with ${n} plurals`, n));'; 45 | const result = babel.transform(input, options).code; 46 | expect(result.match(/_tag_ngettext/g).length).to.eql(3); 47 | }); 48 | 49 | it('should work when n is Literal', () => { 50 | const expected = 'plural [translated]'; 51 | const input = 'console.log(ngettext(msgid`plural form with ${n} plural`, `plural form with ${n} plurals`, 1));'; 52 | const result = babel.transform(input, options).code; 53 | expect(result).to.contain(expected); 54 | 55 | const expected2 = 'plurals [translated]'; 56 | const input2 = 'console.log(ngettext(msgid`plural form with ${n} plural`, ' 57 | + '`plural form with ${n} plurals`, 2));'; 58 | const result2 = babel.transform(input2, options).code; 59 | expect(result2).to.contain(expected2); 60 | }); 61 | 62 | it('should throw if has invalid expressions', () => { 63 | const input = 'console.log(ngettext(msgid`no translation plural ${n + 1}`, `no translation plurals`, n));'; 64 | const func = () => babel.transform(input, options).code; 65 | expect(func).to.throw('You can not use BinaryExpression \'${n + 1}\' in localized strings'); 66 | }); 67 | 68 | it('should throw if has invalid plural argument format', () => { 69 | const input = 'console.log(ngettext(msgid`no translation plural ${n}`, `no translation plurals ${n}`, n + 1));'; 70 | const func = () => babel.transform(input, options).code; 71 | expect(func).to.throw('BinaryExpression \'n + 1\' can not be used as plural argument'); 72 | }); 73 | 74 | it('should use proper plural form', () => { 75 | rmDirSync('debug'); 76 | mkdirp.sync('debug'); 77 | const resultPath = 'debug/ngettext_result.js'; 78 | const input = 'const n = parseInt(process.env.TEST_N, 10);\n' 79 | + 'process.stdout.write(ngettext(msgid`plural form with ${ n } plural`, ' 80 | + '`plural form with ${ n } plurals`, n));'; 81 | const result = babel.transform(input, options).code; 82 | fs.writeFileSync(resultPath, result, { mode: 0o777 }); 83 | 84 | const { stdout: stdout1 } = childProcess.spawnSync('node', [resultPath], 85 | { env: Object.assign(process.env, { TEST_N: 1 }) }); 86 | expect(stdout1.toString()).to.eql('plural form with 1 plural [translated]'); 87 | const { stdout: stdout2 } = childProcess.spawnSync('node', [resultPath], 88 | { env: Object.assign(process.env, { TEST_N: 2 }) }); 89 | expect(stdout2.toString()).to.eql('plural form with 2 plurals [translated]'); 90 | }); 91 | 92 | it('should resolve with indent', () => { 93 | const input = `console.log( 94 | ngettext(msgid\`first line plural 95 | second line plural\`, 96 | \`first line plural 97 | second line plurals\`, n))`; 98 | const result = babel.transform(input, options).code; 99 | expect(result).to.contain('translation plural'); 100 | expect(result).to.contain('translation plurals'); 101 | }); 102 | 103 | it('should throw if expression contains typo', () => { 104 | const input = 'console.log(ngettext(msgid`${ appleCount } apple`, `${ appleCount } apples`, appleCount));'; 105 | const func = () => babel.transform(input, options).code; 106 | expect(func).to.throw( 107 | 'Expression \'appleCount\' is not found in the localized string \'${ count }' 108 | + ' apples (translated)\'.', 109 | ); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | export PATH := $(PWD)/node_modules/.bin:$(PATH) 3 | export NODE_PATH = ./ 4 | 5 | MOCHA_CMD = mocha --require @babel/register 6 | 7 | test_extract_gettext: 8 | $(MOCHA_CMD) ./tests/functional/test_extract_gettext_simple.js 9 | 10 | test_extract_fn_gettext: 11 | $(MOCHA_CMD) ./tests/functional/test_extract_fn_gettext.js 12 | 13 | test_extract_gettext_with_formatting: 14 | $(MOCHA_CMD) ./tests/functional/test_extract_gettext_with_formatting.js 15 | 16 | test_extract_filename: 17 | $(MOCHA_CMD) ./tests/functional/test_extract_filename.js 18 | 19 | test_extract_developer_comments: 20 | $(MOCHA_CMD) ./tests/functional/test_extract_developer_comments.js 21 | 22 | test_extract_developer_comments_by_tag: 23 | $(MOCHA_CMD) ./tests/functional/test_extract_developer_comments_by_tag.js 24 | 25 | test_extract_ngettext: 26 | $(MOCHA_CMD) ./tests/functional/test_extract_ngettext.js 27 | 28 | test_resolve_gettext: 29 | $(MOCHA_CMD) ./tests/functional/test_resolve_gettext.js 30 | 31 | test_resolve_gettext_default: 32 | $(MOCHA_CMD) ./tests/functional/test_resolve_gettext_default.js 33 | 34 | test_resolve_default: 35 | $(MOCHA_CMD) ./tests/functional/test_resolve_default.js 36 | 37 | test_resolve_strip_polyglot_tags: 38 | $(MOCHA_CMD) ./tests/functional/test_resolve_strip_polyglot_tags.js 39 | 40 | test_resolve_jsxtag_gettext: 41 | $(MOCHA_CMD) ./tests/functional/test_resolve_jsxtag_gettext.js 42 | 43 | test_resolve_ngettext: 44 | $(MOCHA_CMD) ./tests/functional/test_resolve_ngettext.js 45 | 46 | test_resolve_ngettext_default: 47 | $(MOCHA_CMD) ./tests/functional/test_resolve_ngettext_default.js 48 | 49 | test_resolve_ngettext_default_for_locale: 50 | $(MOCHA_CMD) ./tests/functional/test_resolve_ngettext_default_for_locale.js 51 | 52 | test_resolve_fn_gettext: 53 | $(MOCHA_CMD) ./tests/functional/test_resolve_fn_gettext.js 54 | 55 | test_resolve_when_validation_fails: 56 | $(MOCHA_CMD) ./tests/functional/test_resolve_when_validation_fails.js 57 | 58 | test_alias_resolve: 59 | $(MOCHA_CMD) ./tests/functional/test_alias_resolve.js 60 | 61 | test_alias_discover: 62 | $(MOCHA_CMD) ./tests/functional/test_alias_discover.js 63 | 64 | test_unresolved: 65 | $(MOCHA_CMD) ./tests/functional/test_unresolved.js 66 | 67 | test_po_resolve: 68 | $(MOCHA_CMD) ./tests/functional/test_po_resolve.js 69 | 70 | test_disabled_scope: 71 | $(MOCHA_CMD) ./tests/functional/test_disabled_scope.js 72 | 73 | test_unit: 74 | $(MOCHA_CMD) ./tests/unit/**/*.js 75 | 76 | test_entries_sort: 77 | $(MOCHA_CMD) ./tests/functional/test_entries_sort.js 78 | 79 | test_entries_sort_by_msgctxt: 80 | $(MOCHA_CMD) ./tests/functional/test_entries_sort_by_msgctxt.js 81 | 82 | test_sorted_entries_sort: 83 | $(MOCHA_CMD) ./tests/functional/test_sorted_entries_sort.js 84 | 85 | test_sorted_entries_without_reference_line_num: 86 | $(MOCHA_CMD) ./tests/functional/test_sorted_entries_without_reference_line_num.js 87 | 88 | test_empty_config: 89 | $(MOCHA_CMD) ./tests/functional/test_empty_config_mode.js 90 | 91 | test_contexts_extract: 92 | $(MOCHA_CMD) ./tests/functional/test_contexts_extract.js 93 | 94 | test_contexts_extract_from_file: 95 | $(MOCHA_CMD) ./tests/functional/test_contexts_extract_from_file.js 96 | 97 | test_resolve_contexts: 98 | $(MOCHA_CMD) ./tests/functional/test_resolve_contexts.js 99 | 100 | test_extract_numbered_expressions: 101 | $(MOCHA_CMD) ./tests/functional/test_extract_numbered_expressions.js 102 | 103 | test_resolve_numbered_expressions: 104 | $(MOCHA_CMD) ./tests/functional/test_resolve_numbered_expressions.js 105 | 106 | test_discover_by_require: 107 | $(MOCHA_CMD) ./tests/functional/test_discover_by_require.js 108 | 109 | test_macro_resolve: 110 | $(MOCHA_CMD) ./tests/functional/test_macro_resolve.js 111 | 112 | test_macro_extract: 113 | $(MOCHA_CMD) ./tests/functional/test_macro_extract.js 114 | 115 | test_npm_specifiers_extract: 116 | $(MOCHA_CMD) ./tests/functional/test_npm_specifiers_extract.js 117 | 118 | test_alias_extract: 119 | $(MOCHA_CMD) ./tests/functional/test_alias_extract.js 120 | 121 | test_extract_js_format: 122 | $(MOCHA_CMD) ./tests/functional/test_extract_js_format.js 123 | 124 | 125 | test_fun: test_extract_gettext 126 | test_fun: test_extract_fn_gettext 127 | test_fun: test_extract_gettext_with_formatting 128 | test_fun: test_extract_filename 129 | test_fun: test_extract_developer_comments 130 | test_fun: test_extract_developer_comments_by_tag 131 | test_fun: test_extract_ngettext 132 | test_fun: test_resolve_gettext 133 | test_fun: test_resolve_gettext_default 134 | test_fun: test_resolve_default 135 | test_fun: test_resolve_strip_polyglot_tags 136 | test_fun: test_resolve_ngettext_default_for_locale 137 | test_fun: test_resolve_ngettext 138 | test_fun: test_resolve_ngettext_default 139 | test_fun: test_resolve_jsxtag_gettext 140 | test_fun: test_po_resolve 141 | test_fun: test_unresolved 142 | test_fun: test_resolve_fn_gettext 143 | test_fun: test_alias_resolve 144 | test_fun: test_disabled_scope 145 | test_fun: test_resolve_when_validation_fails 146 | test_fun: test_alias_discover 147 | test_fun: test_entries_sort 148 | test_fun: test_entries_sort_by_msgctxt 149 | test_fun: test_sorted_entries_sort 150 | test_fun: test_empty_config 151 | test_fun: test_contexts_extract 152 | test_fun: test_resolve_contexts 153 | test_fun: test_contexts_extract_from_file 154 | test_fun: test_extract_numbered_expressions 155 | test_fun: test_resolve_numbered_expressions 156 | test_fun: test_discover_by_require 157 | test_fun: test_macro_resolve 158 | test_fun: test_macro_extract 159 | test_fun: test_alias_extract 160 | test_fun: test_extract_js_format 161 | test_fun: test_npm_specifiers_extract 162 | 163 | test: test_fun 164 | test: test_unit 165 | 166 | lint: 167 | npm run lint 168 | -------------------------------------------------------------------------------- /tests/unit/test_ngettext_extractor.js: -------------------------------------------------------------------------------- 1 | import template from '@babel/template'; 2 | import ngettext from 'src/extractors/ngettext'; 3 | import Context from 'src/context'; 4 | import { expect } from 'chai'; 5 | import { PO_PRIMITIVES } from 'src/defaults'; 6 | 7 | const { MSGID, MSGSTR, MSGID_PLURAL } = PO_PRIMITIVES; 8 | 9 | const enConfig = new Context(); 10 | 11 | describe('ngettext extract', () => { 12 | it('should extract proper msgid1', () => { 13 | const node = template('ngettext(msgid`${ n } banana`, `${ n } bananas`, n)')().expression; 14 | const result = ngettext.extract(node, enConfig); 15 | expect(result[MSGID]).to.eql('${ n } banana'); 16 | }); 17 | 18 | it('should extract proper msgid1 for member expressions', () => { 19 | const node = template('ngettext(msgid`${ state.n } banana`, `${ state.n } bananas`, state.n)')().expression; 20 | const result = ngettext.extract(node, enConfig); 21 | expect(result[MSGID]).to.eql('${ state.n } banana'); 22 | }); 23 | 24 | it('should extract proper msgid1 for member expressions with this', () => { 25 | const node = template('ngettext(msgid`${ this.state.n } banana`, `${ this.state.n } bananas`, this.state.n)')().expression; 26 | const result = ngettext.extract(node, enConfig); 27 | expect(result[MSGID]).to.eql('${ this.state.n } banana'); 28 | }); 29 | 30 | it('should extract proper msgidplural', () => { 31 | const node = template('ngettext(msgid`${ n } banana`, `${ n } bananas`, n)')().expression; 32 | const result = ngettext.extract(node, enConfig); 33 | expect(result[MSGID_PLURAL]).to.eql('${ n } bananas'); 34 | }); 35 | 36 | it('should extract proper msgstr', () => { 37 | const node = template('ngettext(msgid`${ n } banana`, `${ n } bananas`, n)')().expression; 38 | const result = ngettext.extract(node, enConfig); 39 | const msgStr = result[MSGSTR]; 40 | expect(msgStr).to.have.property('length'); 41 | expect(msgStr.length).to.eql(2); 42 | expect(msgStr[0]).to.eql(''); 43 | expect(msgStr[1]).to.eql(''); 44 | }); 45 | 46 | it('should extract valid number of msgstrs', () => { 47 | const config = new Context({ defaultLang: 'uk' }); 48 | const node = template('ngettext(msgid`${ n } банан`, `${ n } банана`, `бананів`, n)')().expression; 49 | const result = ngettext.extract(node, config); 50 | const msgStr = result[MSGSTR]; 51 | expect(msgStr).to.have.property('length'); 52 | expect(msgStr.length).to.eql(3); 53 | expect(msgStr[0]).to.eql(''); 54 | expect(msgStr[1]).to.eql(''); 55 | expect(msgStr[2]).to.eql(''); 56 | }); 57 | it('should not pass validation if has wrong number of plural forms', () => { 58 | const node = template('ngettext(msgid`test`, `test`, `test`, n)')().expression; 59 | const fn = () => ngettext.extract(node, enConfig); 60 | expect(fn).to.throw('Expected to have 2 plural forms but have 3 instead'); 61 | }); 62 | it('should strip indentation for all forms', () => { 63 | const node = template('ngettext(msgid` test\n test`, ` test\n tests`, n)')().expression; 64 | const result = ngettext.extract(node, enConfig); 65 | expect(result[MSGID]).to.eql('test\ntest'); 66 | expect(result[MSGID_PLURAL]).to.eql('test\ntests'); 67 | }); 68 | }); 69 | 70 | describe('ngettext match', () => { 71 | it('should match gettext', () => { 72 | const node = template('ngettext(msgid`test`, `test`, `test`)')().expression; 73 | const result = ngettext.match(node, enConfig); 74 | expect(result).to.be.true; 75 | }); 76 | }); 77 | 78 | describe('ngettext validate', () => { 79 | it('should pass validation', () => { 80 | const node = template('ngettext(msgid`test`, `test`, n)')().expression; 81 | const fn = () => ngettext.validate(node, enConfig); 82 | expect(fn).to.not.throw(); 83 | }); 84 | it('should not pass validation when first arg is not tagged expression', () => { 85 | const node = template('ngettext(`test`, `test`, n)')().expression; 86 | const fn = () => ngettext.validate(node, enConfig); 87 | expect(fn).to.throw("First argument must be tagged template expression. You should use 'msgid' tag"); 88 | }); 89 | it('should not pass validation when first arg is not a msgid', () => { 90 | const node = template('ngettext(z`test`, `test`, n)')().expression; 91 | const fn = () => ngettext.validate(node, enConfig); 92 | expect(fn).to.throw("Expected 'msgid' for the first argument but not 'z'"); 93 | }); 94 | it('should not pass validation when first arg has invalid expressions', () => { 95 | const node = template('ngettext(msgid`test ${fn()}`, `test`, n)')().expression; 96 | const fn = () => ngettext.validate(node, enConfig); 97 | expect(fn).to.throw('You can not use CallExpression \'${fn()}\' in localized strings'); 98 | }); 99 | it('should not pass validation when plural forms has invalid expressions', () => { 100 | const node = template('ngettext(msgid`test`, `test ${ fn() }`, n)')().expression; 101 | const fn = () => ngettext.validate(node, enConfig); 102 | expect(fn).to.throw('You can not use CallExpression \'${fn()}\' in localized strings'); 103 | }); 104 | it('should not pass validation if has wrong \'n\' argument', () => { 105 | const node = template('ngettext(msgid`test`, `test`, fn())')().expression; 106 | const fn = () => ngettext.validate(node, enConfig); 107 | expect(fn).to.throw('CallExpression \'fn()\' can not be used as plural argument'); 108 | }); 109 | it('should not pass validation if has empty msgid', () => { 110 | const node = template('ngettext(msgid``, `test`, n)')().expression; 111 | const fn = () => ngettext.validate(node, enConfig); 112 | expect(fn).to.throw('Can not translate \'\''); 113 | }); 114 | it('should not pass validation if has no meaningful information', () => { 115 | const node = template('ngettext(msgid`${name} ${n}`, `test`, n)')().expression; 116 | const fn = () => ngettext.validate(node, enConfig); 117 | expect(fn).to.throw('Can not translate \'${name} ${n}\''); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import { getNPlurals as getPluralsNumForLang, getPluralFormsHeader, getFormula } from 'plural-forms'; 2 | import { 3 | FUNC_TO_ALIAS_MAP, DEFAULT_POT_OUTPUT, DEFAULT_HEADERS, 4 | UNRESOLVED_ACTION, LOCATION, 5 | } from './defaults'; 6 | import tagGettext from './extractors/tag-gettext'; 7 | import jsxtagGettext from './extractors/jsxtag-gettext'; 8 | import gettext from './extractors/gettext'; 9 | import ngettext from './extractors/ngettext'; 10 | import { 11 | parsePoData, getDefaultPoData, getNPlurals, getPluralFunc, 12 | } from './po-helpers'; 13 | import { ConfigValidationError, ConfigError } from './errors'; 14 | import { validateConfig, configSchema } from './config'; 15 | 16 | const { FAIL, WARN, SKIP } = UNRESOLVED_ACTION; 17 | 18 | const DEFAULT_EXTRACTORS = [tagGettext, jsxtagGettext, gettext, ngettext]; 19 | 20 | function logAction(message, level = SKIP) { 21 | /* eslint-disable no-console */ 22 | switch (level) { 23 | case FAIL: 24 | throw new Error(message); 25 | case SKIP: 26 | break; 27 | case WARN: 28 | // TODO: use logger that can log to console or file or stdout 29 | console.warn(message); 30 | break; 31 | default: 32 | // TODO: use logger that can log to console or file or stdout 33 | console.warn(message); 34 | } 35 | } 36 | 37 | class C3poContext { 38 | constructor(config) { 39 | this.config = config || {}; 40 | const [validationResult, errorsText] = validateConfig(this.config, configSchema); 41 | if (!validationResult) { 42 | throw new ConfigValidationError(errorsText); 43 | } 44 | this.clear(); 45 | if (!this.config.defaultLang) { 46 | this.config.defaultLang = 'en'; 47 | } 48 | this.setPoData(); 49 | Object.freeze(this.config); 50 | } 51 | 52 | clear() { 53 | this.aliases = {}; 54 | this.imports = new Set(); 55 | } 56 | 57 | getAliasesForFunc(ttagFuncName) { 58 | // TODO: implement possibility to overwrite or add aliases in config; 59 | const defaultAlias = FUNC_TO_ALIAS_MAP[ttagFuncName]; 60 | const alias = this.aliases[ttagFuncName] || defaultAlias; 61 | if (!alias) { 62 | throw new ConfigError(`Alias for function ${ttagFuncName} was not found ${ 63 | JSON.stringify(FUNC_TO_ALIAS_MAP)}`); 64 | } 65 | return Array.isArray(alias) ? alias : [alias]; 66 | } 67 | 68 | hasAliasForFunc(ttagFuncName, fn) { 69 | const aliases = this.getAliasesForFunc(ttagFuncName); 70 | return aliases.includes(fn); 71 | } 72 | 73 | setAliases(aliases) { 74 | this.aliases = aliases; 75 | } 76 | 77 | addAlias(funcName, alias) { 78 | if (this.aliases[funcName]) { 79 | this.aliases[funcName].push(alias); 80 | } else { 81 | this.aliases[funcName] = [alias]; 82 | } 83 | } 84 | 85 | setImports(imports) { 86 | this.imports = imports; 87 | } 88 | 89 | hasImport = (alias) => { 90 | const isInDiscover = this.config.discover && this.config.discover.indexOf(alias) !== -1; 91 | return this.imports.has(alias) || isInDiscover; 92 | } 93 | 94 | addImport(importName) { 95 | this.imports.add(importName); 96 | } 97 | 98 | getExtractors() { 99 | // TODO: implement possibility to specify additional extractors in config; 100 | return DEFAULT_EXTRACTORS; 101 | } 102 | 103 | getDefaultHeaders() { 104 | const headers = { ...DEFAULT_HEADERS }; 105 | headers['plural-forms'] = getPluralFormsHeader(this.config.defaultLang); 106 | return headers; 107 | } 108 | 109 | getPluralsCount() { 110 | if (this.poData && this.poData.headers) { 111 | return getNPlurals(this.poData.headers); 112 | } 113 | return getPluralsNumForLang(this.config.defaultLang); 114 | } 115 | 116 | getPluralFormula() { 117 | if (this.poData && this.poData.headers) { 118 | return getPluralFunc(this.poData.headers); 119 | } 120 | return getFormula(this.config.defaultLang); 121 | } 122 | 123 | getLocation() { 124 | return (this.config.extract && this.config.extract.location) || LOCATION.FULL; 125 | } 126 | 127 | getOutputFilepath() { 128 | return (this.config.extract && this.config.extract.output) || DEFAULT_POT_OUTPUT; 129 | } 130 | 131 | getPoFilePath() { 132 | return this.config.resolve && this.config.resolve.translations; 133 | } 134 | 135 | isExtractMode() { 136 | return Boolean(this.config.extract); 137 | } 138 | 139 | isNumberedExpressions() { 140 | return Boolean(this.config.numberedExpressions); 141 | } 142 | 143 | isResolveMode() { 144 | return Boolean(this.config.resolve); 145 | } 146 | 147 | noTranslationAction(message) { 148 | if (!this.isResolveMode()) { 149 | return; 150 | } 151 | if (this.config.resolve && this.config.resolve.translations === 'default') { 152 | return; 153 | } 154 | logAction(message, this.config.resolve.unresolved); 155 | } 156 | 157 | validationFailureAction(funcName, message) { 158 | const level = ( 159 | this.config.extractors 160 | && this.config.extractors[funcName] 161 | && this.config.extractors[funcName].invalidFormat) || FAIL; 162 | logAction(message, level); 163 | } 164 | 165 | isDedent() { 166 | if (this.config.dedent === undefined) { 167 | return true; 168 | } 169 | return this.config.dedent; 170 | } 171 | 172 | devCommentsEnabled() { 173 | return Boolean(this.config.addComments); 174 | } 175 | 176 | getAddComments() { 177 | return this.config.addComments; 178 | } 179 | 180 | isSortedByMsgid() { 181 | return Boolean(this.config.sortByMsgid); 182 | } 183 | 184 | isSortedByMsgctxt() { 185 | return Boolean(this.config.sortByMsgctxt); 186 | } 187 | 188 | isAllowFuzzy() { 189 | return Boolean(this.config.allowFuzzy); 190 | } 191 | 192 | setPoData() { 193 | const poFilePath = this.getPoFilePath(); 194 | if (!poFilePath || poFilePath === 'default') { 195 | this.poData = getDefaultPoData(this.getDefaultHeaders()); 196 | return; 197 | } 198 | this.poData = parsePoData(poFilePath); 199 | } 200 | 201 | getTranslations(gettextContext = '') { 202 | return this.poData.translations[gettextContext] || {}; 203 | } 204 | } 205 | 206 | export default C3poContext; 207 | -------------------------------------------------------------------------------- /src/extractors/ngettext.js: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import tpl from '@babel/template'; 3 | import { PO_PRIMITIVES } from '../defaults'; 4 | import { 5 | dedentStr, template2Msgid, ast2Str, validateAndFormatMsgid, getQuasiStr, strToQuasi, 6 | } from '../utils'; 7 | import { pluralFnBody, makePluralFunc, hasUsefulInfo } from '../po-helpers'; 8 | import { ValidationError } from '../errors'; 9 | 10 | const NAME = 'ngettext'; 11 | const { MSGID, MSGSTR, MSGID_PLURAL } = PO_PRIMITIVES; 12 | 13 | function getMsgid(node, context) { 14 | const [msgidTag, ..._] = node.arguments.slice(0, -1); 15 | return template2Msgid(msgidTag, context); 16 | } 17 | 18 | function validateNPlural(exp) { 19 | if (!t.isIdentifier(exp) && !t.isNumericLiteral(exp) && !t.isMemberExpression(exp)) { 20 | throw new ValidationError(`${exp.type} '${ast2Str(exp)}' can not be used as plural argument`); 21 | } 22 | } 23 | 24 | const validate = (node, context) => { 25 | const msgidTag = node.arguments[0]; 26 | const msgidAliases = context.getAliasesForFunc('msgid'); 27 | if (!t.isTaggedTemplateExpression(msgidTag)) { 28 | throw new ValidationError( 29 | msgidAliases.length > 1 30 | ? `First argument must be tagged template expression. You should use one of '${msgidAliases}' tag` 31 | : `First argument must be tagged template expression. You should use '${msgidAliases[0]}' tag`, 32 | ); 33 | } 34 | if (!context.hasAliasForFunc('msgid', msgidTag.tag.name)) { 35 | throw new ValidationError( 36 | msgidAliases.length > 1 37 | ? `Expected one of '${msgidAliases}' for the first argument but not '${msgidTag.tag.name}'` 38 | : `Expected '${msgidAliases[0]}' for the first argument but not '${msgidTag.tag.name}'`, 39 | ); 40 | } 41 | const tags = node.arguments.slice(1, -1); 42 | 43 | // will throw validation error if tags has expressions with wrong format 44 | tags.forEach((quasi) => template2Msgid({ quasi }, context)); 45 | 46 | if (!context.isNumberedExpressions()) { 47 | validateNPlural(node.arguments[node.arguments.length - 1]); 48 | } 49 | const msgid = template2Msgid(msgidTag, context); 50 | if (!hasUsefulInfo(msgid)) { 51 | throw new ValidationError(`Can not translate '${getQuasiStr(msgidTag)}'`); 52 | } 53 | }; 54 | 55 | function match(node, context) { 56 | return (t.isCallExpression(node) 57 | && t.isIdentifier(node.callee) 58 | && context.hasAliasForFunc(NAME, node.callee.name) 59 | && node.arguments.length > 0 60 | && t.isTaggedTemplateExpression(node.arguments[0])); 61 | } 62 | 63 | function extract(node, context) { 64 | const tags = node.arguments.slice(0, -1); 65 | const msgid = context.isDedent() 66 | ? dedentStr(template2Msgid(tags[0], context)) 67 | : template2Msgid(tags[0], context); 68 | const nplurals = context.getPluralsCount(); 69 | if (tags.length !== nplurals) { 70 | throw new ValidationError(`Expected to have ${nplurals} plural forms but have ${tags.length} instead`); 71 | } 72 | // TODO: handle case when only 1 plural form 73 | const msgidPlural = context.isDedent() 74 | ? dedentStr(template2Msgid({ quasi: tags[1] }, context)) 75 | : template2Msgid({ quasi: tags[1] }, context); 76 | const translate = { 77 | [MSGID]: msgid, 78 | [MSGID_PLURAL]: msgidPlural, 79 | [MSGSTR]: [], 80 | }; 81 | 82 | for (let i = 0; i < nplurals; i++) { 83 | translate[MSGSTR][i] = ''; 84 | } 85 | 86 | return translate; 87 | } 88 | 89 | function ngettextTemplate(ngettext, pluralForm) { 90 | return tpl(`function NGETTEXT(n, args) { ${pluralFnBody(pluralForm)} }`)({ NGETTEXT: ngettext }); 91 | } 92 | 93 | function getNgettextUID(state, pluralFunc) { 94 | /* eslint-disable no-underscore-dangle */ 95 | if (!state.file.__ngettextUid) { 96 | const uid = state.file.scope.generateUidIdentifier('tag_ngettext'); 97 | state.file.path.unshiftContainer('body', 98 | ngettextTemplate(uid, pluralFunc)); 99 | state.file.__ngettextUid = uid; 100 | } 101 | return state.file.__ngettextUid; 102 | } 103 | 104 | function resolveDefault(node, context, state) { 105 | const tagArg = node.arguments[node.arguments.length - 1]; 106 | node.arguments[0] = node.arguments[0].quasi; 107 | const args = node.arguments.slice(0, -1).map((quasi) => { 108 | const quasiStr = getQuasiStr({ quasi }); 109 | const dedentedStr = context.isDedent() ? dedentStr(quasiStr) : quasiStr; 110 | return tpl.ast(strToQuasi(dedentedStr)).expression; 111 | }); 112 | 113 | const nplurals = context.getPluralsCount(); 114 | 115 | while (nplurals > args.length) { 116 | const last = args[args.length - 1]; 117 | args.push(t.templateLiteral(last.quasis, last.expressions)); 118 | } 119 | 120 | return tpl('NGETTEXT(N, ARGS)')({ 121 | NGETTEXT: getNgettextUID(state, context.getPluralFormula()), 122 | N: tagArg, 123 | ARGS: t.arrayExpression(args), 124 | }); 125 | } 126 | 127 | function resolve(node, translationObj, context, state) { 128 | const [msgidTag, ..._] = node.arguments.slice(0, -1); 129 | 130 | const args = translationObj[MSGSTR]; 131 | const tagArg = node.arguments[node.arguments.length - 1]; 132 | const exprs = msgidTag.quasi.expressions.map(ast2Str); 133 | 134 | if (t.isLiteral(tagArg)) { 135 | const pluralFn = makePluralFunc(context.getPluralFormula()); 136 | const orig = validateAndFormatMsgid(pluralFn(tagArg.value, args), exprs); 137 | return tpl.ast(orig); 138 | } 139 | 140 | return tpl('NGETTEXT(N, ARGS)')({ 141 | NGETTEXT: getNgettextUID(state, context.getPluralFormula()), 142 | N: tagArg, 143 | ARGS: t.arrayExpression(args.map((l) => { 144 | let quasis; 145 | let expressions; 146 | if (context.isNumberedExpressions()) { 147 | const transNode = tpl.ast(strToQuasi(l)); 148 | quasis = transNode.expression.quasis; 149 | expressions = transNode.expression.expressions 150 | .map(({ value }) => value) 151 | .map((i) => msgidTag.quasi.expressions[i]); 152 | } else { 153 | const transNode = tpl.ast(validateAndFormatMsgid(l, exprs)); 154 | quasis = transNode.expression.quasis; 155 | expressions = transNode.expression.expressions; 156 | } 157 | return t.templateLiteral(quasis, expressions); 158 | })), 159 | }); 160 | } 161 | 162 | export default { 163 | match, extract, resolve, name: NAME, validate, resolveDefault, getMsgid, 164 | }; 165 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import * as bt from '@babel/types'; 3 | import dedent from 'dedent'; 4 | import generate from '@babel/generator'; 5 | import tpl from '@babel/template'; 6 | 7 | import { 8 | DISABLE_COMMENT, TTAGID, TTAG_MACRO_ID, INTERNAL_TTAG_MACRO_ID, 9 | } from './defaults'; 10 | import { ValidationError, NoExpressionError } from './errors'; 11 | 12 | const disableRegExp = new RegExp(`\\s${DISABLE_COMMENT}\\s`); 13 | 14 | const exprReg = /\$\{\s?[\w\W]+?\s}/; 15 | 16 | export function strHasExpr(str) { 17 | return exprReg.test(str); 18 | } 19 | 20 | export function quasiToStr(str) { 21 | return str.replace(/^`|`$/g, ''); 22 | } 23 | 24 | export function getQuasiStr(node) { 25 | return quasiToStr(generate(node.quasi).code); 26 | } 27 | 28 | export function ast2Str(ast) { 29 | return generate(ast).code; 30 | } 31 | 32 | export function strToQuasi(str) { 33 | return `\`${str}\``; 34 | } 35 | 36 | export function rmDirSync(path) { 37 | execSync(`rm -rf ${path}`); 38 | } 39 | 40 | export function hasExpressions(node) { 41 | return Boolean(node.quasi.expressions.length); 42 | } 43 | 44 | export function getMembersPath({ object, computed, property }) { 45 | /* eslint-disable no-use-before-define */ 46 | const obj = bt.isMemberExpression(object) ? getMembersPath(object) : expr2str(object); 47 | 48 | return computed ? `${obj}[${expr2str(property)}]` : `${obj}.${property.name}`; 49 | } 50 | 51 | function expr2str(expr) { 52 | let str; 53 | if (bt.isIdentifier(expr)) { 54 | str = expr.name; 55 | } else if (bt.isMemberExpression(expr)) { 56 | str = getMembersPath(expr); 57 | } else if (bt.isNumericLiteral(expr)) { 58 | str = expr.value; 59 | } else if (bt.isStringLiteral(expr)) { 60 | str = expr.extra.raw; 61 | } else if (bt.isThisExpression(expr)) { 62 | str = 'this'; 63 | } else { 64 | throw new ValidationError(`You can not use ${expr.type} '\${${ast2Str(expr)}}' in localized strings`); 65 | } 66 | 67 | return str; 68 | } 69 | 70 | export const getMsgid = (str, exprs) => str.reduce((s, l, i) => { 71 | const expr = exprs[i]; 72 | return (expr === undefined) ? s + l : `${s}${l}\${ ${expr2str(expr)} }`; 73 | }, ''); 74 | 75 | export const getMsgidNumbered = (str, exprs) => str.reduce((s, l, i) => { 76 | const expr = exprs[i]; 77 | return (expr === undefined) ? s + l : `${s}${l}\${ ${i} }`; 78 | }, ''); 79 | 80 | export const validateAndFormatMsgid = (msgid, exprNames) => { 81 | const msgidAST = tpl.ast(strToQuasi(msgid)); 82 | const msgidExprs = new Set(msgidAST.expression.expressions.map(ast2Str)); 83 | exprNames.forEach((exprName) => { 84 | if (!msgidExprs.has(exprName)) { 85 | throw new NoExpressionError(`Expression '${exprName}' is not found in the localized string '${msgid}'.`); 86 | } 87 | }); 88 | 89 | // need to regenerate template to fix spaces between in ${} 90 | // because translator can accidentally add extra space or remove 91 | return generate(msgidAST).code.replace(/;$/, ''); 92 | }; 93 | 94 | export function template2Msgid(node, context) { 95 | const strs = node.quasi.quasis.map(({ value: { cooked } }) => cooked); 96 | const exprs = node.quasi.expressions || []; 97 | 98 | if (exprs.length) { 99 | return context.isNumberedExpressions() 100 | ? getMsgidNumbered(strs, exprs) 101 | : getMsgid(strs, exprs); 102 | } 103 | return node.quasi.quasis[0].value.cooked; 104 | } 105 | 106 | export function isInDisabledScope(node, disabledScopes) { 107 | let { scope } = node; 108 | while (scope) { 109 | if (disabledScopes.has(scope.uid)) { 110 | return true; 111 | } 112 | scope = scope.parent; 113 | } 114 | return false; 115 | } 116 | 117 | export function hasDisablingComment(node) { 118 | if (!node.body || !node.body.length) { 119 | return false; 120 | } 121 | for (const { leadingComments } of node.body) { 122 | if (!leadingComments) { 123 | continue; 124 | } 125 | for (const { value } of leadingComments) { 126 | if (value.match(disableRegExp)) { 127 | return true; 128 | } 129 | } 130 | } 131 | return false; 132 | } 133 | 134 | export function isTtagImport(node) { 135 | const { value } = node.source; 136 | return value === TTAGID 137 | || value.startsWith(`npm:${TTAGID}`) 138 | || value === TTAG_MACRO_ID 139 | || value.startsWith(`npm:${TTAG_MACRO_ID}`) 140 | || value === INTERNAL_TTAG_MACRO_ID 141 | || value.startsWith(`npm:${INTERNAL_TTAG_MACRO_ID}`); 142 | } 143 | 144 | export function isTtagRequire(node) { 145 | return bt.isCallExpression(node.init) 146 | && node.init.callee.name === 'require' 147 | && bt.isObjectPattern(node.id) 148 | && node.init.arguments.length === 1 149 | && (node.init.arguments[0].value === TTAGID 150 | || node.init.arguments[0].value === TTAG_MACRO_ID 151 | || node.init.arguments[0].value === INTERNAL_TTAG_MACRO_ID); 152 | } 153 | 154 | export function hasImportSpecifier(node) { 155 | return node.specifiers && node.specifiers.some(bt.isImportSpecifier); 156 | } 157 | 158 | export function dedentStr(str) { 159 | if (str.match(/\n/) !== null) { 160 | return dedent(str); 161 | } 162 | return str; 163 | } 164 | 165 | export function poReferenceComparator(firstPoRef, secondPoRef) { 166 | if (/.*:\d+$/.test(firstPoRef)) { 167 | // reference has a form path/to/file.js:line_number 168 | const firstIdx = firstPoRef.lastIndexOf(':'); 169 | const firstFileRef = firstPoRef.substring(0, firstIdx); 170 | const firstLineNum = Number(firstPoRef.substring(firstIdx + 1)); 171 | const secondIdx = secondPoRef.lastIndexOf(':'); 172 | const secondFileRef = secondPoRef.substring(0, secondIdx); 173 | const secondLineNum = Number(secondPoRef.substring(secondIdx + 1)); 174 | if (firstFileRef !== secondFileRef) { 175 | if (firstFileRef < secondFileRef) { 176 | return -1; 177 | } 178 | return 1; 179 | } 180 | // else 181 | if (firstLineNum < secondLineNum) { 182 | return -1; 183 | } if (firstLineNum > secondLineNum) { 184 | return 1; 185 | } 186 | return 0; 187 | } 188 | // else 189 | // reference has a form path/to/file.js 190 | if (firstPoRef < secondPoRef) { 191 | return -1; 192 | } if (firstPoRef > secondPoRef) { 193 | return 1; 194 | } 195 | return 0; 196 | } 197 | 198 | export function createFnStub(name) { 199 | return tpl('function NAME(){}')({ NAME: name }); 200 | } 201 | -------------------------------------------------------------------------------- /src/po-helpers.js: -------------------------------------------------------------------------------- 1 | import * as bt from '@babel/types'; 2 | import fs from 'fs'; 3 | import gettextParser from 'gettext-parser'; 4 | import dedent from 'dedent'; 5 | import { DEFAULT_HEADERS, PO_PRIMITIVES, LOCATION } from './defaults'; 6 | import { strHasExpr } from './utils'; 7 | 8 | export function buildPotData(translations) { 9 | const data = { 10 | charset: 'UTF-8', 11 | headers: DEFAULT_HEADERS, 12 | translations: { 13 | '': { 14 | }, 15 | }, 16 | }; 17 | 18 | for (const trans of translations) { 19 | const ctx = trans[PO_PRIMITIVES.MSGCTXT] || ''; 20 | if (!data.translations[ctx]) { 21 | data.translations[ctx] = {}; 22 | } 23 | 24 | if (!data.translations[ctx][trans.msgid]) { 25 | data.translations[ctx][trans.msgid] = trans; 26 | continue; 27 | } 28 | 29 | const oldTrans = data.translations[ctx][trans.msgid]; 30 | 31 | // merge references 32 | if (oldTrans.comments && oldTrans.comments.reference 33 | && trans.comments && trans.comments.reference 34 | && !oldTrans.comments.reference.includes(trans.comments.reference)) { 35 | oldTrans.comments.reference = `${oldTrans.comments.reference}\n${trans.comments.reference}`; 36 | } 37 | } 38 | 39 | return data; 40 | } 41 | 42 | export function applyReference(poEntry, node, filepath, location) { 43 | if (!poEntry.comments) { 44 | poEntry.comments = {}; 45 | } 46 | 47 | let reference = null; 48 | 49 | switch (location) { 50 | case LOCATION.FILE: 51 | reference = filepath; break; 52 | case LOCATION.NEVER: 53 | reference = null; break; 54 | default: 55 | reference = `${filepath}:${node.loc.start.line}`; 56 | } 57 | 58 | poEntry.comments.reference = reference; 59 | return poEntry; 60 | } 61 | 62 | /** 63 | * Find comments linked to a translation string 64 | * Some comments are hidden inside expressions, ex: when you put a comment before 65 | * the string inside JSX. 66 | *

67 | * { 68 | * // translator: message 69 | * c('helle').t`world` 70 | * } 71 | *

72 | * So we need to look for the parent container of the current TaggedTemplateExpression 73 | * to find the comments 74 | * @param Object NodePath current processing AST node 75 | * @param Array comments current comments found via NodePath.node.leadingComments 76 | * @returns Array comments 77 | */ 78 | const extractComment = (nodePath, comments = []) => { 79 | // Can be null cf https://github.com/babel/babel/blob/main/packages/babel-types/scripts/generators/typescript-legacy.js#L39 80 | if (comments?.length) { 81 | return comments; 82 | } 83 | 84 | if (nodePath.parent?.type === 'JSXExpressionContainer') { 85 | return nodePath.parent.expression.leadingComments || []; 86 | } 87 | 88 | return []; 89 | }; 90 | 91 | const tagRegex = {}; 92 | export function applyExtractedComments(poEntry, nodePath, tag) { 93 | if (!poEntry.comments) { 94 | poEntry.comments = {}; 95 | } 96 | 97 | const { node } = nodePath; 98 | 99 | if (!( 100 | bt.isStatement(node) 101 | || bt.isDeclaration(node) 102 | )) { 103 | // Collect parents' comments 104 | // 105 | applyExtractedComments(poEntry, nodePath.parentPath, tag); 106 | } 107 | 108 | const comments = extractComment(nodePath, node.leadingComments); 109 | let transComments = comments ? comments.map((c) => c.value) : []; 110 | if (tag) { 111 | if (!tagRegex[tag]) { 112 | tagRegex[tag] = new RegExp(`^\\s*${tag}\\s*(.*?)\\s*$`); 113 | } 114 | transComments = transComments 115 | .map((c) => c.match(tagRegex[tag])) 116 | .filter((match) => Boolean(match)) 117 | .map((c) => dedent(c[1])); 118 | } 119 | 120 | if (transComments.length === 0) return; 121 | 122 | if (poEntry.comments.extracted) { 123 | poEntry.comments.extracted += '\n'; 124 | } else { 125 | poEntry.comments.extracted = ''; 126 | } 127 | poEntry.comments.extracted += transComments.join('\n'); 128 | } 129 | 130 | export function applyFormat(poEntry) { 131 | const msgid = poEntry[PO_PRIMITIVES.MSGID]; 132 | const hasExprs = strHasExpr(msgid); 133 | if (!hasExprs) { 134 | return poEntry; 135 | } 136 | if (!poEntry.comments) { 137 | poEntry.comments = {}; 138 | } 139 | if (poEntry.comments.flag) { 140 | poEntry.comments.flag = `${poEntry.comments.flag}\njavascript-format`; 141 | } else { 142 | poEntry.comments.flag = 'javascript-format'; 143 | } 144 | return poEntry; 145 | } 146 | 147 | export function makePotStr(data) { 148 | return gettextParser.po.compile(data); 149 | } 150 | 151 | const poDataCache = {}; 152 | // This function must use cache, because: 153 | // 1. readFileSync is blocking operation (babel transforms are sync for now) 154 | // 2. po data parse is quite CPU intensive operation that can also block 155 | export function parsePoData(filepath) { 156 | if (poDataCache[filepath]) return poDataCache[filepath]; 157 | const poRaw = fs.readFileSync(filepath); 158 | const parsedPo = gettextParser.po.parse(poRaw.toString()); 159 | const { translations } = parsedPo; 160 | const { headers } = parsedPo; 161 | const data = { translations, headers }; 162 | poDataCache[filepath] = data; 163 | return data; 164 | } 165 | 166 | const pluralRegex = /\splural ?=?([\s\S]*);?/; 167 | export function getPluralFunc(headers) { 168 | try { 169 | const pluralHeader = headers['plural-forms'] || headers['Plural-Forms']; 170 | let pluralFn = pluralRegex.exec(pluralHeader)[1]; 171 | if (pluralFn[pluralFn.length - 1] === ';') { 172 | pluralFn = pluralFn.slice(0, -1); 173 | } 174 | return pluralFn; 175 | } catch (err) { 176 | throw new Error(`Failed to parse plural func from headers "${JSON.stringify(headers)}"\n`); 177 | } 178 | } 179 | 180 | export function getNPlurals(headers) { 181 | const pluralHeader = headers['plural-forms'] || headers['Plural-Forms']; 182 | const nplurals = /nplurals ?= ?(\d)/.exec(pluralHeader)[1]; 183 | return parseInt(nplurals, 10); 184 | } 185 | 186 | export function hasTranslations(translationObj) { 187 | return translationObj[PO_PRIMITIVES.MSGSTR].reduce((r, t) => r && t.length, true); 188 | } 189 | 190 | export function isFuzzy(translationObj) { 191 | return ( 192 | translationObj && translationObj.comments 193 | && translationObj.comments.flag 194 | && translationObj.comments.flag.includes('fuzzy')); 195 | } 196 | 197 | export function pluralFnBody(pluralStr) { 198 | return `return args[+ (${pluralStr})];`; 199 | } 200 | 201 | const fnCache = {}; 202 | export function makePluralFunc(pluralStr) { 203 | /* eslint-disable no-new-func */ 204 | let fn = fnCache[pluralStr]; 205 | if (!fn) { 206 | fn = new Function('n', 'args', pluralFnBody(pluralStr)); 207 | fnCache[pluralStr] = fn; 208 | } 209 | return fn; 210 | } 211 | 212 | export function getDefaultPoData(headers) { 213 | return { headers, translations: { '': {} } }; 214 | } 215 | 216 | const nonTextRegexp = /\${.*?}|\d|\s|[.,\/#!$%\^&\*;{}=\-_`~()]/g; 217 | export function hasUsefulInfo(text) { 218 | const withoutExpressions = text.replace(nonTextRegexp, ''); 219 | return Boolean(withoutExpressions.match(/\S/)); 220 | } 221 | -------------------------------------------------------------------------------- /tests/unit/test_po-helpers.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { 3 | getNPlurals, getPluralFunc, makePluralFunc, applyReference, 4 | hasUsefulInfo, buildPotData, applyFormat, isFuzzy, 5 | } from 'src/po-helpers'; 6 | import { PO_PRIMITIVES, LOCATION } from 'src/defaults'; 7 | 8 | const { MSGID } = PO_PRIMITIVES; 9 | 10 | describe('po-helpers getNPlurals', () => { 11 | it('should extract number of plurals', () => { 12 | const headers = { 13 | 'plural-forms': 'nplurals=3; plural=(n!=1);', 14 | }; 15 | expect(getNPlurals(headers)).to.eql(3); 16 | }); 17 | }); 18 | 19 | describe('po-helpers getPluralFunc', () => { 20 | it('should extract en plural function', () => { 21 | const headers = { 22 | 'plural-forms': 'nplurals=2; plural=(n!=1);', 23 | }; 24 | expect(getPluralFunc(headers)).to.eql('(n!=1)'); 25 | }); 26 | it('should extract slovak plural function', () => { 27 | const headers = { 28 | 'plural-forms': 'nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;', 29 | }; 30 | expect(getPluralFunc(headers)).to.eql('(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2'); 31 | }); 32 | it('should extract ukrainian plural function', () => { 33 | /* eslint-disable max-len */ 34 | const uk = 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);'; 35 | const expected = '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'; 36 | const headers = { 'plural-forms': uk }; 37 | expect(getPluralFunc(headers)).to.eql(expected); 38 | }); 39 | it('should extract plural function without semicolon', () => { 40 | const headers = { 41 | 'plural-forms': 'nplurals=2; plural=(n!=1)', 42 | }; 43 | expect(getPluralFunc(headers)).to.eql('(n!=1)'); 44 | }); 45 | it('should throw useful error message when headers are in wrong format', () => { 46 | const headers = {}; 47 | const fn = () => getPluralFunc(headers); 48 | expect(fn).to.throw('Failed to parse plural func'); 49 | }); 50 | }); 51 | 52 | describe('po-helpers makePluralFunc', () => { 53 | it('should return proper plural func', () => { 54 | const fn = makePluralFunc('n!=1'); 55 | expect(fn(1, ['banana', 'bananas'])).to.eql('banana'); 56 | }); 57 | }); 58 | 59 | describe('po-helpers applyReference', () => { 60 | const poEntry = {}; 61 | const filepath = 'filepath'; 62 | const node = { loc: { start: { line: 3 } } }; 63 | 64 | it('should return file name and line number', () => { 65 | const expected = { comments: { reference: 'filepath:3' } }; 66 | expect(applyReference(poEntry, node, filepath, LOCATION.FULL)).to.eql(expected); 67 | }); 68 | 69 | it('should return file name', () => { 70 | const expected = { comments: { reference: 'filepath' } }; 71 | expect(applyReference(poEntry, node, filepath, LOCATION.FILE)).to.eql(expected); 72 | }); 73 | 74 | it('should return no lines', () => { 75 | const expected = { comments: { reference: null } }; 76 | expect(applyReference(poEntry, node, filepath, LOCATION.NEVER)).to.eql(expected); 77 | }); 78 | }); 79 | 80 | describe('po-helpers applyFormat', () => { 81 | it('should apply javascript format if has expressions', () => { 82 | const poEntry = { [MSGID]: 'test ${ a }' }; 83 | const withFormat = applyFormat(poEntry); 84 | expect(withFormat.comments).to.have.property('flag', 'javascript-format'); 85 | }); 86 | it('should not apply javascript format if has no expressions', () => { 87 | const poEntry = { [MSGID]: 'test' }; 88 | const withFormat = applyFormat(poEntry); 89 | expect(withFormat).to.not.have.property('comments'); 90 | }); 91 | it('should not apply javascript format to existing flags', () => { 92 | const poEntry = { [MSGID]: 'test ${ a }', comments: { flag: 'fuzzy' } }; 93 | const withFormat = applyFormat(poEntry); 94 | expect(withFormat.comments.flag).to.eql('fuzzy\njavascript-format'); 95 | }); 96 | }); 97 | 98 | describe('po-helpers hasUsefulInfo', () => { 99 | it('should return false if has no letter characters', () => { 100 | const input = ' '; 101 | expect(hasUsefulInfo(input)).to.be.false; 102 | }); 103 | it('should return false if has no letter characters but has numbers', () => { 104 | const input = ' 9'; 105 | expect(hasUsefulInfo(input)).to.be.false; 106 | }); 107 | it('should return false if has no letter characters but has punctuation', () => { 108 | const input = ' . * '; 109 | expect(hasUsefulInfo(input)).to.be.false; 110 | }); 111 | it('should return false if has no letter characters but has expressions', () => { 112 | const input = '${name} ${surname}'; 113 | expect(hasUsefulInfo(input)).to.be.false; 114 | }); 115 | it('should return true if has letter characters and expressions', () => { 116 | const input = 'tell us your ${name} and your ${surname}'; 117 | expect(hasUsefulInfo(input)).to.be.true; 118 | }); 119 | it('should return true for expressions with non ascii characters', () => { 120 | const input = '${discountLabelText} с ${dateStartText} по ${dateEndText}'; 121 | expect(hasUsefulInfo(input)).to.be.true; 122 | }); 123 | }); 124 | 125 | describe('po-helpers isFuzzy', () => { 126 | it('should detect fuzzy if has fuzzy tag', () => { 127 | const msg = { 128 | msgid: '{name} fuzzy name', 129 | comments: { 130 | reference: 'tests/fixtures/fixture.js:223', 131 | flag: 'fuzzy', 132 | }, 133 | msgstr: [ 134 | '{surname} fuzzy name', 135 | ], 136 | }; 137 | expect(isFuzzy(msg)).to.be.true; 138 | }); 139 | 140 | it('should detect fuzzy if has fuzzy and other tags', () => { 141 | const msg = { 142 | msgid: '{name} fuzzy name', 143 | comments: { 144 | reference: 'tests/fixtures/fixture.js:223', 145 | flag: 'fuzzy, javascript-format', 146 | }, 147 | msgstr: [ 148 | '{surname} fuzzy name', 149 | ], 150 | }; 151 | expect(isFuzzy(msg)).to.be.true; 152 | }); 153 | }); 154 | 155 | describe('po-helpers buildPotData', () => { 156 | it('should build po data', () => { 157 | const msg1 = { 158 | msgid: 'test', 159 | msgstr: 'test1', 160 | comments: { 161 | reference: 'path/to/file/1.txt:123', 162 | }, 163 | }; 164 | const expected = { 165 | charset: 'UTF-8', 166 | headers: { 167 | 'content-type': 'text/plain; charset=UTF-8', 168 | 'plural-forms': 'nplurals=2; plural=(n!=1);', 169 | }, 170 | translations: { '': { test: msg1 } }, 171 | }; 172 | const result = buildPotData([msg1]); 173 | expect(result).to.eql(expected); 174 | }); 175 | 176 | it('should build po data with multiple contexts', () => { 177 | const msg1 = { 178 | msgid: 'test', 179 | msgstr: 'test1', 180 | comments: { 181 | reference: 'path/to/file/1.txt:123', 182 | }, 183 | }; 184 | 185 | const msg2 = { 186 | msgid: 'test2', 187 | msgstr: 'test2', 188 | msgctxt: 'ctx2', 189 | comments: { 190 | reference: 'path/to/file/1.txt:123', 191 | }, 192 | }; 193 | 194 | const expected = { 195 | charset: 'UTF-8', 196 | headers: { 197 | 'content-type': 'text/plain; charset=UTF-8', 198 | 'plural-forms': 'nplurals=2; plural=(n!=1);', 199 | }, 200 | translations: { '': { test: msg1 }, ctx2: { test2: msg2 } }, 201 | }; 202 | const result = buildPotData([msg1, msg2]); 203 | expect(result).to.eql(expected); 204 | }); 205 | 206 | it('should accumulate references', () => { 207 | const msg1 = { 208 | msgid: 'test', 209 | msgstr: 'test1', 210 | comments: { 211 | reference: 'path/to/file/1.txt:123', 212 | }, 213 | }; 214 | const msg2 = { 215 | msgid: 'test', 216 | msgstr: 'test1', 217 | comments: { 218 | reference: 'path/to/file/2.txt:124', 219 | }, 220 | }; 221 | 222 | const resultmsg = { 223 | msgid: 'test', 224 | msgstr: 'test1', 225 | comments: { 226 | reference: 'path/to/file/1.txt:123\npath/to/file/2.txt:124', 227 | }, 228 | }; 229 | 230 | const expected = { 231 | charset: 'UTF-8', 232 | headers: { 233 | 'content-type': 'text/plain; charset=UTF-8', 234 | 'plural-forms': 'nplurals=2; plural=(n!=1);', 235 | }, 236 | translations: { '': { test: resultmsg } }, 237 | }; 238 | const result = buildPotData([msg1, msg2]); 239 | expect(result).to.eql(expected); 240 | }); 241 | 242 | it('should not accumulate reference if already has', () => { 243 | const msg1 = { 244 | msgid: 'test', 245 | msgstr: 'test1', 246 | comments: { 247 | reference: 'path/to/file/1.txt:123', 248 | }, 249 | }; 250 | const result = buildPotData([msg1, msg1]); 251 | const ref = result.translations[''].test.comments.reference; 252 | expect(ref).to.not.eql('path/to/file/1.txt:123\npath/to/file/1.txt:123'); 253 | }); 254 | }); 255 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | import * as bt from '@babel/types'; 2 | import fs from 'fs'; 3 | import mkdirp from 'mkdirp'; 4 | import path from 'path'; 5 | 6 | import { ALIAS_TO_FUNC_MAP } from './defaults'; 7 | import { buildPotData, makePotStr } from './po-helpers'; 8 | import { extractPoEntry, getExtractor } from './extract'; 9 | import { 10 | hasDisablingComment, isInDisabledScope, isTtagImport, 11 | hasImportSpecifier, poReferenceComparator, isTtagRequire, createFnStub, 12 | } from './utils'; 13 | import { resolveEntries } from './resolve'; 14 | import { ValidationError } from './errors'; 15 | import TtagContext from './context'; 16 | import { 17 | isContextTagCall, isValidTagContext, isContextFnCall, 18 | isValidFnCallContext, 19 | } from './gettext-context'; 20 | 21 | let started = false; 22 | 23 | export function isStarted() { 24 | return started; 25 | } 26 | 27 | const potEntries = []; 28 | 29 | export default function ttagPlugin() { 30 | let context; 31 | let disabledScopes = new Set(); 32 | 33 | function tryMatchTag(cb) { 34 | return (nodePath, state) => { 35 | const { node } = nodePath; 36 | if (isContextTagCall(node, context) && isValidTagContext(nodePath)) { 37 | nodePath._C3PO_GETTEXT_CONTEXT = node.tag.object.arguments[0].value; 38 | nodePath._ORIGINAL_NODE = node; 39 | nodePath.node = bt.taggedTemplateExpression(node.tag.property, node.quasi); 40 | nodePath.node.loc = node.loc; 41 | } 42 | cb(nodePath, state); 43 | }; 44 | } 45 | 46 | function tryMatchCall(cb) { 47 | return (nodePath, state) => { 48 | const { node } = nodePath; 49 | if (isContextFnCall(node, context) && isValidFnCallContext(nodePath)) { 50 | nodePath._C3PO_GETTEXT_CONTEXT = node.callee.object.arguments[0].value; 51 | nodePath._ORIGINAL_NODE = node; 52 | nodePath.node = bt.callExpression(node.callee.property, node.arguments); 53 | nodePath.node.loc = node.loc; 54 | } 55 | cb(nodePath, state); 56 | }; 57 | } 58 | 59 | function extractOrResolve(nodePath, state) { 60 | if (isInDisabledScope(nodePath, disabledScopes)) { 61 | return; 62 | } 63 | 64 | const extractor = getExtractor(nodePath, context); 65 | if (!extractor) { 66 | return; 67 | } 68 | 69 | const aliases = context.getAliasesForFunc(extractor.name); 70 | const hasImport = aliases.find(context.hasImport); 71 | if (!hasImport 72 | // can be used in scope of context without import 73 | && !nodePath._C3PO_GETTEXT_CONTEXT) { 74 | return; 75 | } 76 | 77 | try { 78 | try { 79 | extractor.validate(nodePath.node, context); 80 | } catch (err) { 81 | if (err instanceof ValidationError) { 82 | context.validationFailureAction(extractor.name, err.message); 83 | return; 84 | } 85 | throw err; 86 | } 87 | if (context.isExtractMode()) { 88 | const poEntry = extractPoEntry(extractor, nodePath, context, state); 89 | poEntry && potEntries.push(poEntry); 90 | } 91 | 92 | if (context.isResolveMode()) { 93 | resolveEntries(extractor, nodePath, context, state); 94 | } 95 | } catch (err) { 96 | // TODO: handle specific instances of errors 97 | throw nodePath.buildCodeFrameError(`${err.message}\n${err.stack}`); 98 | } 99 | } 100 | 101 | return { 102 | post() { 103 | if (context && context.isExtractMode() && potEntries.length) { 104 | const poData = buildPotData(potEntries); 105 | 106 | // Here we sort reference entries, this could be useful 107 | // with conf. options extract.location: 'file' and sortByMsgid 108 | // which simplifies merge of .po files from different 109 | // branches of SCM such as git or mercurial. 110 | const ctxs = Object.keys(poData.translations); 111 | for (const ctx of ctxs) { 112 | const poEntries = poData.translations[ctx]; 113 | Object.keys(poEntries).forEach((k) => { 114 | const poEntry = poEntries[k]; 115 | // poEntry has a form: 116 | // { 117 | // msgid: 'message identifier', 118 | // msgstr: 'translation string', 119 | // comments: { 120 | // reference: 'path/to/file.js:line_num\npath/file.js:line_num' 121 | // } 122 | // } 123 | if (poEntry.comments && poEntry.comments.reference) { 124 | poEntry.comments.reference = poEntry.comments.reference 125 | .split('\n') 126 | .sort(poReferenceComparator) 127 | .join('\n'); 128 | } 129 | }); 130 | 131 | if (context.isSortedByMsgid()) { 132 | const oldPoData = poData.translations[ctx]; 133 | const newContext = {}; 134 | const keys = Object.keys(oldPoData).sort(); 135 | keys.forEach((k) => { 136 | newContext[k] = oldPoData[k]; 137 | }); 138 | poData.translations[ctx] = newContext; 139 | } 140 | } 141 | if (context.isSortedByMsgctxt()) { 142 | const unorderedTranslations = poData.translations; 143 | poData.translations = {}; 144 | for (const ctx of Object.keys(unorderedTranslations).sort()) { 145 | poData.translations[ctx] = unorderedTranslations[ctx]; 146 | } 147 | } 148 | 149 | const potStr = makePotStr(poData); 150 | const filepath = context.getOutputFilepath(); 151 | const dirPath = path.dirname(filepath); 152 | mkdirp.sync(dirPath); 153 | fs.writeFileSync(filepath, potStr); 154 | } 155 | }, 156 | visitor: { 157 | TaggedTemplateExpression: tryMatchTag(extractOrResolve), 158 | CallExpression: tryMatchCall(extractOrResolve), 159 | Program: { 160 | enter: (nodePath, state) => { 161 | started = true; 162 | if (!context) { 163 | context = new TtagContext(state.opts); 164 | } else { 165 | context.clear(); 166 | } 167 | disabledScopes = new Set(); 168 | if (hasDisablingComment(nodePath.node)) { 169 | disabledScopes.add(nodePath.scope.uid); 170 | } 171 | }, 172 | }, 173 | BlockStatement: (nodePath) => { 174 | if (hasDisablingComment(nodePath.node)) { 175 | disabledScopes.add(nodePath.scope.uid); 176 | } 177 | }, 178 | VariableDeclaration: (nodePath, state) => { 179 | nodePath.node.declarations.forEach((node) => { 180 | if (!isTtagRequire(node)) return; 181 | const stubs = []; 182 | // require calls 183 | node.id.properties 184 | .map(({ 185 | key: { name: keyName }, 186 | value: { name: valueName }, 187 | }) => [keyName, valueName]) 188 | .filter(([keyName, valueName]) => { 189 | const hasAlias = ALIAS_TO_FUNC_MAP[keyName]; 190 | if (!hasAlias) { 191 | stubs.push(valueName); 192 | } 193 | return hasAlias; 194 | }) 195 | .forEach(([keyName, valueName]) => { 196 | if (keyName !== valueName) { // if alias 197 | context.addAlias(ALIAS_TO_FUNC_MAP[keyName], valueName); 198 | context.addImport(valueName); 199 | } else { 200 | context.addImport(keyName); 201 | } 202 | }); 203 | if (context.isResolveMode()) { 204 | nodePath.remove(); 205 | stubs.forEach((stub) => { 206 | state.file.path.unshiftContainer('body', createFnStub(stub)); 207 | }); 208 | } 209 | }); 210 | }, 211 | ImportDeclaration: (nodePath, state) => { 212 | const { node } = nodePath; 213 | if (!isTtagImport(node)) return; 214 | if (!context) { 215 | context = new TtagContext(state.opts); 216 | } 217 | const stubs = []; 218 | if (hasImportSpecifier(node)) { 219 | node.specifiers 220 | .filter(bt.isImportSpecifier) 221 | .filter(({ imported, local }) => { 222 | const hasAlias = ALIAS_TO_FUNC_MAP[imported.name]; 223 | if (!hasAlias) { 224 | stubs.push(local.name); 225 | } 226 | return hasAlias; 227 | }) 228 | .forEach(({ imported, local }) => { 229 | context.addAlias(ALIAS_TO_FUNC_MAP[imported.name], local.name); 230 | context.addImport(local.name); 231 | }); 232 | } else { 233 | throw new Error('You should use ttag imports in form: "import { t } from \'ttag\'"'); 234 | } 235 | 236 | if (context.isResolveMode()) { 237 | nodePath.remove(); 238 | stubs.forEach((stub) => { 239 | state.file.path.unshiftContainer('body', createFnStub(stub)); 240 | }); 241 | } 242 | }, 243 | }, 244 | }; 245 | } 246 | -------------------------------------------------------------------------------- /tests/unit/test_utils.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import template from '@babel/template'; 3 | import { 4 | template2Msgid, validateAndFormatMsgid, isInDisabledScope, 5 | hasDisablingComment, dedentStr, getMsgid, poReferenceComparator, 6 | getMembersPath, getMsgidNumbered, strHasExpr, 7 | } from 'src/utils'; 8 | import { DISABLE_COMMENT } from 'src/defaults'; 9 | import C3poContext from 'src/context'; 10 | 11 | const testContext = new C3poContext({}); 12 | 13 | describe('utils template2Msgid', () => { 14 | it('should extract msgid with expressions', () => { 15 | const node = template('nt(n)`${n} banana ${ b }`')().expression; 16 | const expected = '${ n } banana ${ b }'; 17 | expect(template2Msgid(node, testContext)).to.eql(expected); 18 | }); 19 | 20 | it('should extract msgid without expressions', () => { 21 | const node = template('t`banana`')().expression; 22 | const expected = 'banana'; 23 | expect(template2Msgid(node, testContext)).to.eql(expected); 24 | }); 25 | 26 | it('should extract msgid with this in expressions', () => { 27 | const node = template('t`${this.user.name} test`')().expression; 28 | const expected = '${ this.user.name } test'; 29 | expect(template2Msgid(node, testContext)).to.eql(expected); 30 | }); 31 | 32 | it('should extract msgid from a computed properties', () => { 33 | const node = template('t`${ arr[0].name }`')().expression; 34 | const expected = '${ arr[0].name }'; 35 | expect(template2Msgid(node, testContext)).to.eql(expected); 36 | }); 37 | 38 | it('should extract msgid from a computed properties with string literals', () => { 39 | const node = template('t`${ arr["test"].name }`')().expression; 40 | const expected = '${ arr["test"].name }'; 41 | expect(template2Msgid(node, testContext)).to.eql(expected); 42 | }); 43 | 44 | it('should extract msgid from a computed properties with member expressions', () => { 45 | const node = template('t`${ arr[this.user.id].name }`')().expression; 46 | const expected = '${ arr[this.user.id].name }'; 47 | expect(template2Msgid(node, testContext)).to.eql(expected); 48 | }); 49 | 50 | it('should throw if has not supported expression type in computed properties', () => { 51 | const node = template('t`${ arr[fn()].name }`')().expression; 52 | const fn = () => template2Msgid(node, testContext); 53 | expect(fn).to.throw('You can not use CallExpression \'${fn()}\' in localized strings'); 54 | }); 55 | 56 | it('should extract msgid with a numeric literal', () => { 57 | const node = template('t`${ 1 }`')().expression; 58 | const expected = '${ 1 }'; 59 | expect(template2Msgid(node, testContext)).to.eql(expected); 60 | }); 61 | 62 | it('should extract msgid with a string literal', () => { 63 | const node = template('t`${ "test" }`')().expression; 64 | const expected = '${ "test" }'; 65 | expect(template2Msgid(node, testContext)).to.eql(expected); 66 | }); 67 | 68 | it('should extract msgid with this', () => { 69 | const node = template('t`${ this }`')().expression; 70 | const expected = '${ this }'; 71 | expect(template2Msgid(node, testContext)).to.eql(expected); 72 | }); 73 | 74 | it('should extract numbered expressions if numberedExpressions: true', () => { 75 | const node = template('t`Hello ${ name } ${ surname}`')().expression; 76 | const expected = 'Hello ${ 0 } ${ 1 }'; 77 | const ctx = new C3poContext({ numberedExpressions: true }); 78 | expect(template2Msgid(node, ctx)).to.eql(expected); 79 | }); 80 | }); 81 | 82 | describe('utils getMembersPath', () => { 83 | it('should get members path', () => { 84 | const node = template('user.name')().expression; 85 | const mPath = getMembersPath(node); 86 | expect(mPath).to.eql('user.name'); 87 | }); 88 | 89 | it('should get members path with "this"', () => { 90 | const node = template('this.user.name')().expression; 91 | const mPath = getMembersPath(node); 92 | expect(mPath).to.eql('this.user.name'); 93 | }); 94 | }); 95 | 96 | describe('utils validateAndFormatMsgid', () => { 97 | it('should extract original template with expressions', () => { 98 | const input = '${a} banana ${b}'; 99 | const expected = '`${a} banana ${b}`'; 100 | expect(validateAndFormatMsgid(input, ['a', 'b'])).to.eql(expected); 101 | }); 102 | 103 | it('should throw if not all expressions exist in translated strings', () => { 104 | const input = '${count} apples (translated)'; 105 | const func = () => validateAndFormatMsgid(input, ['appleCount']); 106 | expect(func).to.throw( 107 | 'NoExpressionError: Expression \'appleCount\' is not found in the localized string ' 108 | + '\'${count} apples (translated)\'.', 109 | ); 110 | }); 111 | 112 | it('should ignore left space inside expressions', () => { 113 | const input = '${a } banana ${ b}'; 114 | const expected = '`${a} banana ${b}`'; 115 | expect(validateAndFormatMsgid(input, ['a', 'b'])).to.eql(expected); 116 | }); 117 | 118 | it('should ignore white spaces inside expressions', () => { 119 | const input = '${a} banana ${ b }'; 120 | const expected = '`${a} banana ${b}`'; 121 | expect(validateAndFormatMsgid(input, ['a', 'b'])).to.eql(expected); 122 | }); 123 | 124 | it('should support computed properties', () => { 125 | const input = '${ a[value] } banana ${ b[value] }'; 126 | const expected = '`${a[value]} banana ${b[value]}`'; 127 | expect(validateAndFormatMsgid(input, ['a[value]', 'b[value]'])).to.eql(expected); 128 | }); 129 | 130 | it('should throw if not all expressions exist in translated strings', () => { 131 | const input = '${ fooxbar } apples (translated)'; 132 | const func = () => validateAndFormatMsgid(input, ['foo.bar']); 133 | expect(func).to.throw( 134 | 'NoExpressionError: Expression \'foo.bar\' is not found in the localized string ' 135 | + '\'${ fooxbar } apples (translated)\'.', 136 | ); 137 | }); 138 | 139 | it('should support [] in computed properties', () => { 140 | const input = "${ a[value[3]] } banana ${ b['[key]'] }"; 141 | const expected = "`${a[value[3]]} banana ${b['[key]']}`"; 142 | expect(validateAndFormatMsgid(input, ['a[value[3]]', "b['[key]']"])).to.eql(expected); 143 | }); 144 | 145 | it('should support string in computed properties', () => { 146 | const input = "${ a['^[a]$'] } banana"; 147 | const expected = "`${a['^[a]$']} banana`"; 148 | expect(validateAndFormatMsgid(input, ["a['^[a]$']"])).to.eql(expected); 149 | }); 150 | 151 | it('should support template strings in computed properties', () => { 152 | const input = '${ a[`foo-${ value }`] } banana'; 153 | const expected = '`${a[`foo-${value}`]} banana`'; 154 | expect(validateAndFormatMsgid(input, ['a[`foo-${value}`]'])).to.eql(expected); 155 | }); 156 | }); 157 | 158 | describe('utils isInDisabledScope', () => { 159 | it('should return true for disabled scope', () => { 160 | const disabledScopes = new Set([1, 2, 3]); 161 | const mockNode = { scope: { uid: 1 } }; 162 | expect(isInDisabledScope(mockNode, disabledScopes)).to.be.true; 163 | }); 164 | it('should return true if has disabled parent scope', () => { 165 | const disabledScopes = new Set([1, 2, 3]); 166 | const parentMock = { uid: 1 }; 167 | const mockNode = { scope: { uid: 4, parent: parentMock } }; 168 | expect(isInDisabledScope(mockNode, disabledScopes)).to.be.true; 169 | }); 170 | it('should return false if has no disabled scopes in chain', () => { 171 | const disabledScopes = new Set([1, 2, 3]); 172 | const parentMock = { uid: 5 }; 173 | const mockNode = { scope: { uid: 4, parent: parentMock } }; 174 | expect(isInDisabledScope(mockNode, disabledScopes)).to.be.false; 175 | }); 176 | }); 177 | 178 | describe('utils hasDisablingComment', () => { 179 | it('should return true for node that has matched comment', () => { 180 | const nodeMock = { body: [{ leadingComments: [{ value: ` ${DISABLE_COMMENT} ` }] }] }; 181 | expect(hasDisablingComment(nodeMock)).to.be.true; 182 | }); 183 | it('should return false for node that has no matched comment', () => { 184 | const nodeMock = { body: [{ leadingComments: [{ value: ` ${DISABLE_COMMENT}2 ` }] }] }; 185 | expect(hasDisablingComment(nodeMock)).to.be.false; 186 | }); 187 | it('should return false if node.body has no nodes', () => { 188 | const nodeMock = { body: [] }; 189 | expect(hasDisablingComment(nodeMock)).to.be.false; 190 | }); 191 | }); 192 | 193 | describe('utils dedentStr', () => { 194 | it('should remove indentation when has \\n symbol', () => { 195 | const input = `some 196 | string`; 197 | const expected = 'some\nstring'; 198 | expect(dedentStr(input)).to.eql(expected); 199 | }); 200 | it('should not remove indentation when has no \\n symbol', () => { 201 | const input = ' some'; 202 | expect(dedentStr(input)).to.eql(input); 203 | }); 204 | }); 205 | 206 | function getStrsExprs(node) { 207 | const strs = node.quasis.map(({ value: { cooked } }) => cooked); 208 | const exprs = node.expressions; 209 | return [strs, exprs]; 210 | } 211 | 212 | describe('utils getMsgid', () => { 213 | it('should extract msgid with expressions', () => { 214 | const node = template('`test ${ a }`')().expression; 215 | const [strs, exprs] = getStrsExprs(node); 216 | expect(getMsgid(strs, exprs)).to.be.eql('test ${ a }'); 217 | }); 218 | it('should extract msgid without expressions', () => { 219 | const node = template('`test`')().expression; 220 | const [strs, exprs] = getStrsExprs(node); 221 | expect(getMsgid(strs, exprs)).to.be.eql('test'); 222 | }); 223 | }); 224 | 225 | describe('utils strHasExpr', () => { 226 | it('should return true if has expressions', () => { 227 | const str1 = 'test match ${ a } and ${ b[0].name }'; 228 | expect(strHasExpr(str1)).to.be.true; 229 | const str2 = 'test match 2 ${ a }'; 230 | expect(strHasExpr(str2)).to.be.true; 231 | }); 232 | it('should return false if has no expressions', () => { 233 | const str = 'test no match'; 234 | expect(strHasExpr(str)).to.be.false; 235 | }); 236 | }); 237 | 238 | describe('utils getMsgidNumbered', () => { 239 | it('should extract msgid with expressions', () => { 240 | const node = template('`test ${ a } ${ b }`')().expression; 241 | const [strs, exprs] = getStrsExprs(node); 242 | expect(getMsgidNumbered(strs, exprs)).to.be.eql('test ${ 0 } ${ 1 }'); 243 | }); 244 | it('should extract msgid without expressions', () => { 245 | const node = template('`test`')().expression; 246 | const [strs, exprs] = getStrsExprs(node); 247 | expect(getMsgidNumbered(strs, exprs)).to.be.eql('test'); 248 | }); 249 | }); 250 | 251 | describe('utils poReferenceComparator', () => { 252 | it('# path/a.js should be less than # path/b.js', () => { 253 | expect(poReferenceComparator( 254 | '# path/a.js', 255 | '# path/b.js', 256 | )).to.be.eql(-1); 257 | }); 258 | 259 | it('# path/b.js should be less than # path/a.js', () => { 260 | expect(poReferenceComparator( 261 | '# path/b.js', 262 | '# path/a.js', 263 | )).to.be.eql(1); 264 | }); 265 | 266 | it('# path/a.js should be equal to # path/a.js', () => { 267 | expect(poReferenceComparator( 268 | '# path/a.js', 269 | '# path/a.js', 270 | )).to.be.eql(0); 271 | }); 272 | 273 | it('# path/a.js:5 should be less than # path/a.js:10', () => { 274 | expect(poReferenceComparator( 275 | '# path/a.js:5', 276 | '# path/a.js:10', 277 | )).to.be.eql(-1); 278 | }); 279 | 280 | it('# path/a.js:10 should be less than # path/a.js:5', () => { 281 | expect(poReferenceComparator( 282 | '# path/a.js:10', 283 | '# path/a.js:5', 284 | )).to.be.eql(1); 285 | }); 286 | 287 | it('# path/a.js:10 should be less than # path/a.js:10', () => { 288 | expect(poReferenceComparator( 289 | '# path/a.js:10', 290 | '# path/a.js:10', 291 | )).to.be.eql(0); 292 | }); 293 | }); 294 | --------------------------------------------------------------------------------