From 2cdb3a079dff19f3ca93c242c2aaa78cf724a3a2 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 8 Oct 2019 14:46:28 +0100 Subject: [PATCH] feat(ivy): i18n - implement compile-time inlining (#32881) This commit implements a tool that will inline translations and generate a translated copy of a set of application files from a set of translation files. PR Close #32881 --- .../cli-hello-world-ivy-i18n/yarn.lock | 95 +- package.json | 1 + packages/compiler/src/compiler.ts | 1 + packages/localize/BUILD.bazel | 4 +- packages/localize/index.ts | 2 +- packages/localize/localize.ts | 5 +- packages/localize/package.json | 8 + packages/localize/private.ts | 11 + packages/localize/src/tools/BUILD.bazel | 38 + .../localize/src/tools/src/diagnostics.ts | 18 + packages/localize/src/tools/src/file_utils.ts | 48 + .../asset_files/asset_translation_handler.ts | 31 + .../localize/src/tools/src/translate/main.ts | 104 +++ .../message_renderers/message_renderer.ts | 25 + .../target_message_renderer.ts | 56 ++ .../src/tools/src/translate/output_path.ts | 23 + .../source_files/es2015_translate_plugin.ts | 41 + .../source_files/es5_translate_plugin.ts | 38 + .../source_file_translation_handler.ts | 70 ++ .../source_files/source_file_utils.ts | 205 +++++ .../translation_file_loader.ts | 34 + .../translation_parsers/base_visitor.ts | 22 + .../translation_parsers/i18n_error.ts | 31 + .../simple_json_translation_parser.ts | 38 + .../translation_parsers/translation_parser.ts | 29 + .../translation_parsers/translation_utils.ts | 41 + .../xliff1/xliff1_message_serializer.ts | 56 ++ .../xliff1/xliff1_translation_parser.ts | 99 +++ .../xliff2/xliff2_message_serializer.ts | 96 ++ .../xliff2/xliff2_translation_parser.ts | 112 +++ .../src/tools/src/translate/translator.ts | 82 ++ packages/localize/src/tools/test/BUILD.bazel | 29 + .../asset_file_translation_handler_spec.ts | 45 + .../test/translate/integration/BUILD.bazel | 32 + .../integration/locales/messages.de.json | 6 + .../integration/locales/messages.es.xlf | 14 + .../integration/locales/messages.fr.xlf | 13 + .../test/translate/integration/main_spec.ts | 115 +++ .../integration/test_files/test-1.txt | 1 + .../integration/test_files/test-2.txt | 1 + .../translate/integration/test_files/test.js | 2 + .../tools/test/translate/output_path_spec.ts | 44 + .../es2015_translate_plugin_spec.ts | 185 ++++ .../source_files/es5_translate_plugin_spec.ts | 325 +++++++ .../source_file_translation_handler_spec.ts | 76 ++ .../source_files/source_file_utils_spec.ts | 184 ++++ .../translation_loader_spec.ts | 87 ++ .../simple_json/simple_json_spec.ts | 43 + .../xliff1/xliff1_translation_parser_spec.ts | 466 ++++++++++ .../xliff2/xliff2_translation_parser_spec.ts | 467 ++++++++++ .../tools/test/translate/translator_spec.ts | 96 ++ .../localize/src/tools/tsconfig-build.json | 23 + .../localize/src/tools/types/babel/LICENSE | 21 + .../localize/src/tools/types/babel/README.md | 11 + .../localize/src/tools/types/babel/core.d.ts | 734 ++++++++++++++++ .../src/tools/types/babel/generator.d.ts | 123 +++ .../src/tools/types/babel/template.d.ts | 78 ++ .../src/tools/types/babel/traverse.d.ts | 827 ++++++++++++++++++ packages/localize/src/translate.ts | 3 +- packages/localize/src/utils/BUILD.bazel | 17 + packages/localize/src/utils/index.ts | 10 + .../localize/src/utils/{ => src}/constants.ts | 0 .../localize/src/utils/{ => src}/messages.ts | 18 + .../src/utils/{ => src}/translations.ts | 46 +- packages/localize/src/utils/test/BUILD.bazel | 24 + .../utils => src/utils/test}/messages_spec.ts | 44 +- .../utils/test}/translations_spec.ts | 3 +- packages/localize/test/BUILD.bazel | 1 + packages/localize/test/translate_spec.ts | 4 +- test-main.js | 1 + tools/gulp-tasks/lint.js | 3 + yarn.lock | 204 ++++- 72 files changed, 5856 insertions(+), 34 deletions(-) create mode 100644 packages/localize/private.ts create mode 100644 packages/localize/src/tools/BUILD.bazel create mode 100644 packages/localize/src/tools/src/diagnostics.ts create mode 100644 packages/localize/src/tools/src/file_utils.ts create mode 100644 packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts create mode 100644 packages/localize/src/tools/src/translate/main.ts create mode 100644 packages/localize/src/tools/src/translate/message_renderers/message_renderer.ts create mode 100644 packages/localize/src/tools/src/translate/message_renderers/target_message_renderer.ts create mode 100644 packages/localize/src/tools/src/translate/output_path.ts create mode 100644 packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts create mode 100644 packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts create mode 100644 packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts create mode 100644 packages/localize/src/tools/src/translate/source_files/source_file_utils.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_file_loader.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/base_visitor.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/i18n_error.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/simple_json/simple_json_translation_parser.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_parser.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_message_serializer.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_message_serializer.ts create mode 100644 packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser.ts create mode 100644 packages/localize/src/tools/src/translate/translator.ts create mode 100644 packages/localize/src/tools/test/BUILD.bazel create mode 100644 packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts create mode 100644 packages/localize/src/tools/test/translate/integration/BUILD.bazel create mode 100644 packages/localize/src/tools/test/translate/integration/locales/messages.de.json create mode 100644 packages/localize/src/tools/test/translate/integration/locales/messages.es.xlf create mode 100644 packages/localize/src/tools/test/translate/integration/locales/messages.fr.xlf create mode 100644 packages/localize/src/tools/test/translate/integration/main_spec.ts create mode 100644 packages/localize/src/tools/test/translate/integration/test_files/test-1.txt create mode 100644 packages/localize/src/tools/test/translate/integration/test_files/test-2.txt create mode 100644 packages/localize/src/tools/test/translate/integration/test_files/test.js create mode 100644 packages/localize/src/tools/test/translate/output_path_spec.ts create mode 100644 packages/localize/src/tools/test/translate/source_files/es2015_translate_plugin_spec.ts create mode 100644 packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts create mode 100644 packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts create mode 100644 packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts create mode 100644 packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts create mode 100644 packages/localize/src/tools/test/translate/translation_files/translation_parsers/simple_json/simple_json_spec.ts create mode 100644 packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser_spec.ts create mode 100644 packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser_spec.ts create mode 100644 packages/localize/src/tools/test/translate/translator_spec.ts create mode 100644 packages/localize/src/tools/tsconfig-build.json create mode 100644 packages/localize/src/tools/types/babel/LICENSE create mode 100644 packages/localize/src/tools/types/babel/README.md create mode 100644 packages/localize/src/tools/types/babel/core.d.ts create mode 100644 packages/localize/src/tools/types/babel/generator.d.ts create mode 100644 packages/localize/src/tools/types/babel/template.d.ts create mode 100644 packages/localize/src/tools/types/babel/traverse.d.ts create mode 100644 packages/localize/src/utils/BUILD.bazel create mode 100644 packages/localize/src/utils/index.ts rename packages/localize/src/utils/{ => src}/constants.ts (100%) rename packages/localize/src/utils/{ => src}/messages.ts (92%) rename packages/localize/src/utils/{ => src}/translations.ts (70%) create mode 100644 packages/localize/src/utils/test/BUILD.bazel rename packages/localize/{test/utils => src/utils/test}/messages_spec.ts (78%) rename packages/localize/{test/utils => src/utils/test}/translations_spec.ts (97%) diff --git a/integration/cli-hello-world-ivy-i18n/yarn.lock b/integration/cli-hello-world-ivy-i18n/yarn.lock index 25ffde56a0..c19538df05 100644 --- a/integration/cli-hello-world-ivy-i18n/yarn.lock +++ b/integration/cli-hello-world-ivy-i18n/yarn.lock @@ -175,7 +175,11 @@ version "9.0.0-next.9" "@angular/localize@file:../../dist/packages-dist/localize": - version "9.0.0-next.9" + version "0.0.0" + dependencies: + "@babel/core" "^7.5.5" + glob "7.1.2" + yargs "13.1.0" "@angular/platform-browser-dynamic@file:../../dist/packages-dist/platform-browser-dynamic": version "9.0.0-next.9" @@ -226,6 +230,26 @@ semver "^5.4.1" source-map "^0.5.0" +"@babel/core@^7.5.5": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.6.2.tgz#069a776e8d5e9eefff76236bc8845566bd31dd91" + integrity sha512-l8zto/fuoZIbncm+01p8zPSDZu/VuuJhAfA7d/AbzM09WR7iVhavvfNDYCNpo1VvLk6E6xgAoP9P+/EMJHuRkQ== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.6.2" + "@babel/helpers" "^7.6.2" + "@babel/parser" "^7.6.2" + "@babel/template" "^7.6.0" + "@babel/traverse" "^7.6.2" + "@babel/types" "^7.6.0" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.13" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + "@babel/generator@^7.0.0", "@babel/generator@^7.2.2": version "7.2.2" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.2.2.tgz#18c816c70962640eab42fe8cae5f3947a5c65ccc" @@ -248,6 +272,16 @@ source-map "^0.5.0" trim-right "^1.0.1" +"@babel/generator@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.6.2.tgz#dac8a3c2df118334c2a29ff3446da1636a8f8c03" + integrity sha512-j8iHaIW4gGPnViaIHI7e9t/Hl8qLjERI6DcV9kEpAIDJsAOrcnXqRS7t+QbhL76pwbtqP+QCQLL0z1CyVmtjjQ== + dependencies: + "@babel/types" "^7.6.0" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32" @@ -419,6 +453,15 @@ "@babel/traverse" "^7.5.5" "@babel/types" "^7.5.5" +"@babel/helpers@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.6.2.tgz#681ffe489ea4dcc55f23ce469e58e59c1c045153" + integrity sha512-3/bAUL8zZxYs1cdX2ilEE0WobqbCmKWr/889lf2SS0PpDcpEIY8pb1CCyz0pEcX3pEb+MCbks1jIokz2xLtGTA== + dependencies: + "@babel/template" "^7.6.0" + "@babel/traverse" "^7.6.2" + "@babel/types" "^7.6.0" + "@babel/highlight@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" @@ -438,6 +481,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b" integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g== +"@babel/parser@^7.6.0", "@babel/parser@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.6.2.tgz#205e9c95e16ba3b8b96090677a67c9d6075b70a1" + integrity sha512-mdFqWrSPCmikBoaBYMuBulzTIKuXVPtEISFbRRVNwMWpCms/hmE2kRq0bblUHaNRKrjRlmVbx1sDHmjmRgD2Xg== + "@babel/plugin-proposal-async-generator-functions@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e" @@ -843,6 +891,15 @@ "@babel/parser" "^7.4.4" "@babel/types" "^7.4.4" +"@babel/template@^7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.6.0.tgz#7f0159c7f5012230dad64cca42ec9bdb5c9536e6" + integrity sha512-5AEH2EXD8euCk446b7edmgFdub/qfH1SN6Nii3+fyXP807QRx9Q73A2N5hNwRRslC2H9sNzaFhsPubkS4L8oNQ== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.6.0" + "@babel/types" "^7.6.0" + "@babel/traverse@^7.0.0": version "7.2.3" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.2.3.tgz#7ff50cefa9c7c0bd2d81231fdac122f3957748d8" @@ -873,6 +930,21 @@ globals "^11.1.0" lodash "^4.17.13" +"@babel/traverse@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.6.2.tgz#b0e2bfd401d339ce0e6c05690206d1e11502ce2c" + integrity sha512-8fRE76xNwNttVEF2TwxJDGBLWthUkHWSldmfuBzVRmEDWOtu4XdINTgN7TDWzuLg4bbeIMLvfMFD9we5YcWkRQ== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.6.2" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.4.4" + "@babel/parser" "^7.6.2" + "@babel/types" "^7.6.0" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + "@babel/types@^7.0.0", "@babel/types@^7.2.2": version "7.2.2" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.2.2.tgz#44e10fc24e33af524488b716cdaee5360ea8ed1e" @@ -891,6 +963,15 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@babel/types@^7.6.0": + version "7.6.1" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.6.1.tgz#53abf3308add3ac2a2884d539151c57c4b3ac648" + integrity sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + "@ngtools/webpack@8.3.0-next.1": version "8.3.0-next.1" resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-8.3.0-next.1.tgz#06052331611bf1ca440d58fbe29593d561b54514" @@ -3615,6 +3696,18 @@ glob@7.0.x: once "^1.3.0" path-is-absolute "^1.0.0" +glob@7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@7.1.4, glob@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" diff --git a/package.json b/package.json index 8a8c442ec4..75f9e77ec3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@angular-devkit/core": "^8.0.0-beta.15", "@angular-devkit/schematics": "^8.0.0-beta.15", "@angular/bazel": "file:./tools/npm/@angular_bazel", + "@babel/core": "^7.5.5", "@bazel/jasmine": "0.38.1", "@bazel/karma": "0.38.1", "@bazel/protractor": "0.38.1", diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 98831a3127..7f9f6bb9dd 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -76,6 +76,7 @@ export * from './ml_parser/html_tags'; export * from './ml_parser/interpolation_config'; export * from './ml_parser/tags'; export {LexerRange} from './ml_parser/lexer'; +export * from './ml_parser/xml_parser'; export {NgModuleCompiler} from './ng_module_compiler'; export {ArrayType, AssertNotNull, DYNAMIC_TYPE, BinaryOperator, BinaryOperatorExpr, BuiltinMethod, BuiltinType, BuiltinTypeName, BuiltinVar, CastExpr, ClassField, ClassMethod, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, ExternalReference, literalMap, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, StatementVisitor, ThrowStmt, TryCatchStmt, Type, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr, StmtModifier, Statement, STRING_TYPE, TypeofExpr, collectExternalReferences} from './output/output_ast'; export {EmitterVisitorContext} from './output/abstract_emitter'; diff --git a/packages/localize/BUILD.bazel b/packages/localize/BUILD.bazel index a5113a202e..d086fb082f 100644 --- a/packages/localize/BUILD.bazel +++ b/packages/localize/BUILD.bazel @@ -12,9 +12,8 @@ ts_library( ), module_name = "@angular/localize", deps = [ - "//packages/compiler", "//packages/localize/src/localize", - "@npm//@types/node", + "//packages/localize/src/utils", ], ) @@ -27,6 +26,7 @@ ng_package( entry_point = ":index.ts", packages = [ "//packages/localize/schematics:npm_package", + "//packages/localize/src/tools:npm_package", ], tags = [ "release-with-framework", diff --git a/packages/localize/index.ts b/packages/localize/index.ts index f75ccf8076..1f5ed0ec2c 100644 --- a/packages/localize/index.ts +++ b/packages/localize/index.ts @@ -10,4 +10,4 @@ // The public API exports are specified in the `./localize` module, which is checked by the // public_api_guard rules -export * from './localize'; +export * from './localize'; \ No newline at end of file diff --git a/packages/localize/localize.ts b/packages/localize/localize.ts index c8eb15048d..d1ebb08c04 100644 --- a/packages/localize/localize.ts +++ b/packages/localize/localize.ts @@ -8,4 +8,7 @@ // This file contains the public API of the `@angular/localize` entry-point -export {clearTranslations, loadTranslations} from './src/translate'; \ No newline at end of file +export {clearTranslations, loadTranslations} from './src/translate'; + +// Exports that are not part of the public API +export * from './private'; \ No newline at end of file diff --git a/packages/localize/package.json b/packages/localize/package.json index ba76b0cc8f..1f02a8162e 100644 --- a/packages/localize/package.json +++ b/packages/localize/package.json @@ -10,6 +10,9 @@ "fesm5": "./fesm5/localize.js", "fesm2015": "./fesm2015/localize.js", "typings": "./index.d.ts", + "bin": { + "localize-translate": "./src/tools/src/translate/main.js" + }, "author": "angular", "license": "MIT", "repository": { @@ -27,5 +30,10 @@ ], "engines": { "node": ">=8.0" + }, + "dependencies": { + "@babel/core": "^7.5.5", + "glob": "7.1.2", + "yargs": "13.1.0" } } diff --git a/packages/localize/private.ts b/packages/localize/private.ts new file mode 100644 index 0000000000..902c2af0a4 --- /dev/null +++ b/packages/localize/private.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// This file exports all the `utils` as private exports so that other parts of `@angular/localize` +// can make use of them. +export {MessageId as ɵMessageId, MissingTranslationError as ɵMissingTranslationError, ParsedMessage as ɵParsedMessage, ParsedTranslation as ɵParsedTranslation, ParsedTranslations as ɵParsedTranslations, SourceMessage as ɵSourceMessage, TargetMessage as ɵTargetMessage, computeMsgId as ɵcomputeMsgId, findEndOfBlock as ɵfindEndOfBlock, isMissingTranslationError as ɵisMissingTranslationError, makeParsedTranslation as ɵmakeParsedTranslation, makeTemplateObject as ɵmakeTemplateObject, parseMessage as ɵparseMessage, parseMetadata as ɵparseMetadata, parseTranslation as ɵparseTranslation, splitBlock as ɵsplitBlock, translate as ɵtranslate} from './src/utils'; diff --git a/packages/localize/src/tools/BUILD.bazel b/packages/localize/src/tools/BUILD.bazel new file mode 100644 index 0000000000..ea79438ef2 --- /dev/null +++ b/packages/localize/src/tools/BUILD.bazel @@ -0,0 +1,38 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "npm_package", "ts_library") +load("@npm_bazel_typescript//:index.bzl", "ts_config") + +ts_config( + name = "tsconfig", + src = "tsconfig-build.json", + deps = ["//packages:tsconfig-build.json"], +) + +ts_library( + name = "tools", + srcs = glob( + [ + "**/*.ts", + ], + ), + tsconfig = ":tsconfig", + deps = [ + "//packages/compiler", + "//packages/localize", + "@npm//@babel/core", + "@npm//@babel/types", + "@npm//@types/glob", + "@npm//@types/node", + "@npm//@types/yargs", + ], +) + +npm_package( + name = "npm_package", + srcs = [ + ], + deps = [ + ":tools", + ], +) diff --git a/packages/localize/src/tools/src/diagnostics.ts b/packages/localize/src/tools/src/diagnostics.ts new file mode 100644 index 0000000000..5ba8a52862 --- /dev/null +++ b/packages/localize/src/tools/src/diagnostics.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * This class is used to collect and then report warnings and errors that occur during the execution + * of the tools. + */ +export class Diagnostics { + readonly messages: {type: 'warning' | 'error', message: string}[] = []; + get hasErrors() { return this.messages.some(m => m.type === 'error'); } + warn(message: string) { this.messages.push({type: 'warning', message}); } + error(message: string) { this.messages.push({type: 'error', message}); } +} diff --git a/packages/localize/src/tools/src/file_utils.ts b/packages/localize/src/tools/src/file_utils.ts new file mode 100644 index 0000000000..4477e7036c --- /dev/null +++ b/packages/localize/src/tools/src/file_utils.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as fs from 'fs'; +import * as path from 'path'; + +export class FileUtils { + static readFile(absolutePath: string): string { return fs.readFileSync(absolutePath, 'utf8'); } + + static readFileBuffer(absolutePath: string): Buffer { return fs.readFileSync(absolutePath); } + + static writeFile(absolutePath: string, contents: string|Buffer) { + FileUtils.ensureDir(path.dirname(absolutePath)); + fs.writeFileSync(absolutePath, contents); + } + + static ensureDir(absolutePath: string): void { + const parents: string[] = []; + while (!FileUtils.isRoot(absolutePath) && !fs.existsSync(absolutePath)) { + parents.push(absolutePath); + absolutePath = path.dirname(absolutePath); + } + while (parents.length) { + fs.mkdirSync(parents.pop() !); + } + } + + static remove(p: string): void { + const stat = fs.statSync(p); + if (stat.isFile()) { + fs.unlinkSync(p); + } else if (stat.isDirectory()) { + fs.readdirSync(p).forEach(child => { + const absChild = path.resolve(p, child); + FileUtils.remove(absChild); + }); + fs.rmdirSync(p); + } + } + + static isRoot(absolutePath: string): boolean { + return path.dirname(absolutePath) === absolutePath; + } +} diff --git a/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts b/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts new file mode 100644 index 0000000000..ac5d5221d0 --- /dev/null +++ b/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Diagnostics} from '../../diagnostics'; +import {FileUtils} from '../../file_utils'; +import {OutputPathFn} from '../output_path'; +import {TranslationBundle, TranslationHandler} from '../translator'; + + + +/** + * Translate an asset file by simply copying it to the appropriate translation output paths. + */ +export class AssetTranslationHandler implements TranslationHandler { + canTranslate(_relativeFilePath: string, _contents: Buffer): boolean { return true; } + translate( + diagnostics: Diagnostics, _sourceRoot: string, relativeFilePath: string, contents: Buffer, + outputPathFn: OutputPathFn, translations: TranslationBundle[]): void { + for (const translation of translations) { + try { + FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents); + } catch (e) { + diagnostics.error(e.message); + } + } + } +} diff --git a/packages/localize/src/tools/src/translate/main.ts b/packages/localize/src/tools/src/translate/main.ts new file mode 100644 index 0000000000..15a63fa813 --- /dev/null +++ b/packages/localize/src/tools/src/translate/main.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as glob from 'glob'; +import {resolve} from 'path'; +import * as yargs from 'yargs'; + +import {AssetTranslationHandler} from './asset_files/asset_translation_handler'; +import {getOutputPathFn, OutputPathFn} from './output_path'; +import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler'; +import {MissingTranslationStrategy} from './source_files/source_file_utils'; +import {TranslationLoader} from './translation_files/translation_file_loader'; +import {SimpleJsonTranslationParser} from './translation_files/translation_parsers/simple_json/simple_json_translation_parser'; +import {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1/xliff1_translation_parser'; +import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2/xliff2_translation_parser'; +import {Translator} from './translator'; +import {Diagnostics} from '../diagnostics'; + +if (require.main === module) { + const args = process.argv.slice(2); + const options = + yargs + .option('r', { + alias: 'root', + required: true, + describe: + 'The root path of the files to translate, either absolute or relative to the current working directory. E.g. `dist/en`.', + }) + .option('s', { + alias: 'source', + required: true, + describe: + 'A glob pattern indicating what files to translate, relative to the `root` path. E.g. `bundles/**/*`.', + }) + + .option('t', { + alias: 'translations', + required: true, + describe: + 'A glob pattern indicating what translation files to load, either absolute or relative to the current working directory. E.g. `my_proj/src/locale/messages.*.xlf.', + }) + .option('o', { + alias: 'outputPath', + required: true, + describe: + 'A output path pattern to where the translated files will be written. The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.' + }) + .option('m', { + alias: 'missingTranslation', + describe: 'How to handle missing translations.', + choices: ['error', 'warning', 'ignore'], + default: 'warning', + }) + .help() + .parse(args); + + const sourceRootPath = options['r']; + const sourceFilePaths = + glob.sync(options['s'], {absolute: true, cwd: sourceRootPath, nodir: true}); + const translationFilePaths = glob.sync(options['t'], {absolute: true, nodir: true}); + const outputPathFn = getOutputPathFn(options['o']); + const diagnostics = new Diagnostics(); + const missingTranslation: MissingTranslationStrategy = options['m']; + + translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, diagnostics, + missingTranslation}); + + diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`)); + process.exit(diagnostics.hasErrors ? 1 : 0); +} + +export interface TranslateFilesOptions { + sourceRootPath: string; + sourceFilePaths: string[]; + translationFilePaths: string[]; + outputPathFn: OutputPathFn; + diagnostics: Diagnostics; + missingTranslation: MissingTranslationStrategy; +} + +export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, + diagnostics, missingTranslation}: TranslateFilesOptions) { + const translationLoader = new TranslationLoader([ + new Xliff2TranslationParser(), + new Xliff1TranslationParser(), + new SimpleJsonTranslationParser(), + ]); + + const resourceProcessor = new Translator( + [ + new SourceFileTranslationHandler({missingTranslation}), + new AssetTranslationHandler(), + ], + diagnostics); + + const translations = translationLoader.loadBundles(translationFilePaths); + sourceRootPath = resolve(sourceRootPath); + resourceProcessor.translateFiles(sourceFilePaths, sourceRootPath, outputPathFn, translations); +} diff --git a/packages/localize/src/tools/src/translate/message_renderers/message_renderer.ts b/packages/localize/src/tools/src/translate/message_renderers/message_renderer.ts new file mode 100644 index 0000000000..5bcce4a984 --- /dev/null +++ b/packages/localize/src/tools/src/translate/message_renderers/message_renderer.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export interface MessageRenderer { + message: T; + startRender(): void; + endRender(): void; + text(text: string): void; + placeholder(name: string, body: string|undefined): void; + startPlaceholder(name: string): void; + closePlaceholder(name: string): void; + startContainer(): void; + closeContainer(): void; + startIcu(): void; + endIcu(): void; +} + +export function stripInterpolationMarkers(interpolation: string): string { + return interpolation.replace(/^\{\{/, '').replace(/}}$/, ''); +} diff --git a/packages/localize/src/tools/src/translate/message_renderers/target_message_renderer.ts b/packages/localize/src/tools/src/translate/message_renderers/target_message_renderer.ts new file mode 100644 index 0000000000..3db16dea25 --- /dev/null +++ b/packages/localize/src/tools/src/translate/message_renderers/target_message_renderer.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵParsedTranslation, ɵmakeParsedTranslation} from '@angular/localize'; +import {MessageRenderer} from './message_renderer'; + +/** + * A message renderer that outputs `ɵParsedTranslation` objects. + */ +export class TargetMessageRenderer implements MessageRenderer<ɵParsedTranslation> { + private current: MessageInfo = {messageParts: [], placeholderNames: [], text: ''}; + private icuDepth = 0; + + get message(): ɵParsedTranslation { + const {messageParts, placeholderNames} = this.current; + return ɵmakeParsedTranslation(messageParts, placeholderNames); + } + startRender(): void {} + endRender(): void { this.storeMessagePart(); } + text(text: string): void { this.current.text += text; } + placeholder(name: string, body: string|undefined): void { this.renderPlaceholder(name); } + startPlaceholder(name: string): void { this.renderPlaceholder(name); } + closePlaceholder(name: string): void { this.renderPlaceholder(name); } + startContainer(): void {} + closeContainer(): void {} + startIcu(): void { + this.icuDepth++; + this.text('{'); + } + endIcu(): void { + this.icuDepth--; + this.text('}'); + } + private renderPlaceholder(name: string) { + if (this.icuDepth > 0) { + this.text(`{${name}}`); + } else { + this.storeMessagePart(); + this.current.placeholderNames.push(name); + } + } + private storeMessagePart() { + this.current.messageParts.push(this.current.text); + this.current.text = ''; + } +} + +interface MessageInfo { + messageParts: string[]; + placeholderNames: string[]; + text: string; +} \ No newline at end of file diff --git a/packages/localize/src/tools/src/translate/output_path.ts b/packages/localize/src/tools/src/translate/output_path.ts new file mode 100644 index 0000000000..89f33cb0d5 --- /dev/null +++ b/packages/localize/src/tools/src/translate/output_path.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {join} from 'path'; + +export interface OutputPathFn { (locale: string, relativePath: string): string; } + +/** + * Create a function that will compute the absolute path to where a translated file should be + * written. + * + * The special `{{LOCALE}}` marker will be replaced with the locale code of the current translation. + * @param outputFolder An absolute path to the folder containing this set of translations. + */ +export function getOutputPathFn(outputFolder: string): OutputPathFn { + const [pre, post] = outputFolder.split('{{LOCALE}}'); + return post === undefined ? (_locale, relativePath) => join(pre, relativePath) : + (locale, relativePath) => join(pre + locale + post, relativePath); +} diff --git a/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts b/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts new file mode 100644 index 0000000000..52cdd795f0 --- /dev/null +++ b/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵParsedTranslation} from '@angular/localize'; +import {NodePath, PluginObj} from '@babel/core'; +import {TaggedTemplateExpression} from '@babel/types'; +import {Diagnostics} from '../../diagnostics'; +import {TranslatePluginOptions, buildLocalizeReplacement, isBabelParseError, isGlobalIdentifier, isNamedIdentifier, translate, unwrapMessagePartsFromTemplateLiteral} from './source_file_utils'; + +export function makeEs2015TranslatePlugin( + diagnostics: Diagnostics, translations: Record, + {missingTranslation = 'error', localizeName = '$localize'}: TranslatePluginOptions = {}): + PluginObj { + return { + visitor: { + TaggedTemplateExpression(path: NodePath) { + try { + const tag = path.get('tag'); + if (isNamedIdentifier(tag, localizeName) && isGlobalIdentifier(tag)) { + const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis); + const translated = translate( + diagnostics, translations, messageParts, path.node.quasi.expressions, + missingTranslation); + path.replaceWith(buildLocalizeReplacement(translated[0], translated[1])); + } + } catch (e) { + if (isBabelParseError(e)) { + // If we get a BabelParseError here then something went wrong with Babel itself + // since there must be something wrong with the structure of the AST generated + // by Babel parsing a TaggedTemplateExpression. + throw path.hub.file.buildCodeFrameError(e.node, e.message); + } + } + } + } + }; +} diff --git a/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts b/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts new file mode 100644 index 0000000000..d0fc51d038 --- /dev/null +++ b/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵParsedTranslation} from '@angular/localize'; +import {NodePath, PluginObj} from '@babel/core'; +import {CallExpression} from '@babel/types'; +import {Diagnostics} from '../../diagnostics'; +import {TranslatePluginOptions, buildLocalizeReplacement, isBabelParseError, isGlobalIdentifier, isNamedIdentifier, translate, unwrapMessagePartsFromLocalizeCall, unwrapSubstitutionsFromLocalizeCall} from './source_file_utils'; + +export function makeEs5TranslatePlugin( + diagnostics: Diagnostics, translations: Record, + {missingTranslation = 'error', localizeName = '$localize'}: TranslatePluginOptions = {}): + PluginObj { + return { + visitor: { + CallExpression(callPath: NodePath) { + try { + const calleePath = callPath.get('callee'); + if (isNamedIdentifier(calleePath, localizeName) && isGlobalIdentifier(calleePath)) { + const messageParts = unwrapMessagePartsFromLocalizeCall(callPath.node); + const expressions = unwrapSubstitutionsFromLocalizeCall(callPath.node); + const translated = + translate(diagnostics, translations, messageParts, expressions, missingTranslation); + callPath.replaceWith(buildLocalizeReplacement(translated[0], translated[1])); + } + } catch (e) { + if (isBabelParseError(e)) { + diagnostics.error(callPath.hub.file.buildCodeFrameError(e.node, e.message).message); + } + } + } + } + }; +} diff --git a/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts b/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts new file mode 100644 index 0000000000..1511ec9293 --- /dev/null +++ b/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {parseSync, transformFromAstSync} from '@babel/core'; +import {extname, join} from 'path'; + +import {Diagnostics} from '../../diagnostics'; +import {FileUtils} from '../../file_utils'; +import {OutputPathFn} from '../output_path'; +import {TranslationBundle, TranslationHandler} from '../translator'; + +import {makeEs2015TranslatePlugin} from './es2015_translate_plugin'; +import {makeEs5TranslatePlugin} from './es5_translate_plugin'; +import {TranslatePluginOptions} from './source_file_utils'; + +/** + * Translate a file by inlining all messages tagged by `$localize` with the appropriate translated + * message. + */ +export class SourceFileTranslationHandler implements TranslationHandler { + constructor(private translationOptions: TranslatePluginOptions = {}) {} + + canTranslate(relativeFilePath: string, contents: Buffer): boolean { + return extname(relativeFilePath) === '.js'; + } + + translate( + diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer, + outputPathFn: OutputPathFn, translations: TranslationBundle[]): void { + const sourceCode = contents.toString('utf8'); + // A short-circuit check to avoid parsing the file into an AST if it does not contain any + // `$localize` identifiers. + if (!sourceCode.includes('$localize')) { + for (const translation of translations) { + FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents); + } + } else { + const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath}); + if (!ast) { + diagnostics.error(`Unable to parse source file: ${join(sourceRoot, relativeFilePath)}`); + return; + } + for (const translationBundle of translations) { + const translated = transformFromAstSync(ast, sourceCode, { + compact: true, + generatorOpts: {minified: true}, + plugins: [ + makeEs2015TranslatePlugin( + diagnostics, translationBundle.translations, this.translationOptions), + makeEs5TranslatePlugin( + diagnostics, translationBundle.translations, this.translationOptions), + ], + filename: relativeFilePath, + }); + if (translated && translated.code) { + FileUtils.writeFile( + outputPathFn(translationBundle.locale, relativeFilePath), translated.code); + } else { + diagnostics.error( + `Unable to translate source file: ${join(sourceRoot, relativeFilePath)}`); + return; + } + } + } + } +} diff --git a/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts b/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts new file mode 100644 index 0000000000..48fcf35dee --- /dev/null +++ b/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵParsedTranslation, ɵisMissingTranslationError, ɵmakeTemplateObject, ɵtranslate} from '@angular/localize'; +import {NodePath} from '@babel/traverse'; +import * as t from '@babel/types'; +import {Diagnostics} from '../../diagnostics'; + +/** + * Is the given `expression` an identifier with the correct name + * @param expression The expression to check. + */ +export function isNamedIdentifier( + expression: NodePath, name: string): expression is NodePath { + return expression.isIdentifier() && expression.node.name === name; +} + +/** +* Is the given `identifier` declared globally. +* @param identifier The identifier to check. +*/ +export function isGlobalIdentifier(identifier: NodePath) { + return !identifier.scope || !identifier.scope.hasBinding(identifier.node.name); +} + +/** +* Build a translated expression to replace the call to `$localize`. +* @param messageParts The static parts of the message. +* @param substitutions The expressions to substitute into the message. +*/ +export function buildLocalizeReplacement( + messageParts: TemplateStringsArray, substitutions: readonly t.Expression[]): t.Expression { + let mappedString: t.Expression = t.stringLiteral(messageParts[0]); + for (let i = 1; i < messageParts.length; i++) { + mappedString = + t.binaryExpression('+', mappedString, wrapInParensIfNecessary(substitutions[i - 1])); + mappedString = t.binaryExpression('+', mappedString, t.stringLiteral(messageParts[i])); + } + return mappedString; +} + +/** +* Extract the message parts from the given `call` (to `$localize`). +* +* The message parts will either by the first argument to the `call` or it will be wrapped in call +* to a helper function like `__makeTemplateObject`. +* +* @param call The AST node of the call to process. +*/ +export function unwrapMessagePartsFromLocalizeCall(call: t.CallExpression): TemplateStringsArray { + let cooked = call.arguments[0]; + if (!t.isExpression(cooked)) { + throw new BabelParseError(call, 'Unexpected argument to `$localize`: ' + cooked); + } + + // If there is no call to `__makeTemplateObject(...)`, then `raw` must be the same as `cooked`. + let raw = cooked; + + // Check for cached call of the form `x || x = __makeTemplateObject(...)` + if (t.isLogicalExpression(cooked) && cooked.operator === '||' && t.isIdentifier(cooked.left) && + t.isExpression(cooked.right)) { + if (t.isAssignmentExpression(cooked.right)) { + cooked = cooked.right.right; + } + } + + // Check for `__makeTemplateObject(cooked, raw)` call + if (t.isCallExpression(cooked)) { + raw = cooked.arguments[1] as t.Expression; + if (!t.isExpression(raw)) { + throw new BabelParseError( + raw, + 'Unexpected `raw` argument to the "makeTemplateObject()" function (expected an expression).'); + } + cooked = cooked.arguments[0]; + if (!t.isExpression(cooked)) { + throw new BabelParseError( + cooked, + 'Unexpected `cooked` argument to the "makeTemplateObject()" function (expected an expression).'); + } + } + + const cookedStrings = unwrapStringLiteralArray(cooked); + const rawStrings = unwrapStringLiteralArray(raw); + return ɵmakeTemplateObject(cookedStrings, rawStrings); +} + + +export function unwrapSubstitutionsFromLocalizeCall(call: t.CallExpression): t.Expression[] { + const expressions = call.arguments.splice(1); + if (!isArrayOfExpressions(expressions)) { + const badExpression = expressions.find(expression => !t.isExpression(expression)) !; + throw new BabelParseError( + badExpression, + 'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).'); + } + return expressions; +} + +export function unwrapMessagePartsFromTemplateLiteral(elements: t.TemplateElement[]): + TemplateStringsArray { + const cooked = elements.map(q => { + if (q.value.cooked === undefined) { + throw new BabelParseError( + q, `Unexpected undefined message part in "${elements.map(q => q.value.cooked)}"`); + } + return q.value.cooked; + }); + const raw = elements.map(q => q.value.raw); + return ɵmakeTemplateObject(cooked, raw); +} + +/** +* Wrap the given `expression` in parentheses if it is a binary expression. +* +* This ensures that this expression is evaluated correctly if it is embedded in another expression. +* +* @param expression The expression to potentially wrap. +*/ +export function wrapInParensIfNecessary(expression: t.Expression): t.Expression { + if (t.isBinaryExpression(expression)) { + return t.parenthesizedExpression(expression); + } else { + return expression; + } +} + +/** +* Extract the string values from an `array` of string literals. +* @param array The array to unwrap. +*/ +export function unwrapStringLiteralArray(array: t.Expression): string[] { + if (!isStringLiteralArray(array)) { + throw new BabelParseError( + array, 'Unexpected messageParts for `$localize` (expected an array of strings).'); + } + return array.elements.map((str: t.StringLiteral) => str.value); +} + +/** +* Is the given `node` an array of literal strings? +* +* @param node The node to test. +*/ +export function isStringLiteralArray(node: t.Node): node is t.Expression& + {elements: t.StringLiteral[]} { + return t.isArrayExpression(node) && node.elements.every(element => t.isStringLiteral(element)); +} + +/** +* Are all the given `nodes` expressions? +* @param nodes The nodes to test. +*/ +export function isArrayOfExpressions(nodes: t.Node[]): nodes is t.Expression[] { + return nodes.every(element => t.isExpression(element)); +} + +/** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */ +export interface TranslatePluginOptions { + missingTranslation?: MissingTranslationStrategy; + localizeName?: string; +} + +/** + * How to handle missing translations. + */ +export type MissingTranslationStrategy = 'error' | 'warning' | 'ignore'; + +/** + * Translate the text of the given message, using the given translations. + * + * Logs as warning if the translation is not available + */ +export function translate( + diagnostics: Diagnostics, translations: Record, + messageParts: TemplateStringsArray, substitutions: readonly any[], + missingTranslation: MissingTranslationStrategy): [TemplateStringsArray, readonly any[]] { + try { + return ɵtranslate(translations, messageParts, substitutions); + } catch (e) { + if (ɵisMissingTranslationError(e)) { + if (missingTranslation === 'error') { + diagnostics.error(e.message); + } else if (missingTranslation === 'warning') { + diagnostics.warn(e.message); + } + } else { + diagnostics.error(e.message); + } + return [messageParts, substitutions]; + } +} + +export class BabelParseError extends Error { + private readonly type = 'BabelParseError'; + constructor(public node: t.BaseNode, message: string) { super(message); } +} + +export function isBabelParseError(e: any): e is BabelParseError { + return e.type === 'BabelParseError'; +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_file_loader.ts b/packages/localize/src/tools/src/translate/translation_files/translation_file_loader.ts new file mode 100644 index 0000000000..04cbc7bb3a --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_file_loader.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {FileUtils} from '../../file_utils'; +import {TranslationBundle} from '../translator'; +import {TranslationParser} from './translation_parsers/translation_parser'; + +/** + * Use this class to load a collection of translation files from disk. + */ +export class TranslationLoader { + constructor(private translationParsers: TranslationParser[]) {} + + /** + * Load and parse the translation files into a collection of `TranslationBundles`. + * + * @param translationFilePaths A collection of absolute paths to the translation files. + */ + loadBundles(translationFilePaths: string[]): TranslationBundle[] { + return translationFilePaths.map(filePath => { + const fileContents = FileUtils.readFile(filePath); + for (const translationParser of this.translationParsers) { + if (translationParser.canParse(filePath, fileContents)) { + return translationParser.parse(filePath, fileContents); + } + } + throw new Error(`Unable to parse translation file: ${filePath}`); + }); + } +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/base_visitor.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/base_visitor.ts new file mode 100644 index 0000000000..b07fccbf94 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/base_visitor.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Attribute, Comment, Element, Expansion, ExpansionCase, Text, Visitor} from '@angular/compiler'; + +/** + * A simple base class for the `Visitor` interface, which is a noop for every method. + * + * Sub-classes only need to override the methods that they care about. + */ +export class BaseVisitor implements Visitor { + visitElement(_element: Element, _context: any): any {} + visitAttribute(_attribute: Attribute, _context: any): any {} + visitText(_text: Text, _context: any): any {} + visitComment(_comment: Comment, _context: any): any {} + visitExpansion(_expansion: Expansion, _context: any): any {} + visitExpansionCase(_expansionCase: ExpansionCase, _context: any): any {} +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/i18n_error.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/i18n_error.ts new file mode 100644 index 0000000000..dc5c7d378b --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/i18n_error.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ParseErrorLevel, ParseSourceSpan} from '@angular/compiler'; + +/** + * Instances of this class are thrown when there is an error in the source code that is being + * translated. + */ +export class I18nError extends Error { + constructor( + public span: ParseSourceSpan, public msg: string, + public level: ParseErrorLevel = ParseErrorLevel.ERROR) { + super(msg); + } + + contextualMessage(): string { + const ctx = this.span.start.getContext(100, 3); + return ctx ? `${this.msg} ("${ctx.before}[${ParseErrorLevel[this.level]} ->]${ctx.after}")` : + this.msg; + } + + toString(): string { + const details = this.span.details ? `, ${this.span.details}` : ''; + return `${this.contextualMessage()}: ${this.span.start}${details}`; + } +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/simple_json/simple_json_translation_parser.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/simple_json/simple_json_translation_parser.ts new file mode 100644 index 0000000000..d5de42d152 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/simple_json/simple_json_translation_parser.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵMessageId, ɵParsedTranslation, ɵparseTranslation} from '@angular/localize'; +import {extname} from 'path'; +import {TranslationBundle} from '../../../translator'; +import {TranslationParser} from '../translation_parser'; + +/** + * A translation parser that can parse JSON that has the form: + * + * ``` + * { + * "locale": "...", + * "translations": { + * "message-id": "Target message string", + * ... + * } + * } + * ``` + */ +export class SimpleJsonTranslationParser implements TranslationParser { + canParse(filePath: string, _contents: string): boolean { return (extname(filePath) === '.json'); } + + parse(_filePath: string, contents: string): TranslationBundle { + const {locale, translations} = JSON.parse(contents); + const parsedTranslations: Record<ɵMessageId, ɵParsedTranslation> = {}; + for (const messageId in translations) { + const targetMessage = translations[messageId]; + parsedTranslations[messageId] = ɵparseTranslation(targetMessage); + } + return {locale, translations: parsedTranslations}; + } +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_parser.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_parser.ts new file mode 100644 index 0000000000..144058d1b3 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_parser.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {TranslationBundle} from '../../translator'; + +/** + * Implement this interface to provide a class that can parse the contents of a translation file. + */ +export interface TranslationParser { + /** + * Returns true if this parser can parse the given file. + * + * @param filePath The absolute path to the translation file. + * @param contents The contents of the translation file. + */ + canParse(filePath: string, contents: string): boolean; + + /** + * Parses the given file, extracting the target locale and translations. + * + * @param filePath The absolute path to the translation file. + * @param contents The contents of the translation file. + */ + parse(filePath: string, contents: string): TranslationBundle; +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts new file mode 100644 index 0000000000..9d707f0886 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Element, LexerRange, Node, XmlParser} from '@angular/compiler'; +import {I18nError} from './i18n_error'; + +export function getAttrOrThrow(element: Element, attrName: string): string { + const attrValue = getAttribute(element, attrName); + if (attrValue === undefined) { + throw new I18nError(element.sourceSpan, `Missing required "${attrName}" attribute:`); + } + return attrValue; +} + +export function getAttribute(element: Element, attrName: string): string|undefined { + const attr = element.attrs.find(a => a.name === attrName); + return attr !== undefined ? attr.value : undefined; +} + +export function parseInnerRange(element: Element): Node[] { + const xmlParser = new XmlParser(); + const xml = xmlParser.parse( + element.sourceSpan.start.file.content, element.sourceSpan.start.file.url, + {tokenizeExpansionForms: true, range: getInnerRange(element)}); + return xml.rootNodes; +} + +function getInnerRange(element: Element): LexerRange { + const start = element.startSourceSpan !.end; + const end = element.endSourceSpan !.start; + return { + startPos: start.offset, + startLine: start.line, + startCol: start.col, + endPos: end.offset, + }; +} \ No newline at end of file diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_message_serializer.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_message_serializer.ts new file mode 100644 index 0000000000..5b9e9fb369 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_message_serializer.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Element, Expansion, ExpansionCase, Node, Text, visitAll} from '@angular/compiler'; +import {MessageRenderer} from '../../../message_renderers/message_renderer'; +import {BaseVisitor} from '../base_visitor'; +import {I18nError} from '../i18n_error'; +import {getAttrOrThrow} from '../translation_utils'; + +const INLINE_ELEMENTS = ['g', 'bx', 'ex', 'bpt', 'ept', 'ph', 'it', 'mrk']; + +export class Xliff1MessageSerializer extends BaseVisitor { + constructor(private renderer: MessageRenderer) { super(); } + + serialize(nodes: Node[]): T { + this.renderer.startRender(); + visitAll(this, nodes); + this.renderer.endRender(); + return this.renderer.message; + } + + visitElement(element: Element): void { + if (element.name === 'x') { + this.visitPlaceholder(getAttrOrThrow(element, 'id'), ''); + } else if (INLINE_ELEMENTS.indexOf(element.name) !== -1) { + visitAll(this, element.children); + } else { + throw new I18nError(element.sourceSpan, `Invalid element found in message.`); + } + } + + visitText(text: Text): void { this.renderer.text(text.value); } + + visitExpansion(expansion: Expansion): void { + this.renderer.startIcu(); + this.renderer.text(`${expansion.switchValue}, ${expansion.type},`); + visitAll(this, expansion.cases); + this.renderer.endIcu(); + } + + visitExpansionCase(expansionCase: ExpansionCase): void { + this.renderer.text(` ${expansionCase.value} {`); + this.renderer.startContainer(); + visitAll(this, expansionCase.expression); + this.renderer.closeContainer(); + this.renderer.text(`}`); + } + + visitPlaceholder(name: string, body: string|undefined): void { + this.renderer.placeholder(name, body); + } +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser.ts new file mode 100644 index 0000000000..93350b9ea3 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Element, Node, XmlParser, visitAll} from '@angular/compiler'; +import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; +import {extname} from 'path'; +import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer'; +import {TranslationBundle} from '../../../translator'; +import {BaseVisitor} from '../base_visitor'; +import {I18nError} from '../i18n_error'; +import {TranslationParser} from '../translation_parser'; +import {getAttrOrThrow, parseInnerRange} from '../translation_utils'; +import {Xliff1MessageSerializer} from './xliff1_message_serializer'; + +const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/; + +/** + * A translation parser that can load XLIFF 1.2 files. + * + * http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html + * http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html + * + */ +export class Xliff1TranslationParser implements TranslationParser { + canParse(filePath: string, contents: string): boolean { + return (extname(filePath) === '.xlf') && XLIFF_1_2_NS_REGEX.test(contents); + } + + parse(filePath: string, contents: string): TranslationBundle { + const xmlParser = new XmlParser(); + const xml = xmlParser.parse(contents, filePath); + const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes); + if (bundle === undefined) { + throw new Error(`Unable to parse "${filePath}" as XLIFF 1.2 format.`); + } + return bundle; + } +} + +class XliffFileElementVisitor extends BaseVisitor { + private bundle: TranslationBundle|undefined; + + static extractBundle(xliff: Node[]): TranslationBundle|undefined { + const visitor = new this(); + visitAll(visitor, xliff); + return visitor.bundle; + } + + visitElement(element: Element): any { + if (element.name === 'file') { + this.bundle = { + locale: getAttrOrThrow(element, 'target-language'), + translations: XliffTranslationVisitor.extractTranslations(element) + }; + } else { + return visitAll(this, element.children); + } + } +} + +class XliffTranslationVisitor extends BaseVisitor { + private translations: Record<ɵMessageId, ɵParsedTranslation> = {}; + + static extractTranslations(file: Element): Record { + const visitor = new this(); + visitAll(visitor, file.children); + return visitor.translations; + } + + visitElement(element: Element): any { + if (element.name === 'trans-unit') { + const id = getAttrOrThrow(element, 'id'); + if (this.translations[id] !== undefined) { + throw new I18nError(element.sourceSpan, `Duplicated translations for message "${id}"`); + } + + const targetMessage = element.children.find(isTargetElement); + if (targetMessage === undefined) { + throw new I18nError(element.sourceSpan, 'Missing required element'); + } + this.translations[id] = serializeTargetMessage(targetMessage); + } else { + return visitAll(this, element.children); + } + } +} + +function serializeTargetMessage(source: Element): ɵParsedTranslation { + const serializer = new Xliff1MessageSerializer(new TargetMessageRenderer()); + return serializer.serialize(parseInnerRange(source)); +} + +function isTargetElement(node: Node): node is Element { + return node instanceof Element && node.name === 'target'; +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_message_serializer.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_message_serializer.ts new file mode 100644 index 0000000000..6f2d93379d --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_message_serializer.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Element, Expansion, ExpansionCase, Node, Text, visitAll} from '@angular/compiler'; +import {MessageRenderer} from '../../../message_renderers/message_renderer'; +import {BaseVisitor} from '../base_visitor'; +import {I18nError} from '../i18n_error'; +import {getAttrOrThrow, getAttribute} from '../translation_utils'; + +const INLINE_ELEMENTS = ['cp', 'sc', 'ec', 'mrk', 'sm', 'em']; + +export class Xliff2MessageSerializer extends BaseVisitor { + constructor(private renderer: MessageRenderer) { super(); } + + serialize(nodes: Node[]): T { + this.renderer.startRender(); + visitAll(this, nodes); + this.renderer.endRender(); + return this.renderer.message; + } + + visitElement(element: Element): void { + if (element.name === 'ph') { + this.visitPlaceholder(getAttrOrThrow(element, 'equiv'), getAttribute(element, 'disp')); + } else if (element.name === 'pc') { + this.visitPlaceholderContainer( + getAttrOrThrow(element, 'equivStart'), element.children, + getAttrOrThrow(element, 'equivEnd')); + } else if (INLINE_ELEMENTS.indexOf(element.name) !== -1) { + visitAll(this, element.children); + } else { + throw new I18nError(element.sourceSpan, `Invalid element found in message.`); + } + } + + visitText(text: Text): void { this.renderer.text(text.value); } + + visitExpansion(expansion: Expansion): void { + this.renderer.startIcu(); + this.renderer.text(`${expansion.switchValue}, ${expansion.type},`); + visitAll(this, expansion.cases); + this.renderer.endIcu(); + } + + visitExpansionCase(expansionCase: ExpansionCase): void { + this.renderer.text(` ${expansionCase.value} {`); + this.renderer.startContainer(); + visitAll(this, expansionCase.expression); + this.renderer.closeContainer(); + this.renderer.text(`}`); + } + + visitContainedNodes(nodes: Node[]): void { + const length = nodes.length; + let index = 0; + while (index < length) { + if (!isPlaceholderContainer(nodes[index])) { + const startOfContainedNodes = index; + while (index < length - 1) { + index++; + if (isPlaceholderContainer(nodes[index])) { + break; + } + } + if (index - startOfContainedNodes > 1) { + // Only create a container if there are two or more contained Nodes in a row + this.renderer.startContainer(); + visitAll(this, nodes.slice(startOfContainedNodes, index - 1)); + this.renderer.closeContainer(); + } + } + if (index < length) { + nodes[index].visit(this, undefined); + } + index++; + } + } + + visitPlaceholder(name: string, body: string|undefined): void { + this.renderer.placeholder(name, body); + } + + visitPlaceholderContainer(startName: string, children: Node[], closeName: string): void { + this.renderer.startPlaceholder(startName); + this.visitContainedNodes(children); + this.renderer.closePlaceholder(closeName); + } +} + +function isPlaceholderContainer(node: Node): boolean { + return node instanceof Element && node.name === 'pc'; +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser.ts new file mode 100644 index 0000000000..65736b6395 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Element, Node, XmlParser, visitAll} from '@angular/compiler'; +import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; +import {extname} from 'path'; +import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer'; +import {TranslationBundle} from '../../../translator'; +import {BaseVisitor} from '../base_visitor'; +import {I18nError} from '../i18n_error'; +import {TranslationParser} from '../translation_parser'; +import {getAttrOrThrow, parseInnerRange} from '../translation_utils'; +import {Xliff2MessageSerializer} from './xliff2_message_serializer'; + +const XLIFF_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/; + +/** + * A translation parser that can load translations from XLIFF 2 files. + * + * http://docs.oasis-open.org/xliff/xliff-core/v2.0/os/xliff-core-v2.0-os.html + * + */ +export class Xliff2TranslationParser implements TranslationParser { + canParse(filePath: string, contents: string): boolean { + return (extname(filePath) === '.xlf') && XLIFF_2_0_NS_REGEX.test(contents); + } + + parse(filePath: string, contents: string): TranslationBundle { + const xmlParser = new XmlParser(); + const xml = xmlParser.parse(contents, filePath); + const bundle = Xliff2TranslationBundleVisitor.extractBundle(xml.rootNodes); + if (bundle === undefined) { + throw new Error(`Unable to parse "${filePath}" as XLIFF 2.0 format.`); + } + return bundle; + } +} + +class Xliff2TranslationBundleVisitor extends BaseVisitor { + private locale: string|undefined; + private bundle: TranslationBundle|undefined; + + static extractBundle(xliff: Node[]): TranslationBundle|undefined { + const visitor = new this(); + visitAll(visitor, xliff); + return visitor.bundle; + } + + visitElement(element: Element): any { + if (element.name === 'xliff') { + this.locale = getAttrOrThrow(element, 'trgLang'); + return visitAll(this, element.children); + } else if (element.name === 'file') { + this.bundle = { + locale: this.locale !, + translations: Xliff2TranslationVisitor.extractTranslations(element) + }; + } else { + return visitAll(this, element.children); + } + } +} + +class Xliff2TranslationVisitor extends BaseVisitor { + private translations: Record<ɵMessageId, ɵParsedTranslation> = {}; + + static extractTranslations(file: Element): Record { + const visitor = new this(); + visitAll(visitor, file.children); + return visitor.translations; + } + + visitElement(element: Element, context: any): any { + if (element.name === 'unit') { + const externalId = getAttrOrThrow(element, 'id'); + if (this.translations[externalId] !== undefined) { + throw new I18nError( + element.sourceSpan, `Duplicated translations for message "${externalId}"`); + } + visitAll(this, element.children, {unit: externalId}); + } else if (element.name === 'segment') { + assertTranslationUnit(element, context); + const targetMessage = element.children.find(isTargetElement); + if (targetMessage === undefined) { + throw new I18nError(element.sourceSpan, 'Missing required element'); + } + this.translations[context.unit] = serializeTargetMessage(targetMessage); + } else { + return visitAll(this, element.children); + } + } +} + +function assertTranslationUnit(segment: Element, context: any) { + if (context === undefined || context.unit === undefined) { + throw new I18nError( + segment.sourceSpan, 'Invalid element: should be a child of a element.'); + } +} + +function serializeTargetMessage(source: Element): ɵParsedTranslation { + const serializer = new Xliff2MessageSerializer(new TargetMessageRenderer()); + return serializer.serialize(parseInnerRange(source)); +} + +function isTargetElement(node: Node): node is Element { + return node instanceof Element && node.name === 'target'; +} diff --git a/packages/localize/src/tools/src/translate/translator.ts b/packages/localize/src/tools/src/translate/translator.ts new file mode 100644 index 0000000000..56a6fc4428 --- /dev/null +++ b/packages/localize/src/tools/src/translate/translator.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; +import {relative} from 'path'; + +import {Diagnostics} from '../diagnostics'; +import {FileUtils} from '../file_utils'; + +import {OutputPathFn} from './output_path'; + + + +/** + * An object that holds translations that have been loaded + * from a translation file. + */ +export interface TranslationBundle { + locale: string; + translations: Record<ɵMessageId, ɵParsedTranslation>; +} + +/** + * Implement this interface to provide a class that can handle translation for the given resource in + * an appropriate manner. + * + * For example, source code files will need to be transformed if they contain `$localize` tagged + * template strings, while most static assets will just need to be copied. + */ +export interface TranslationHandler { + /** + * Returns true if the given file can be translated by this handler. + * + * @param relativeFilePath A relative path from the sourceRoot to the resource file to handle. + * @param contents The contents of the file to handle. + */ + canTranslate(relativeFilePath: string, contents: Buffer): boolean; + + /** + * Translate the file at `relativeFilePath` containing `contents`, using the given `translations`, + * and write the translated content to the path computed by calling `outputPathFn()`. + * + * @param diagnostics An object for collecting translation diagnostic messages. + * @param sourceRoot An absolute path to the root of the files being translated. + * @param relativeFilePath A relative path from the sourceRoot to the file to translate. + * @param contents The contents of the file to translate. + * @param outputPathFn A function that returns an absolute path where the output file should be + * written. + * @param translations A collection of translations to apply to this file. + */ + translate( + diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer, + outputPathFn: OutputPathFn, translations: TranslationBundle[]): void; +} + +/** + * Translate each file (e.g. source file or static asset) using the given `TranslationHandler`s. + * The file will be translated by the first handler that returns true for `canTranslate()`. + */ +export class Translator { + constructor(private resourceHandlers: TranslationHandler[], private diagnostics: Diagnostics) {} + + translateFiles( + inputPaths: string[], rootPath: string, outputPathFn: OutputPathFn, + translations: TranslationBundle[]): void { + inputPaths.forEach(inputPath => { + const contents = FileUtils.readFileBuffer(inputPath); + const relativePath = relative(rootPath, inputPath); + for (const resourceHandler of this.resourceHandlers) { + if (resourceHandler.canTranslate(relativePath, contents)) { + return resourceHandler.translate( + this.diagnostics, rootPath, relativePath, contents, outputPathFn, translations); + } + } + this.diagnostics.error(`Unable to handle resource file: ${inputPath}`); + }); + } +} diff --git a/packages/localize/src/tools/test/BUILD.bazel b/packages/localize/src/tools/test/BUILD.bazel new file mode 100644 index 0000000000..f9765020ff --- /dev/null +++ b/packages/localize/src/tools/test/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["**/*_spec.ts"], + ), + deps = [ + "//packages:types", + "//packages/compiler", + "//packages/localize", + "//packages/localize/src/tools", + "@npm//@babel/types", + "@npm//@types/glob", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = [ + "angular/tools/testing/init_node_no_angular_spec.js", + ], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + "@npm//glob", + ], +) diff --git a/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts b/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts new file mode 100644 index 0000000000..681f164954 --- /dev/null +++ b/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Diagnostics} from '../../../src/diagnostics'; +import {FileUtils} from '../../../src/file_utils'; +import {AssetTranslationHandler} from '../../../src/translate/asset_files/asset_translation_handler'; + +describe('AssetTranslationHandler', () => { + describe('canTranslate()', () => { + it('should always return true', () => { + const handler = new AssetTranslationHandler(); + expect(handler.canTranslate('relative/path', Buffer.from('contents'))).toBe(true); + }); + }); + + describe('translate()', () => { + beforeEach(() => { + spyOn(FileUtils, 'writeFile'); + spyOn(FileUtils, 'ensureDir'); + }); + + it('should write the translated file for each translation locale', () => { + const diagnostics = new Diagnostics(); + const handler = new AssetTranslationHandler(); + const translations = [ + {locale: 'en', translations: {}}, + {locale: 'fr', translations: {}}, + ]; + const contents = Buffer.from('contents'); + handler.translate( + diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations); + + expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/en/relative/path', contents); + expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/fr/relative/path', contents); + }); + }); +}); + +function mockOutputPathFn(locale: string, relativePath: string) { + return `/translations/${locale}/${relativePath}`; +} diff --git a/packages/localize/src/tools/test/translate/integration/BUILD.bazel b/packages/localize/src/tools/test/translate/integration/BUILD.bazel new file mode 100644 index 0000000000..9f2eeabf1a --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["**/*_spec.ts"], + ), + deps = [ + "//packages:types", + "//packages/localize/src/tools", + ], +) + +jasmine_node_test( + name = "integration", + bootstrap = [ + "angular/tools/testing/init_node_no_angular_spec.js", + ], + data = glob( + [ + "locales/**", + "test_files/**", + ], + ), + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + "@npm//glob", + "@npm//yargs", + ], +) diff --git a/packages/localize/src/tools/test/translate/integration/locales/messages.de.json b/packages/localize/src/tools/test/translate/integration/locales/messages.de.json new file mode 100644 index 0000000000..14ff38b2f9 --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/locales/messages.de.json @@ -0,0 +1,6 @@ +{ + "locale": "de", + "translations": { + "3291030485717846467": "Guten Tag, {$PH}!" + } +} \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/integration/locales/messages.es.xlf b/packages/localize/src/tools/test/translate/integration/locales/messages.es.xlf new file mode 100644 index 0000000000..7b23119b0b --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/locales/messages.es.xlf @@ -0,0 +1,14 @@ + + + + + Hello, ! + Hola, ! + + file.ts + 2 + + + + + diff --git a/packages/localize/src/tools/test/translate/integration/locales/messages.fr.xlf b/packages/localize/src/tools/test/translate/integration/locales/messages.fr.xlf new file mode 100644 index 0000000000..c9be8b470d --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/locales/messages.fr.xlf @@ -0,0 +1,13 @@ + + + + + file.ts:2 + + + Hello, ! + Bonjour, ! + + + + diff --git a/packages/localize/src/tools/test/translate/integration/main_spec.ts b/packages/localize/src/tools/test/translate/integration/main_spec.ts new file mode 100644 index 0000000000..bfaecaacf2 --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/main_spec.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {resolve} from 'path'; + +import {Diagnostics} from '../../../src/diagnostics'; +import {FileUtils} from '../../../src/file_utils'; +import {translateFiles} from '../../../src/translate/main'; +import {getOutputPathFn} from '../../../src/translate/output_path'; + +describe('translateFiles()', () => { + const tmpDir = process.env.TEST_TMPDIR; + if (tmpDir === undefined) return; + + const testDir = resolve(tmpDir, 'translatedFiles_tests'); + + beforeEach(() => FileUtils.ensureDir(testDir)); + afterEach(() => { FileUtils.remove(testDir); }); + + it('should copy non-code files to the destination folders', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: resolve(__dirname, 'test_files'), + sourceFilePaths: resolveAll(__dirname + '/test_files', ['test-1.txt', 'test-2.txt']), + outputPathFn, + translationFilePaths: resolveAll( + __dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']), + diagnostics, + missingTranslation: 'error' + }); + + expect(diagnostics.messages.length).toEqual(0); + + expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(FileUtils.readFile(resolve(testDir, 'de', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(FileUtils.readFile(resolve(testDir, 'de', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(FileUtils.readFile(resolve(testDir, 'es', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(FileUtils.readFile(resolve(testDir, 'es', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + }); + + it('should translate and copy source-code files to the destination folders', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: resolve(__dirname, 'test_files'), + sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), outputPathFn, + translationFilePaths: resolveAll( + __dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']), + diagnostics, + missingTranslation: 'error', + }); + + expect(diagnostics.messages.length).toEqual(0); + + expect(FileUtils.readFile(resolve(testDir, 'fr', 'test.js'))) + .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); + expect(FileUtils.readFile(resolve(testDir, 'de', 'test.js'))) + .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); + expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js'))) + .toEqual(`var name="World";var message="Hola, "+name+"!";`); + }); + + it('should transform and/or copy files to the destination folders', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: resolve(__dirname, 'test_files'), + sourceFilePaths: + resolveAll(__dirname + '/test_files', ['test-1.txt', 'test-2.txt', 'test.js']), + outputPathFn, + translationFilePaths: resolveAll( + __dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']), + diagnostics, + missingTranslation: 'error', + }); + + expect(diagnostics.messages.length).toEqual(0); + + expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(FileUtils.readFile(resolve(testDir, 'de', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(FileUtils.readFile(resolve(testDir, 'de', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(FileUtils.readFile(resolve(testDir, 'es', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(FileUtils.readFile(resolve(testDir, 'es', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + + expect(FileUtils.readFile(resolve(testDir, 'fr', 'test.js'))) + .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); + expect(FileUtils.readFile(resolve(testDir, 'de', 'test.js'))) + .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); + expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js'))) + .toEqual(`var name="World";var message="Hola, "+name+"!";`); + }); +}); + +function resolveAll(rootPath: string, paths: string[]): string[] { + return paths.map(p => resolve(rootPath, p)); +} diff --git a/packages/localize/src/tools/test/translate/integration/test_files/test-1.txt b/packages/localize/src/tools/test/translate/integration/test_files/test-1.txt new file mode 100644 index 0000000000..46a9dd3a2d --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/test_files/test-1.txt @@ -0,0 +1 @@ +Contents of test-1.txt \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/integration/test_files/test-2.txt b/packages/localize/src/tools/test/translate/integration/test_files/test-2.txt new file mode 100644 index 0000000000..b48d58aff5 --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/test_files/test-2.txt @@ -0,0 +1 @@ +Contents of test-2.txt \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/integration/test_files/test.js b/packages/localize/src/tools/test/translate/integration/test_files/test.js new file mode 100644 index 0000000000..8b3009345f --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/test_files/test.js @@ -0,0 +1,2 @@ +var name = 'World'; +var message = $localize `Hello, ${name}!`; \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/output_path_spec.ts b/packages/localize/src/tools/test/translate/output_path_spec.ts new file mode 100644 index 0000000000..f952d54706 --- /dev/null +++ b/packages/localize/src/tools/test/translate/output_path_spec.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {getOutputPathFn} from '../../src/translate/output_path'; + +describe('getOutputPathFn()', () => { + it('should return a function that joins the `outputPath` and the `relativePath`', () => { + const fn = getOutputPathFn('/output/path'); + expect(fn('en', 'relative/path')).toEqual('/output/path/relative/path'); + expect(fn('en', '../parent/path')).toEqual('/output/parent/path'); + }); + + it('should return a function that interpolates the `{{LOCALE}}` in the middle of the `outputPath`', + () => { + const fn = getOutputPathFn('/output/{{LOCALE}}/path'); + expect(fn('en', 'relative/path')).toEqual('/output/en/path/relative/path'); + expect(fn('fr', 'relative/path')).toEqual('/output/fr/path/relative/path'); + }); + + it('should return a function that interpolates the `{{LOCALE}}` in the middle of a path segment in the `outputPath`', + () => { + const fn = getOutputPathFn('/output-{{LOCALE}}-path'); + expect(fn('en', 'relative/path')).toEqual('/output-en-path/relative/path'); + expect(fn('fr', 'relative/path')).toEqual('/output-fr-path/relative/path'); + }); + + it('should return a function that interpolates the `{{LOCALE}}` at the start of the `outputPath`', + () => { + const fn = getOutputPathFn('{{LOCALE}}/path'); + expect(fn('en', 'relative/path')).toEqual('en/path/relative/path'); + expect(fn('fr', 'relative/path')).toEqual('fr/path/relative/path'); + }); + + it('should return a function that interpolates the `{{LOCALE}}` at the end of the `outputPath`', + () => { + const fn = getOutputPathFn('/output/{{LOCALE}}'); + expect(fn('en', 'relative/path')).toEqual('/output/en/relative/path'); + expect(fn('fr', 'relative/path')).toEqual('/output/fr/relative/path'); + }); +}); \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/source_files/es2015_translate_plugin_spec.ts b/packages/localize/src/tools/test/translate/source_files/es2015_translate_plugin_spec.ts new file mode 100644 index 0000000000..5a034a5a52 --- /dev/null +++ b/packages/localize/src/tools/test/translate/source_files/es2015_translate_plugin_spec.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵcomputeMsgId, ɵparseTranslation} from '@angular/localize'; +import {transformSync} from '@babel/core'; + +import {Diagnostics} from '../../../src/diagnostics'; +import {makeEs2015TranslatePlugin} from '../../../src/translate/source_files/es2015_translate_plugin'; + +describe('makeEs2015Plugin', () => { + describe('(no translations)', () => { + it('should transform `$localize` tags with binary expression', () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize`try\\n${40 + b}\\n me`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('const b = 10;\n"try\\n" + (40 + b) + "\\n me";'); + }); + + it('should transform nested `$localize` tags', () => { + const diagnostics = new Diagnostics(); + const input = '$localize`a${1}b${$localize`x${5}y${6}z`}c`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('"a" + 1 + "b" + ("x" + 5 + "y" + 6 + "z") + "c";'); + }); + + it('should transform tags inside functions', () => { + const diagnostics = new Diagnostics(); + const input = 'function foo() { $localize`a${1}b${2}c`; }'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('function foo() {\n "a" + 1 + "b" + 2 + "c";\n}'); + }); + + it('should ignore tags with the wrong name', () => { + const diagnostics = new Diagnostics(); + const input = 'other`a${1}b${2}c`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('other`a${1}b${2}c`;'); + }); + + it('should transform tags with different tag name configured', () => { + const diagnostics = new Diagnostics(); + const input = 'other`a${1}b${2}c`;'; + const output = transformSync( + input, + {plugins: [makeEs2015TranslatePlugin(diagnostics, {}, {localizeName: 'other'})]}) !; + expect(output.code).toEqual('"a" + 1 + "b" + 2 + "c";'); + }); + + it('should ignore tags if the identifier is not global', () => { + const diagnostics = new Diagnostics(); + const input = 'function foo($localize) { $localize`a${1}b${2}c`; }'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('function foo($localize) {\n $localize`a${1}b${2}c`;\n}'); + }); + + it('should add missing translation to diagnostic errors if missingTranslation is set to "error"', + () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize `try\\n${40 + b}\\n me`;'; + transformSync(input, { + plugins: [makeEs2015TranslatePlugin(diagnostics, {}, {missingTranslation: 'error'})] + }) !; + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + `No translation found for "${ɵcomputeMsgId('try\n{$PH}\n me')}" ("try\n{$PH}\n me").` + }); + }); + + it('should add missing translation to diagnostic errors if missingTranslation is set to "warning"', + () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize `try\\n${40 + b}\\n me`;'; + transformSync(input, { + plugins: + [makeEs2015TranslatePlugin(diagnostics, {}, {missingTranslation: 'warning'})] + }) !; + expect(diagnostics.hasErrors).toBe(false); + expect(diagnostics.messages[0]).toEqual({ + type: 'warning', + message: + `No translation found for "${ɵcomputeMsgId('try\n{$PH}\n me')}" ("try\n{$PH}\n me").` + }); + }); + + it('should add missing translation to diagnostic errors if missingTranslation is set to "ignore"', + () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize `try\\n${40 + b}\\n me`;'; + transformSync(input, { + plugins: + [makeEs2015TranslatePlugin(diagnostics, {}, {missingTranslation: 'ignore'})] + }) !; + expect(diagnostics.hasErrors).toBe(false); + expect(diagnostics.messages).toEqual([]); + }); + }); + + describe('(with translations)', () => { + it('should translate message parts (identity translations)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc')]: ɵparseTranslation('abc'), + [ɵcomputeMsgId('abc{$PH}')]: ɵparseTranslation('abc{$PH}'), + [ɵcomputeMsgId('abc{$PH}def')]: ɵparseTranslation('abc{$PH}def'), + [ɵcomputeMsgId('abc{$PH}def{$PH_1}')]: ɵparseTranslation('abc{$PH}def{$PH_1}'), + [ɵcomputeMsgId('Hello, {$PH}!')]: ɵparseTranslation('Hello, {$PH}!'), + }; + const input = '$localize `abc`;\n' + + '$localize `abc${1 + 2 + 3}`;\n' + + '$localize `abc${1 + 2 + 3}def`;\n' + + '$localize `abc${1 + 2 + 3}def${4 + 5 + 6}`;\n' + + '$localize `Hello, ${getName()}!`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code) + .toEqual( + '"abc";\n' + + '"abc" + (1 + 2 + 3) + "";\n' + + '"abc" + (1 + 2 + 3) + "def";\n' + + '"abc" + (1 + 2 + 3) + "def" + (4 + 5 + 6) + "";\n' + + '"Hello, " + getName() + "!";'); + }); + + it('should translate message parts (uppercase translations)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc')]: ɵparseTranslation('ABC'), + [ɵcomputeMsgId('abc{$PH}')]: ɵparseTranslation('ABC{$PH}'), + [ɵcomputeMsgId('abc{$PH}def')]: ɵparseTranslation('ABC{$PH}DEF'), + [ɵcomputeMsgId('abc{$PH}def{$PH_1}')]: ɵparseTranslation('ABC{$PH}DEF{$PH_1}'), + [ɵcomputeMsgId('Hello, {$PH}!')]: ɵparseTranslation('HELLO, {$PH}!'), + }; + const input = '$localize `abc`;\n' + + '$localize `abc${1 + 2 + 3}`;\n' + + '$localize `abc${1 + 2 + 3}def`;\n' + + '$localize `abc${1 + 2 + 3}def${4 + 5 + 6}`;\n' + + '$localize `Hello, ${getName()}!`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code) + .toEqual( + '"ABC";\n' + + '"ABC" + (1 + 2 + 3) + "";\n' + + '"ABC" + (1 + 2 + 3) + "DEF";\n' + + '"ABC" + (1 + 2 + 3) + "DEF" + (4 + 5 + 6) + "";\n' + + '"HELLO, " + getName() + "!";'); + }); + + it('should translate message parts (reversing placeholders)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc{$PH}def{$PH_1} - Hello, {$PH_2}!')]: + ɵparseTranslation('abc{$PH_2}def{$PH_1} - Hello, {$PH}!'), + }; + const input = '$localize `abc${1 + 2 + 3}def${4 + 5 + 6} - Hello, ${getName()}!`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code) + .toEqual('"abc" + getName() + "def" + (4 + 5 + 6) + " - Hello, " + (1 + 2 + 3) + "!";'); + }); + + it('should translate message parts (removing placeholders)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc{$PH}def{$PH_1} - Hello, {$PH_2}!')]: + ɵparseTranslation('abc{$PH} - Hello, {$PH_2}!'), + }; + const input = '$localize `abc${1 + 2 + 3}def${4 + 5 + 6} - Hello, ${getName()}!`;'; + const output = + transformSync(input, {plugins: [makeEs2015TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code).toEqual('"abc" + (1 + 2 + 3) + " - Hello, " + getName() + "!";'); + }); + }); +}); diff --git a/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts b/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts new file mode 100644 index 0000000000..bc263d614d --- /dev/null +++ b/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵcomputeMsgId, ɵparseTranslation} from '@angular/localize'; +import {transformSync} from '@babel/core'; + +import {Diagnostics} from '../../../src/diagnostics'; +import {makeEs5TranslatePlugin} from '../../../src/translate/source_files/es5_translate_plugin'; + +describe('makeEs5Plugin', () => { + describe('(no translations)', () => { + it('should transform `$localize` calls with binary expression', () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize(["try\\n", "\\n me"], 40 + b);'; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('const b = 10;\n"try\\n" + (40 + b) + "\\n me";'); + }); + + it('should transform nested `$localize` calls', () => { + const diagnostics = new Diagnostics(); + const input = '$localize(["a", "b", "c"], 1, $localize(["x", "y", "z"], 5, 6));'; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('"a" + 1 + "b" + ("x" + 5 + "y" + 6 + "z") + "c";'); + }); + + it('should transform calls inside functions', () => { + const diagnostics = new Diagnostics(); + const input = 'function foo() { $localize(["a", "b", "c"], 1, 2); }'; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('function foo() {\n "a" + 1 + "b" + 2 + "c";\n}'); + }); + + it('should ignore tags with the wrong name', () => { + const diagnostics = new Diagnostics(); + const input = 'other(["a", "b", "c"], 1, 2);'; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('other(["a", "b", "c"], 1, 2);'); + }); + + it('should transform calls with different function name configured', () => { + const diagnostics = new Diagnostics(); + const input = 'other(["a", "b", "c"], 1, 2);'; + const output = transformSync( + input, {plugins: [makeEs5TranslatePlugin(diagnostics, {}, {localizeName: 'other'})]}) !; + expect(output.code).toEqual('"a" + 1 + "b" + 2 + "c";'); + }); + + it('should ignore tags if the identifier is not global', () => { + const diagnostics = new Diagnostics(); + const input = 'function foo($localize) { $localize(["a", "b", "c"], 1, 2); }'; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code) + .toEqual('function foo($localize) {\n $localize(["a", "b", "c"], 1, 2);\n}'); + }); + + it('should handle template object helper calls', () => { + const diagnostics = new Diagnostics(); + const input = `$localize(__makeTemplateObject(['try', 'me'], ['try', 'me']), 40 + 2);`; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('"try" + (40 + 2) + "me";'); + }); + + it('should handle template object aliased helper calls', () => { + const diagnostics = new Diagnostics(); + const input = `$localize(m(['try', 'me'], ['try', 'me']), 40 + 2);`; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('"try" + (40 + 2) + "me";'); + }); + + it('should handle template object inline helper calls', () => { + const diagnostics = new Diagnostics(); + const input = + `$localize((this&&this.__makeTemplateObject||function(e,t){return Object.defineProperty?Object.defineProperty(e,"raw",{value:t}):e.raw=t,e})(['try', 'me'], ['try', 'me']), 40 + 2);`; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('"try" + (40 + 2) + "me";'); + }); + + it('should handle cached helper calls', () => { + const diagnostics = new Diagnostics(); + const input = + `$localize(cachedObj||(cachedObj=__makeTemplateObject(['try', 'me'],['try', 'me'])),40 + 2)`; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toEqual('"try" + (40 + 2) + "me";'); + }); + + it('should add diagnostic error with code-frame information if the arguments to `$localize` are missing', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize()'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: '/app/dist/test.js: Unexpected argument to `$localize`: undefined\n' + + '> 1 | $localize()\n' + + ' | ^', + }); + }); + + it('should add diagnostic error with code-frame information if the first argument to `$localize` is not an array', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize(null, [])'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + '/app/dist/test.js: Unexpected messageParts for `$localize` (expected an array of strings).\n' + + '> 1 | $localize(null, [])\n' + + ' | ^', + }); + }); + + it('should add diagnostic error with code-frame information if raw message parts are not an expression', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize(__makeTemplateObject([], ...[]))'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + '/app/dist/test.js: Unexpected `raw` argument to the "makeTemplateObject()" function (expected an expression).\n' + + '> 1 | $localize(__makeTemplateObject([], ...[]))\n' + + ' | ^', + }); + }); + + it('should add diagnostic error with code-frame information if cooked message parts are not an expression', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize(__makeTemplateObject(...[], []))'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + '/app/dist/test.js: Unexpected `cooked` argument to the "makeTemplateObject()" function (expected an expression).\n' + + '> 1 | $localize(__makeTemplateObject(...[], []))\n' + + ' | ^', + }); + }); + + it('should add diagnostic error with code-frame information if not all cooked message parts are strings', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize(__makeTemplateObject(["a", 12, "b"], ["a", "12", "b"]))'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + '/app/dist/test.js: Unexpected messageParts for `$localize` (expected an array of strings).\n' + + '> 1 | $localize(__makeTemplateObject(["a", 12, "b"], ["a", "12", "b"]))\n' + + ' | ^', + }); + }); + + it('should add diagnostic error with code-frame information if not all raw message parts are strings', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize(__makeTemplateObject(["a", "12", "b"], ["a", 12, "b"]))'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + '/app/dist/test.js: Unexpected messageParts for `$localize` (expected an array of strings).\n' + + '> 1 | $localize(__makeTemplateObject(["a", "12", "b"], ["a", 12, "b"]))\n' + + ' | ^', + }); + }); + + it('should add diagnostic error with code-frame information if not all substitutions are expressions', + () => { + const diagnostics = new Diagnostics(); + const input = '$localize(__makeTemplateObject(["a", "b"], ["a", "b"]), ...[])'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {})], filename: '/app/dist/test.js'}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + '/app/dist/test.js: Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).\n' + + '> 1 | $localize(__makeTemplateObject(["a", "b"], ["a", "b"]), ...[])\n' + + ' | ^', + }); + }); + + it('should add missing translation to diagnostic errors if missingTranslation is set to "error"', + () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize(["try\\n", "\\n me"], 40 + b);'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {}, {missingTranslation: 'error'})]}); + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: + `No translation found for "${ɵcomputeMsgId('try\n{$PH}\n me')}" ("try\n{$PH}\n me").` + }); + }); + + it('should add missing translation to diagnostic warnings if missingTranslation is set to "warning"', + () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize(["try\\n", "\\n me"], 40 + b);'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {}, {missingTranslation: 'warning'})]}); + expect(diagnostics.hasErrors).toBe(false); + expect(diagnostics.messages[0]).toEqual({ + type: 'warning', + message: + `No translation found for "${ɵcomputeMsgId('try\n{$PH}\n me')}" ("try\n{$PH}\n me").` + }); + }); + + it('should ignore missing translations if missingTranslation is set to "ignore"', () => { + const diagnostics = new Diagnostics(); + const input = 'const b = 10;\n$localize(["try\\n", "\\n me"], 40 + b);'; + transformSync( + input, + {plugins: [makeEs5TranslatePlugin(diagnostics, {}, {missingTranslation: 'ignore'})]}); + expect(diagnostics.hasErrors).toBe(false); + expect(diagnostics.messages).toEqual([]); + }); + }); +}); + +describe('(with translations)', () => { + it('should translate message parts (identity translations)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc')]: ɵparseTranslation('abc'), + [ɵcomputeMsgId('abc{$PH}')]: ɵparseTranslation('abc{$PH}'), + [ɵcomputeMsgId('abc{$PH}def')]: ɵparseTranslation('abc{$PH}def'), + [ɵcomputeMsgId('abc{$PH}def{$PH_1}')]: ɵparseTranslation('abc{$PH}def{$PH_1}'), + [ɵcomputeMsgId('Hello, {$PH}!')]: ɵparseTranslation('Hello, {$PH}!'), + }; + const input = '$localize(["abc"]);\n' + + '$localize(["abc", ""], 1 + 2 + 3);\n' + + '$localize(["abc", "def"], 1 + 2 + 3);\n' + + '$localize(["abc", "def", ""], 1 + 2 + 3, 4 + 5 + 6);\n' + + '$localize(["Hello, ", "!"], getName());'; + const output = + transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code) + .toEqual( + '"abc";\n' + + '"abc" + (1 + 2 + 3) + "";\n' + + '"abc" + (1 + 2 + 3) + "def";\n' + + '"abc" + (1 + 2 + 3) + "def" + (4 + 5 + 6) + "";\n' + + '"Hello, " + getName() + "!";'); + }); + + it('should translate message parts (uppercase translations)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc')]: ɵparseTranslation('ABC'), + [ɵcomputeMsgId('abc{$PH}')]: ɵparseTranslation('ABC{$PH}'), + [ɵcomputeMsgId('abc{$PH}def')]: ɵparseTranslation('ABC{$PH}DEF'), + [ɵcomputeMsgId('abc{$PH}def{$PH_1}')]: ɵparseTranslation('ABC{$PH}DEF{$PH_1}'), + [ɵcomputeMsgId('Hello, {$PH}!')]: ɵparseTranslation('HELLO, {$PH}!'), + }; + const input = '$localize(["abc"]);\n' + + '$localize(["abc", ""], 1 + 2 + 3);\n' + + '$localize(["abc", "def"], 1 + 2 + 3);\n' + + '$localize(["abc", "def", ""], 1 + 2 + 3, 4 + 5 + 6);\n' + + '$localize(["Hello, ", "!"], getName());'; + const output = + transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code) + .toEqual( + '"ABC";\n' + + '"ABC" + (1 + 2 + 3) + "";\n' + + '"ABC" + (1 + 2 + 3) + "DEF";\n' + + '"ABC" + (1 + 2 + 3) + "DEF" + (4 + 5 + 6) + "";\n' + + '"HELLO, " + getName() + "!";'); + }); + + it('should translate message parts (reversing placeholders)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc{$PH}def{$PH_1} - Hello, {$PH_2}!')]: + ɵparseTranslation('abc{$PH_2}def{$PH_1} - Hello, {$PH}!'), + }; + const input = '$localize(["abc", "def", " - Hello, ", "!"], 1 + 2 + 3, 4 + 5 + 6, getName());'; + const output = + transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code) + .toEqual('"abc" + getName() + "def" + (4 + 5 + 6) + " - Hello, " + (1 + 2 + 3) + "!";'); + }); + + it('should translate message parts (removing placeholders)', () => { + const diagnostics = new Diagnostics(); + const translations = { + [ɵcomputeMsgId('abc{$PH}def{$PH_1} - Hello, {$PH_2}!')]: + ɵparseTranslation('abc{$PH} - Hello, {$PH_2}!'), + }; + const input = '$localize(["abc", "def", " - Hello, ", "!"], 1 + 2 + 3, 4 + 5 + 6, getName());'; + const output = + transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, translations)]}) !; + expect(output.code).toEqual('"abc" + (1 + 2 + 3) + " - Hello, " + getName() + "!";'); + }); +}); diff --git a/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts b/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts new file mode 100644 index 0000000000..788d21124d --- /dev/null +++ b/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Diagnostics} from '../../../src/diagnostics'; +import {FileUtils} from '../../../src/file_utils'; +import {SourceFileTranslationHandler} from '../../../src/translate/source_files/source_file_translation_handler'; + +describe('SourceFileTranslationHandler', () => { + describe('canTranslate()', () => { + it('should return true if the path ends in ".js"', () => { + const handler = new SourceFileTranslationHandler(); + expect(handler.canTranslate('relative/path', Buffer.from('contents'))).toBe(false); + expect(handler.canTranslate('relative/path.js', Buffer.from('contents'))).toBe(true); + }); + }); + + describe('translate()', () => { + beforeEach(() => { spyOn(FileUtils, 'writeFile'); }); + + it('should copy files for each translation locale if they contain no reference to `$localize`', + () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(); + const translations = [ + {locale: 'en', translations: {}}, + {locale: 'fr', translations: {}}, + ]; + const contents = Buffer.from('contents'); + handler.translate( + diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations); + + expect(FileUtils.writeFile) + .toHaveBeenCalledWith('/translations/en/relative/path', contents); + expect(FileUtils.writeFile) + .toHaveBeenCalledWith('/translations/fr/relative/path', contents); + }); + + it('should transform each $localize template tag', () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(); + const translations = [ + {locale: 'en', translations: {}}, + {locale: 'fr', translations: {}}, + ]; + const contents = Buffer.from( + '$localize`a${1}b${2}c`;\n' + + '$localize(__makeTemplateObject(["a", "b", "c"], ["a", "b", "c"]), 1, 2);'); + const output = '"a"+1+"b"+2+"c";"a"+1+"b"+2+"c";'; + handler.translate( + diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, translations); + + expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/en/relative/path.js', output); + expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/fr/relative/path.js', output); + }); + + it('should error if the file is not valid JS', () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(); + const translations = [{locale: 'en', translations: {}}]; + const contents = Buffer.from('this is not a valid $localize file.'); + expect( + () => handler.translate( + diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, + translations)) + .toThrowError(); + }); + }); +}); + +function mockOutputPathFn(locale: string, relativePath: string) { + return `/translations/${locale}/${relativePath}`; +} diff --git a/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts b/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts new file mode 100644 index 0000000000..317c6519ca --- /dev/null +++ b/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵmakeTemplateObject} from '@angular/localize'; +import {NodePath, transformSync} from '@babel/core'; +import generate from '@babel/generator'; +import template from '@babel/template'; +import {Expression, Identifier, TaggedTemplateExpression, ExpressionStatement, FunctionDeclaration, CallExpression, isParenthesizedExpression, numericLiteral, binaryExpression, NumericLiteral} from '@babel/types'; +import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral} from '../../../src/translate/source_files/source_file_utils'; + +describe('utils', () => { + describe('isNamedIdentifier()', () => { + it('should return true if the expression is an identifier with name `$localize`', () => { + const taggedTemplate = getTaggedTemplate('$localize ``;'); + expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(true); + }); + + it('should return false if the expression is an identifier without the name `$localize`', + () => { + const taggedTemplate = getTaggedTemplate('other ``;'); + expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false); + }); + + it('should return false if the expression is not an identifier', () => { + const taggedTemplate = getTaggedTemplate('$localize() ``;'); + expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false); + }); + }); + + describe('isGlobalIdentifier()', () => { + it('should return true if the identifier is at the top level and not declared', () => { + const taggedTemplate = getTaggedTemplate('$localize ``;'); + expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath)).toBe(true); + }); + + it('should return true if the identifier is in a block scope and not declared', () => { + const taggedTemplate = getTaggedTemplate('function foo() { $localize ``; } foo();'); + expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath)).toBe(true); + }); + + it('should return false if the identifier is declared locally', () => { + const taggedTemplate = getTaggedTemplate('function $localize() {} $localize ``;'); + expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath)).toBe(false); + }); + + it('should return false if the identifier is a function parameter', () => { + const taggedTemplate = getTaggedTemplate('function foo($localize) { $localize ``; }'); + expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath)).toBe(false); + }); + }); + + describe('buildLocalizeReplacement', () => { + it('should interleave the `messageParts` with the `substitutions`', () => { + const messageParts = ɵmakeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']); + const substitutions = [numericLiteral(1), numericLiteral(2)]; + const expression = buildLocalizeReplacement(messageParts, substitutions); + expect(generate(expression).code).toEqual('"a" + 1 + "b" + 2 + "c"'); + }); + + it('should wrap "binary expression" substitutions in parentheses', () => { + const messageParts = ɵmakeTemplateObject(['a', 'b'], ['a', 'b']); + const binary = binaryExpression('+', numericLiteral(1), numericLiteral(2)); + const expression = buildLocalizeReplacement(messageParts, [binary]); + expect(generate(expression).code).toEqual('"a" + (1 + 2) + "b"'); + }); + }); + + describe('unwrapMessagePartsFromLocalizeCall', () => { + it('should return an array of string literals from a direct call to a tag function', () => { + const ast = template.ast `$localize(['a', 'b\\t', 'c'], 1, 2)` as ExpressionStatement; + const call = ast.expression as CallExpression; + const parts = unwrapMessagePartsFromLocalizeCall(call); + expect(parts).toEqual(['a', 'b\t', 'c']); + }); + + it('should return an array of string literals from a downleveled tagged template', () => { + const ast = template.ast + `$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)` as + ExpressionStatement; + const call = ast.expression as CallExpression; + const parts = unwrapMessagePartsFromLocalizeCall(call); + expect(parts).toEqual(['a', 'b\t', 'c']); + expect(parts.raw).toEqual(['a', 'b\\t', 'c']); + }); + }); + + describe('unwrapSubstitutionsFromLocalizeCall', () => { + it('should return the substitutions from a direct call to a tag function', () => { + const ast = template.ast `$localize(['a', 'b\t', 'c'], 1, 2)` as ExpressionStatement; + const call = ast.expression as CallExpression; + const substitutions = unwrapSubstitutionsFromLocalizeCall(call); + expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]); + }); + + it('should return the substitutions from a downleveled tagged template', () => { + const ast = template.ast + `$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)` as + ExpressionStatement; + const call = ast.expression as CallExpression; + const substitutions = unwrapSubstitutionsFromLocalizeCall(call); + expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]); + }); + }); + + describe('unwrapMessagePartsFromTemplateLiteral', () => { + it('should return a TemplateStringsArray built from the template literal elements', () => { + const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;'); + expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.node.quasi.quasis)) + .toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c'])); + }); + }); + + describe('wrapInParensIfNecessary', () => { + it('should wrap the expression in parentheses if it is binary', () => { + const ast = template.ast `a + b` as ExpressionStatement; + const wrapped = wrapInParensIfNecessary(ast.expression); + expect(isParenthesizedExpression(wrapped)).toBe(true); + }); + + it('should return the expression untouched if it is not binary', () => { + const ast = template.ast `a` as ExpressionStatement; + const wrapped = wrapInParensIfNecessary(ast.expression); + expect(isParenthesizedExpression(wrapped)).toBe(false); + }); + }); + + describe('unwrapStringLiteralArray', () => { + it('should return an array of string from an array expression', () => { + const ast = template.ast `['a', 'b', 'c']` as ExpressionStatement; + expect(unwrapStringLiteralArray(ast.expression)).toEqual(['a', 'b', 'c']); + }); + + it('should throw an error if any elements of the array are not literal strings', () => { + const ast = template.ast `['a', 2, 'c']` as ExpressionStatement; + expect(() => unwrapStringLiteralArray(ast.expression)) + .toThrowError('Unexpected messageParts for `$localize` (expected an array of strings).'); + }); + }); + + describe('isStringLiteralArray()', () => { + it('should return true if the ast is an array of strings', () => { + const ast = template.ast `['a', 'b', 'c']` as ExpressionStatement; + expect(isStringLiteralArray(ast.expression)).toBe(true); + }); + + it('should return false if the ast is not an array', () => { + const ast = template.ast `'a'` as ExpressionStatement; + expect(isStringLiteralArray(ast.expression)).toBe(false); + }); + + it('should return false if at least on of the array elements is not a string', () => { + const ast = template.ast `['a', 1, 'b']` as ExpressionStatement; + expect(isStringLiteralArray(ast.expression)).toBe(false); + }); + }); + + describe('isArrayOfExpressions()', () => { + it('should return true if all the nodes are expressions', () => { + const ast = template.ast `function foo(a, b, c) {}` as FunctionDeclaration; + expect(isArrayOfExpressions(ast.params)).toBe(true); + }); + + it('should return false if any of the nodes is not an expression', () => { + const ast = template.ast `function foo(a, b, ...c) {}` as FunctionDeclaration; + expect(isArrayOfExpressions(ast.params)).toBe(false); + }); + }); +}); + +function getTaggedTemplate(code: string): NodePath { + const {expressions, plugin} = collectExpressionsPlugin(); + transformSync(code, {plugins: [plugin]}); + return expressions.find(e => e.isTaggedTemplateExpression()) as any; +} + +function collectExpressionsPlugin() { + const expressions: NodePath[] = []; + const visitor = {Expression: (path: NodePath) => { expressions.push(path); }}; + return {expressions, plugin: {visitor}}; +} diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts new file mode 100644 index 0000000000..9f7497c152 --- /dev/null +++ b/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵParsedTranslation} from '@angular/localize'; + +import {FileUtils} from '../../../src/file_utils'; +import {TranslationLoader} from '../../../src/translate/translation_files/translation_file_loader'; +import {TranslationParser} from '../../../src/translate/translation_files/translation_parsers/translation_parser'; + +describe('TranslationLoader', () => { + describe('loadBundles()', () => { + beforeEach(() => { + spyOn(FileUtils, 'readFile').and.returnValues('english messages', 'french messages'); + }); + + it('should `canParse()` and `parse()` for each file', () => { + const parser = new MockTranslationParser(true); + const loader = new TranslationLoader([parser]); + loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf']); + expect(parser.log).toEqual([ + 'canParse(/src/locale/messages.en.xlf, english messages)', + 'parse(/src/locale/messages.en.xlf, english messages)', + 'canParse(/src/locale/messages.fr.xlf, french messages)', + 'parse(/src/locale/messages.fr.xlf, french messages)', + ]); + }); + + it('should stop at the first parser that can parse each file', () => { + const parser1 = new MockTranslationParser(false); + const parser2 = new MockTranslationParser(true); + const parser3 = new MockTranslationParser(true); + const loader = new TranslationLoader([parser1, parser2, parser3]); + loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf']); + expect(parser1.log).toEqual([ + 'canParse(/src/locale/messages.en.xlf, english messages)', + 'canParse(/src/locale/messages.fr.xlf, french messages)', + ]); + expect(parser2.log).toEqual([ + 'canParse(/src/locale/messages.en.xlf, english messages)', + 'parse(/src/locale/messages.en.xlf, english messages)', + 'canParse(/src/locale/messages.fr.xlf, french messages)', + 'parse(/src/locale/messages.fr.xlf, french messages)', + ]); + }); + + it('should return locale and translations parsed from each file', () => { + const translations = {}; + const parser = new MockTranslationParser(true, 'pl', translations); + const loader = new TranslationLoader([parser]); + const result = + loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf']); + expect(result).toEqual([ + {locale: 'pl', translations}, + {locale: 'pl', translations}, + ]); + }); + + it('should error if none of the parsers can parse the file', () => { + const parser = new MockTranslationParser(false); + const loader = new TranslationLoader([parser]); + expect(() => loader.loadBundles([ + '/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf' + ])).toThrowError('Unable to parse translation file: /src/locale/messages.en.xlf'); + }); + }); +}); + +class MockTranslationParser implements TranslationParser { + log: string[] = []; + constructor( + private _canParse: boolean = true, private _locale: string = 'fr', + private _translations: Record = {}) {} + + canParse(filePath: string, fileContents: string) { + this.log.push(`canParse(${filePath}, ${fileContents})`); + return this._canParse; + } + + parse(filePath: string, fileContents: string) { + this.log.push(`parse(${filePath}, ${fileContents})`); + return {locale: this._locale, translations: this._translations}; + } +} \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/simple_json/simple_json_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/simple_json/simple_json_spec.ts new file mode 100644 index 0000000000..d4fbff595f --- /dev/null +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/simple_json/simple_json_spec.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵmakeTemplateObject} from '@angular/localize'; +import {SimpleJsonTranslationParser} from '../../../../../src/translate/translation_files/translation_parsers/simple_json/simple_json_translation_parser'; + +describe('SimpleJsonTranslationParser', () => { + describe('canParse()', () => { + it('should return true if the file extension is `.json`', () => { + const parser = new SimpleJsonTranslationParser(); + expect(parser.canParse('/some/file.xlf', '')).toBe(false); + expect(parser.canParse('/some/file.json', '')).toBe(true); + }); + }); + + describe('parse()', () => { + it('should extract the locale from the JSON contents', () => { + const parser = new SimpleJsonTranslationParser(); + const result = parser.parse('/some/file.json', '{"locale": "en", "translations": {}}'); + expect(result.locale).toEqual('en'); + }); + + it('should extract and process the translations from the JSON contents', () => { + const parser = new SimpleJsonTranslationParser(); + const result = parser.parse('/some/file.json', `{ + "locale": "fr", + "translations": { + "Hello, {$ph_1}!": "Bonjour, {$ph_1}!" + } + }`); + expect(result.translations).toEqual({ + 'Hello, {$ph_1}!': { + messageParts: ɵmakeTemplateObject(['Bonjour, ', '!'], ['Bonjour, ', '!']), + placeholderNames: ['ph_1'] + }, + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser_spec.ts new file mode 100644 index 0000000000..972f33dbae --- /dev/null +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser_spec.ts @@ -0,0 +1,466 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵcomputeMsgId, ɵmakeParsedTranslation} from '@angular/localize'; +import {Xliff1TranslationParser} from '../../../../../src/translate/translation_files/translation_parsers/xliff1/xliff1_translation_parser'; + +describe('Xliff1TranslationParser', () => { + describe('canParse()', () => { + it('should return true if the file extension is `.xlf` and it contains the XLIFF namespace', + () => { + const parser = new Xliff1TranslationParser(); + expect(parser.canParse( + '/some/file.xlf', + '')) + .toBe(true); + expect(parser.canParse( + '/some/file.json', + '')) + .toBe(false); + expect(parser.canParse('/some/file.xlf', '')).toBe(false); + expect(parser.canParse('/some/file.json', '')).toBe(false); + }); + }); + + describe('parse()', () => { + it('should extract the locale from the file contents', () => { + const XLIFF = ` + + + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + expect(result.locale).toEqual('fr'); + }); + + it('should extract basic messages', () => { + /** + * Source HTML: + * + * ``` + *
translatable attribute
+ * ``` + */ + const XLIFF = ` + + + + + translatable attribute + etubirtta elbatalsnart + + file.ts + 1 + + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('translatable attribute')]) + .toEqual(ɵmakeParsedTranslation(['etubirtta elbatalsnart'])); + }); + + it('should extract translations with simple placeholders', () => { + /** + * Source HTML: + * + * ``` + *
translatable element >with placeholders {{ interpolation}}
+ * ``` + */ + const XLIFF = ` + + + + + translatable element with placeholders + tnemele elbatalsnart sredlohecalp htiw + + file.ts + 2 + + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect( + result.translations[ɵcomputeMsgId( + 'translatable element {$START_BOLD_TEXT}with placeholders{$LOSE_BOLD_TEXT} {$INTERPOLATION}')]) + .toEqual(ɵmakeParsedTranslation( + ['', ' tnemele elbatalsnart ', 'sredlohecalp htiw', ''], + ['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'])); + }); + + it('should extract translations with simple ICU expressions', () => { + /** + * Source HTML: + * + * ``` + *
{VAR_PLURAL, plural, =0 {

test

} }
+ * ``` + */ + const XLIFF = ` + + + + + {VAR_PLURAL, plural, =0 {test} } + {VAR_PLURAL, plural, =0 {TEST} } + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId( + '{VAR_PLURAL, plural, =0 {{START_PARAGRAPH}test{CLOSE_PARAGRAPH}}}')]) + .toEqual(ɵmakeParsedTranslation( + ['{VAR_PLURAL, plural, =0 {{START_PARAGRAPH}TEST{CLOSE_PARAGRAPH}}}'], [])); + }); + + it('should extract translations with duplicate source messages', () => { + /** + * Source HTML: + * + * ``` + *
foo
+ *
foo
+ *
foo
+ * ``` + */ + const XLIFF = ` + + + + + foo + oof + + file.ts + 3 + + d + m + + + foo + toto + + file.ts + 4 + + d + m + + + foo + tata + + file.ts + 5 + + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('foo')]).toEqual(ɵmakeParsedTranslation(['oof'])); + expect(result.translations['i']).toEqual(ɵmakeParsedTranslation(['toto'])); + expect(result.translations['bar']).toEqual(ɵmakeParsedTranslation(['tata'])); + }); + + it('should extract translations with only placeholders, which are re-ordered', () => { + /** + * Source HTML: + * + * ``` + *

+ * ``` + */ + const XLIFF = ` + + + + + + + + + file.ts + 6 + + ph names + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('{$LINE_BREAK}{$TAG_IMG}{$TAG_IMG_1}')]) + .toEqual( + ɵmakeParsedTranslation(['', '', '', ''], ['TAG_IMG_1', 'TAG_IMG', 'LINE_BREAK'])); + }); + + it('should extract translations with empty target', () => { + /** + * Source HTML: + * + * ``` + *
hello
+ * ``` + */ + const XLIFF = ` + + + + + hello + + + file.ts + 6 + + ph names + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('hello {$START_TAG_SPAN}{$CLOSE_TAG_SPAN}')]) + .toEqual(ɵmakeParsedTranslation([''])); + }); + + it('should extract translations with deeply nested ICUs', () => { + /** + * Source HTML: + * + * ``` + * Test: { count, plural, =0 { { sex, select, other {

deeply nested

}} } =other {a lot}} + * ``` + * + * Note that the message gets split into two translation units: + * * The first one contains the outer message with an `ICU` placeholder + * * The second one is the ICU expansion itself + * + * Note that special markers `VAR_PLURAL` and `VAR_SELECT` are added, which are then replaced + * by IVY at runtime with the actual values being rendered by the ICU expansion. + */ + const XLIFF = ` + + + + + Test: + Le test: + + file.ts + 11 + + + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested}}} =other {a lot}} + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {profondément imbriqué}}} =other {beaucoup}} + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('Test: {$ICU}')]) + .toEqual(ɵmakeParsedTranslation(['Le test: ', ''], ['ICU'])); + + expect( + result.translations[ɵcomputeMsgId( + '{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {{START_PARAGRAPH}deeply nested{CLOSE_PARAGRAPH}}}} =other {beaucoup}}')]) + .toEqual(ɵmakeParsedTranslation([ + '{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {{START_PARAGRAPH}profondément imbriqué{CLOSE_PARAGRAPH}}}} =other {beaucoup}}' + ])); + }); + + it('should extract translations containing multiple lines', () => { + /** + * Source HTML: + * + * ``` + *
multi + * lines
+ * ``` + */ + const XLIFF = ` + + + + + multi\nlines + multi\nlignes + + file.ts + 12 + + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('multi\nlines')]) + .toEqual(ɵmakeParsedTranslation(['multi\nlignes'])); + }); + + it('should extract translations with elements', () => { + const XLIFF = ` + + + + + First sentence. + + Should not be parsed + + Translated first sentence. + + + First sentence. Second sentence. + + Should not be parsed + + Translated first sentence. + + + + `; + const parser = new Xliff1TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations['mrk-test']) + .toEqual(ɵmakeParsedTranslation(['Translated first sentence.'])); + + expect(result.translations['mrk-test2']) + .toEqual(ɵmakeParsedTranslation(['Translated first sentence.'])); + }); + + describe('[structure errors]', () => { + it('should throw when a trans-unit has no translation', () => { + const XLIFF = ` + + + + + + + + +`; + + expect(() => { + const parser = new Xliff1TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Missing required element/); + }); + + + it('should throw when a trans-unit has no id attribute', () => { + const XLIFF = ` + + + + + + + + + +`; + + expect(() => { + const parser = new Xliff1TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Missing required "id" attribute/); + }); + + it('should throw on duplicate trans-unit id', () => { + const XLIFF = ` + + + + + + + + + + + + + +`; + + expect(() => { + const parser = new Xliff1TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Duplicated translations for message "deadbeef"/); + }); + }); + + describe('[message errors]', () => { + it('should throw on unknown message tags', () => { + const XLIFF = ` + + + + + + msg should contain only ph tags + + + +`; + + expect(() => { + const parser = new Xliff1TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Invalid element found in message/); + }); + + it('should throw when a placeholder misses an id attribute', () => { + const XLIFF = ` + + + + + + + + + +`; + + expect(() => { + const parser = new Xliff1TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/required "id" attribute/gi); + }); + }); + }); +}); diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser_spec.ts new file mode 100644 index 0000000000..d31f56c6d4 --- /dev/null +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser_spec.ts @@ -0,0 +1,467 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ɵcomputeMsgId, ɵmakeParsedTranslation} from '@angular/localize'; +import {Xliff2TranslationParser} from '../../../../../src/translate/translation_files/translation_parsers/xliff2/xliff2_translation_parser'; + +describe('Xliff2TranslationParser', () => { + describe('canParse()', () => { + it('should return true if the file extension is `.xlf` and it contains the XLIFF namespace', () => { + const parser = new Xliff2TranslationParser(); + expect( + parser.canParse( + '/some/file.xlf', + '')) + .toBe(true); + expect( + parser.canParse( + '/some/file.json', + '')) + .toBe(false); + expect(parser.canParse('/some/file.xlf', '')).toBe(false); + expect(parser.canParse('/some/file.json', '')).toBe(false); + }); + }); + + describe('parse()', () => { + it('should extract the locale from the file contents', () => { + const XLIFF = ` + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + expect(result.locale).toEqual('fr'); + }); + + it('should extract basic messages', () => { + /** + * Source HTML: + * + * ``` + *
translatable attribute
+ * ``` + */ + const XLIFF = ` + + + + + file.ts:2 + + + translatable attribute + etubirtta elbatalsnart + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('translatable attribute', '')]) + .toEqual(ɵmakeParsedTranslation(['etubirtta elbatalsnart'])); + }); + + it('should extract translations with simple placeholders', () => { + /** + * Source HTML: + * + * ``` + *
translatable element >with placeholders {{ interpolation}}
+ * ``` + */ + const XLIFF = ` + + + + + file.ts:3 + + + translatable element with placeholders + tnemele elbatalsnart sredlohecalp htiw + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect( + result.translations[ɵcomputeMsgId( + 'translatable element {$START_BOLD_TEXT}with placeholders{$LOSE_BOLD_TEXT} {$INTERPOLATION}')]) + .toEqual(ɵmakeParsedTranslation( + ['', ' tnemele elbatalsnart ', 'sredlohecalp htiw', ''], + ['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'])); + }); + + it('should extract translations with simple ICU expressions', () => { + /** + * Source HTML: + * + * ``` + *
{VAR_PLURAL, plural, =0 {

test

} }
+ * ``` + */ + const XLIFF = ` + + + + + file.ts:4 + + + {VAR_PLURAL, plural, =0 {test} } + {VAR_PLURAL, plural, =0 {TEST} } + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId( + '{VAR_PLURAL, plural, =0 {{START_PARAGRAPH}test{CLOSE_PARAGRAPH}}}')]) + .toEqual(ɵmakeParsedTranslation( + ['{VAR_PLURAL, plural, =0 {{START_PARAGRAPH}TEST{CLOSE_PARAGRAPH}}}'], [])); + }); + + it('should extract translations with duplicate source messages', () => { + /** + * Source HTML: + * + * ``` + *
foo
+ *
foo
+ *
foo
+ * ``` + */ + const XLIFF = ` + + + + + d + m + file.ts:5 + + + foo + oof + + + + + d + m + file.ts:5 + + + foo + toto + + + + + d + m + file.ts:5 + + + foo + tata + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('foo')]).toEqual(ɵmakeParsedTranslation(['oof'])); + expect(result.translations['i']).toEqual(ɵmakeParsedTranslation(['toto'])); + expect(result.translations['bar']).toEqual(ɵmakeParsedTranslation(['tata'])); + }); + + it('should extract translations with only placeholders, which are re-ordered', () => { + /** + * Source HTML: + * + * ``` + *

+ * ``` + */ + const XLIFF = ` + + + + + ph names + file.ts:7 + + + + + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('{$LINE_BREAK}{$TAG_IMG}{$TAG_IMG_1}')]) + .toEqual( + ɵmakeParsedTranslation(['', '', '', ''], ['TAG_IMG_1', 'TAG_IMG', 'LINE_BREAK'])); + }); + + it('should extract translations with empty target', () => { + /** + * Source HTML: + * + * ``` + *
hello
+ * ``` + */ + const XLIFF = ` + + + + + empty element + file.ts:8 + + + hello + + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('hello {$START_TAG_SPAN}{$CLOSE_TAG_SPAN}')]) + .toEqual(ɵmakeParsedTranslation([''])); + }); + + it('should extract translations with deeply nested ICUs', () => { + /** + * Source HTML: + * + * ``` + * Test: { count, plural, =0 { { sex, select, other {

deeply nested

}} } =other {a lot}} + * ``` + * + * Note that the message gets split into two translation units: + * * The first one contains the outer message with an `ICU` placeholder + * * The second one is the ICU expansion itself + * + * Note that special markers `VAR_PLURAL` and `VAR_SELECT` are added, which are then replaced + * by IVY at runtime with the actual values being rendered by the ICU expansion. + */ + const XLIFF = ` + + + + + file.ts:10 + + + Test: + Le test: + + + + + file.ts:10 + + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested}}} =other {a lot}} + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {profondément imbriqué}}} =other {beaucoup}} + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('Test: {$ICU}')]) + .toEqual(ɵmakeParsedTranslation(['Le test: ', ''], ['ICU'])); + + expect( + result.translations[ɵcomputeMsgId( + '{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {{START_PARAGRAPH}deeply nested{CLOSE_PARAGRAPH}}}} =other {beaucoup}}')]) + .toEqual(ɵmakeParsedTranslation([ + '{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {{START_PARAGRAPH}profondément imbriqué{CLOSE_PARAGRAPH}}}} =other {beaucoup}}' + ])); + }); + + it('should extract translations containing multiple lines', () => { + /** + * Source HTML: + * + * ``` + *
multi + * lines
+ * ``` + */ + const XLIFF = ` + + + + + file.ts:11,12 + + + multi\nlines + multi\nlignes + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations[ɵcomputeMsgId('multi\nlines')]) + .toEqual(ɵmakeParsedTranslation(['multi\nlignes'])); + }); + + it('should extract translations with elements', () => { + const XLIFF = ` + + + + + First sentence. + Translated first sentence. + + + + + First sentence. Second sentence. + Translated first sentence. + + + + `; + const parser = new Xliff2TranslationParser(); + const result = parser.parse('/some/file.xlf', XLIFF); + + expect(result.translations['mrk-test']) + .toEqual(ɵmakeParsedTranslation(['Translated first sentence.'])); + + expect(result.translations['mrk-test2']) + .toEqual(ɵmakeParsedTranslation(['Translated first sentence.'])); + }); + + describe('[structure errors]', () => { + it('should throw when a trans-unit has no translation', () => { + const XLIFF = ` + + + + + + + + + `; + + expect(() => { + const parser = new Xliff2TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Missing required element/); + }); + + + it('should throw when a trans-unit has no id attribute', () => { + const XLIFF = ` + + + + + + + + + + `; + + expect(() => { + const parser = new Xliff2TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Missing required "id" attribute/); + }); + + it('should throw on duplicate trans-unit id', () => { + const XLIFF = ` + + + + + + + + + + + + + + + + `; + + expect(() => { + const parser = new Xliff2TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Duplicated translations for message "deadbeef"/); + }); + }); + + describe('[message errors]', () => { + it('should throw on unknown message tags', () => { + const XLIFF = ` + + + + + + msg should contain only ph and pc tags + + + + `; + + expect(() => { + const parser = new Xliff2TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Invalid element found in message/); + }); + + it('should throw when a placeholder misses an id attribute', () => { + const XLIFF = ` + + + + + + + + + + `; + + expect(() => { + const parser = new Xliff2TranslationParser(); + parser.parse('/some/file.xlf', XLIFF); + }).toThrowError(/Missing required "equiv" attribute/); + }); + }); + }); +}); diff --git a/packages/localize/src/tools/test/translate/translator_spec.ts b/packages/localize/src/tools/test/translate/translator_spec.ts new file mode 100644 index 0000000000..8fec6f79c6 --- /dev/null +++ b/packages/localize/src/tools/test/translate/translator_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Diagnostics as Diagnostics} from '../../src/diagnostics'; +import {FileUtils} from '../../src/file_utils'; +import {TranslationHandler, Translator} from '../../src/translate/translator'; + +describe('Translator', () => { + describe('translateFiles()', () => { + + beforeEach(() => { + spyOn(FileUtils, 'readFileBuffer') + .and.returnValues(Buffer.from('resource file 1'), Buffer.from('resource file 2')); + }); + + it('should call FileUtils.readFileBuffer to load the resource file contents', () => { + const translator = new Translator([new MockTranslationHandler()], new Diagnostics()); + translator.translateFiles( + ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); + expect(FileUtils.readFileBuffer).toHaveBeenCalledWith('/dist/file1.js'); + expect(FileUtils.readFileBuffer).toHaveBeenCalledWith('/dist/images/img.gif'); + }); + + it('should call `canTranslate()` and `translate()` for each file', () => { + const diagnostics = new Diagnostics(); + const handler = new MockTranslationHandler(true); + const translator = new Translator([handler], diagnostics); + translator.translateFiles( + ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); + + expect(handler.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + 'translate(/dist, file1.js, resource file 1)', + 'canTranslate(images/img.gif, resource file 2)', + 'translate(/dist, images/img.gif, resource file 2)', + ]); + }); + + it('should stop at the first handler that can handle each file', () => { + const diagnostics = new Diagnostics(); + const handler1 = new MockTranslationHandler(false); + const handler2 = new MockTranslationHandler(true); + const handler3 = new MockTranslationHandler(true); + const translator = new Translator([handler1, handler2, handler3], diagnostics); + translator.translateFiles( + ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); + + expect(handler1.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + 'canTranslate(images/img.gif, resource file 2)', + ]); + expect(handler2.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + 'translate(/dist, file1.js, resource file 1)', + 'canTranslate(images/img.gif, resource file 2)', + 'translate(/dist, images/img.gif, resource file 2)', + ]); + }); + + it('should error if none of the handlers can handle the file', () => { + const diagnostics = new Diagnostics(); + const handler = new MockTranslationHandler(false); + const translator = new Translator([handler], diagnostics); + + translator.translateFiles( + ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); + + expect(diagnostics.messages).toEqual([ + {type: 'error', message: 'Unable to handle resource file: /dist/file1.js'}, + {type: 'error', message: 'Unable to handle resource file: /dist/images/img.gif'}, + ]); + }); + }); +}); + +class MockTranslationHandler implements TranslationHandler { + log: string[] = []; + constructor(private _canTranslate: boolean = true) {} + + canTranslate(relativePath: string, contents: Buffer) { + this.log.push(`canTranslate(${relativePath}, ${contents.toString('utf8')})`); + return this._canTranslate; + } + + translate(_diagnostics: Diagnostics, rootPath: string, relativePath: string, contents: Buffer) { + this.log.push(`translate(${rootPath}, ${relativePath}, ${contents})`); + } +} + +function mockOutputPathFn(locale: string, relativePath: string) { + return `translations/${locale}/${relativePath}`; +} diff --git a/packages/localize/src/tools/tsconfig-build.json b/packages/localize/src/tools/tsconfig-build.json new file mode 100644 index 0000000000..d72ef48436 --- /dev/null +++ b/packages/localize/src/tools/tsconfig-build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig-build.json", + + "compilerOptions": { + "module": "commonjs", + "stripInternal": false, + "target": "es2015", + "lib": [ + "es2015", + "es2017.object" + ], + "paths": { + "@angular/*": ["./packages/*"] + }, + "strict": true, + "types": [ + "node" + ] + }, + "bazelOptions": { + "suppressTsconfigOverrideWarnings": true + } +} \ No newline at end of file diff --git a/packages/localize/src/tools/types/babel/LICENSE b/packages/localize/src/tools/types/babel/LICENSE new file mode 100644 index 0000000000..d1ca00f20a --- /dev/null +++ b/packages/localize/src/tools/types/babel/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE \ No newline at end of file diff --git a/packages/localize/src/tools/types/babel/README.md b/packages/localize/src/tools/types/babel/README.md new file mode 100644 index 0000000000..d3e038d694 --- /dev/null +++ b/packages/localize/src/tools/types/babel/README.md @@ -0,0 +1,11 @@ +# @babel/... external types + +The Bazel `ts_library` rule does not understand how to map imports of the form `@babel/core` to +external typings of the form `@types/babel__core`. Note the double underscore to account for the +namespaced package. + +See https://github.com/bazelbuild/rules_nodejs/issues/1033. + +This folder is a workaround to this by copying the typings directly into the project. Once the +issue with `ts_library` is resolved we can remove this folder and add appropriate npm dependencies +for the typings \ No newline at end of file diff --git a/packages/localize/src/tools/types/babel/core.d.ts b/packages/localize/src/tools/types/babel/core.d.ts new file mode 100644 index 0000000000..6fa5aff387 --- /dev/null +++ b/packages/localize/src/tools/types/babel/core.d.ts @@ -0,0 +1,734 @@ +declare module '@babel/core' { + // Type definitions for @babel/core 7.1 + // Project: https://github.com/babel/babel/tree/master/packages/babel-core, https://babeljs.io + // Definitions by: Troy Gerwien + // Marvin Hagemeister + // Melvin Groenhoff + // Jessica Franco + // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + // TypeScript Version: 2.9 + + import {GeneratorOptions} from '@babel/generator'; + import traverse, {Visitor, NodePath} from '@babel/traverse'; + import template from '@babel/template'; + import * as t from '@babel/types'; + import {ParserOptions} from '@babel/parser'; + + export {ParserOptions, GeneratorOptions, t as types, template, traverse, NodePath, Visitor}; + + export type Node = t.Node; + export type ParseResult = t.File | t.Program; + export const version: string; + export const DEFAULT_EXTENSIONS: ['.js', '.jsx', '.es6', '.es', '.mjs']; + + export interface TransformOptions { + /** + * Include the AST in the returned object + * + * Default: `false` + */ + ast?: boolean|null; + + /** + * Attach a comment after all non-user injected code + * + * Default: `null` + */ + auxiliaryCommentAfter?: string|null; + + /** + * Attach a comment before all non-user injected code + * + * Default: `null` + */ + auxiliaryCommentBefore?: string|null; + + /** + * Specify the "root" folder that defines the location to search for "babel.config.js", and the + * default folder to allow `.babelrc` files inside of. + * + * Default: `"."` + */ + root?: string|null; + + /** + * This option, combined with the "root" value, defines how Babel chooses its project root. + * The different modes define different ways that Babel can process the "root" value to get + * the final project root. + * + * @see https://babeljs.io/docs/en/next/options#rootmode + */ + rootMode?: 'root'|'upward'|'upward-optional'; + + /** + * The config file to load Babel's config from. Defaults to searching for "babel.config.js" + * inside the "root" folder. `false` will disable searching for config files. + * + * Default: `undefined` + */ + configFile?: string|false|null; + + /** + * Specify whether or not to use .babelrc and + * .babelignore files. + * + * Default: `true` + */ + babelrc?: boolean|null; + + /** + * Specify which packages should be search for .babelrc files when they are being compiled. + * `true` to always search, or a path string or an array of paths to packages to search + * inside of. Defaults to only searching the "root" package. + * + * Default: `(root)` + */ + babelrcRoots?: true|string|string[]|null; + + /** + * Defaults to environment variable `BABEL_ENV` if set, or else `NODE_ENV` if set, or else it + * defaults to `"development"` + * + * Default: env vars + */ + envName?: string; + + /** + * Enable code generation + * + * Default: `true` + */ + code?: boolean|null; + + /** + * Output comments in generated output + * + * Default: `true` + */ + comments?: boolean|null; + + /** + * Do not include superfluous whitespace characters and line terminators. When set to `"auto"` + * compact is set to `true` on input sizes of >500KB + * + * Default: `"auto"` + */ + compact?: boolean|'auto'|null; + + /** + * The working directory that Babel's programmatic options are loaded relative to. + * + * Default: `"."` + */ + cwd?: string|null; + + /** + * Utilities may pass a caller object to identify themselves to Babel and + * pass capability-related flags for use by configs, presets and plugins. + * + * @see https://babeljs.io/docs/en/next/options#caller + */ + caller?: TransformCaller; + + /** + * This is an object of keys that represent different environments. For example, you may have: + * `{ env: { production: { \/* specific options *\/ } } }` + * which will use those options when the `envName` is `production` + * + * Default: `{}` + */ + env?: {[index: string]: TransformOptions | null | undefined;}|null; + + /** + * A path to a `.babelrc` file to extend + * + * Default: `null` + */ + extends?: string|null; + + /** + * Filename for use in errors etc + * + * Default: `"unknown"` + */ + filename?: string|null; + + /** + * Filename relative to `sourceRoot` + * + * Default: `(filename)` + */ + filenameRelative?: string|null; + + /** + * An object containing the options to be passed down to the babel code generator, + * @babel/generator + * + * Default: `{}` + */ + generatorOpts?: GeneratorOptions|null; + + /** + * Specify a custom callback to generate a module id with. Called as `getModuleId(moduleName)`. + * If falsy value is returned then the generated module id is used + * + * Default: `null` + */ + getModuleId?: ((moduleName: string) => string | null | undefined)|null; + + /** + * ANSI highlight syntax error code frames + * + * Default: `true` + */ + highlightCode?: boolean|null; + + /** + * Opposite to the `only` option. `ignore` is disregarded if `only` is specified + * + * Default: `null` + */ + ignore?: string[]|null; + + /** + * A source map object that the output source map will be based on + * + * Default: `null` + */ + inputSourceMap?: object|null; + + /** + * Should the output be minified (not printing last semicolons in blocks, printing literal + * string values instead of escaped ones, stripping `()` from `new` when safe) + * + * Default: `false` + */ + minified?: boolean|null; + + /** + * Specify a custom name for module ids + * + * Default: `null` + */ + moduleId?: string|null; + + /** + * If truthy, insert an explicit id for modules. By default, all modules are anonymous. (Not + * available for `common` modules) + * + * Default: `false` + */ + moduleIds?: boolean|null; + + /** + * Optional prefix for the AMD module formatter that will be prepend to the filename on module + * definitions + * + * Default: `(sourceRoot)` + */ + moduleRoot?: string|null; + + /** + * A glob, regex, or mixed array of both, matching paths to **only** compile. Can also be an + * array of arrays containing paths to explicitly match. When attempting to compile + * a non-matching file it's returned verbatim + * + * Default: `null` + */ + only?: string|RegExp|Array|null; + + /** + * An object containing the options to be passed down to the babel parser, @babel/parser + * + * Default: `{}` + */ + parserOpts?: ParserOptions|null; + + /** + * List of plugins to load and use + * + * Default: `[]` + */ + plugins?: PluginItem[]|null; + + /** + * List of presets (a set of plugins) to load and use + * + * Default: `[]` + */ + presets?: PluginItem[]|null; + + /** + * Retain line numbers. This will lead to wacky code but is handy for scenarios where you can't + * use source maps. (**NOTE**: This will not retain the columns) + * + * Default: `false` + */ + retainLines?: boolean|null; + + /** + * An optional callback that controls whether a comment should be output or not. Called as + * `shouldPrintComment(commentContents)`. **NOTE**: This overrides the `comment` option when + * used + * + * Default: `null` + */ + shouldPrintComment?: ((commentContents: string) => boolean)|null; + + /** + * Set `sources[0]` on returned source map + * + * Default: `(filenameRelative)` + */ + sourceFileName?: string|null; + + /** + * If truthy, adds a `map` property to returned output. If set to `"inline"`, a comment with a + * sourceMappingURL directive is added to the bottom of the returned code. If set to `"both"` + * then a `map` property is returned as well as a source map comment appended. **This does not + * emit sourcemap files by itself!** + * + * Default: `false` + */ + sourceMaps?: boolean|'inline'|'both'|null; + + /** + * The root from which all sources are relative + * + * Default: `(moduleRoot)` + */ + sourceRoot?: string|null; + + /** + * Indicate the mode the code should be parsed in. Can be one of "script", "module", or + * "unambiguous". `"unambiguous"` will make Babel attempt to guess, based on the presence of ES6 + * `import` or `export` statements. Files with ES6 `import`s and `export`s are considered + * `"module"` and are otherwise `"script"`. + * + * Default: `("module")` + */ + sourceType?: 'script'|'module'|'unambiguous'|null; + + /** + * An optional callback that can be used to wrap visitor methods. **NOTE**: This is useful for + * things like introspection, and not really needed for implementing anything. Called as + * `wrapPluginVisitorMethod(pluginAlias, visitorType, callback)`. + */ + wrapPluginVisitorMethod?: + ((pluginAlias: string, visitorType: 'enter'|'exit', + callback: + (path: NodePath, state: any) => void) => (path: NodePath, state: any) => void)|null; + } + + export interface TransformCaller { + // the only required property + name: string; + // e.g. set to true by `babel-loader` and false by `babel-jest` + supportsStaticESM?: boolean; + // augment this with a "declare module '@babel/core' { ... }" if you need more keys + } + + export type FileResultCallback = (err: Error | null, result: BabelFileResult | null) => any; + + /** + * Transforms the passed in code. Calling a callback with an object with the generated code, + * source map, and AST. + */ + export function transform(code: string, callback: FileResultCallback): void; + + /** + * Transforms the passed in code. Calling a callback with an object with the generated code, + * source map, and AST. + */ + export function transform( + code: string, opts: TransformOptions | undefined, callback: FileResultCallback): void; + + /** + * Here for backward-compatibility. Ideally use `transformSync` if you want a synchronous API. + */ + export function transform(code: string, opts?: TransformOptions): BabelFileResult|null; + + /** + * Transforms the passed in code. Returning an object with the generated code, source map, and + * AST. + */ + export function transformSync(code: string, opts?: TransformOptions): BabelFileResult|null; + + /** + * Transforms the passed in code. Calling a callback with an object with the generated code, + * source map, and AST. + */ + export function transformAsync( + code: string, opts?: TransformOptions): Promise; + + /** + * Asynchronously transforms the entire contents of a file. + */ + export function transformFile(filename: string, callback: FileResultCallback): void; + + /** + * Asynchronously transforms the entire contents of a file. + */ + export function transformFile( + filename: string, opts: TransformOptions | undefined, callback: FileResultCallback): void; + + /** + * Synchronous version of `babel.transformFile`. Returns the transformed contents of the + * `filename`. + */ + export function transformFileSync(filename: string, opts?: TransformOptions): BabelFileResult| + null; + + /** + * Asynchronously transforms the entire contents of a file. + */ + export function transformFileAsync( + filename: string, opts?: TransformOptions): Promise; + + /** + * Given an AST, transform it. + */ + export function transformFromAst( + ast: Node, code: string | undefined, callback: FileResultCallback): void; + + /** + * Given an AST, transform it. + */ + export function transformFromAst( + ast: Node, code: string | undefined, opts: TransformOptions | undefined, + callback: FileResultCallback): void; + + /** + * Here for backward-compatibility. Ideally use ".transformSync" if you want a synchronous API. + */ + export function transformFromAstSync( + ast: Node, code?: string, opts?: TransformOptions): BabelFileResult|null; + + /** + * Given an AST, transform it. + */ + export function transformFromAstAsync( + ast: Node, code?: string, opts?: TransformOptions): Promise; + + // A babel plugin is a simple function which must return an object matching + // the following interface. Babel will throw if it finds unknown properties. + // The list of allowed plugin keys is here: + // https://github.com/babel/babel/blob/4e50b2d9d9c376cee7a2cbf56553fe5b982ea53c/packages/babel-core/src/config/option-manager.js#L71 + export interface PluginObj { + name?: string; + manipulateOptions?(opts: any, parserOpts: any): void; + pre?(this: S, state: any): void; + visitor: Visitor; + post?(this: S, state: any): void; + inherits?: any; + } + + export interface BabelFileResult { + ast?: t.File|null; + code?: string|null; + ignored?: boolean; + map?: { + version: number; sources: string[]; names: string[]; sourceRoot?: string; + sourcesContent?: string[]; + mappings: string; + file: string; + }|null; + metadata?: BabelFileMetadata; + } + + export interface BabelFileMetadata { + usedHelpers: string[]; + marked: Array < { + type: string; + message: string; + loc: object; + } + > ; + modules: BabelFileModulesMetadata; + } + + export interface BabelFileModulesMetadata { + imports: object[]; + exports: {exported: object[]; specifiers: object[];}; + } + + export type FileParseCallback = (err: Error | null, result: ParseResult | null) => any; + + /** + * Given some code, parse it using Babel's standard behavior. + * Referenced presets and plugins will be loaded such that optional syntax plugins are + * automatically enabled. + */ + export function parse(code: string, callback: FileParseCallback): void; + + /** + * Given some code, parse it using Babel's standard behavior. + * Referenced presets and plugins will be loaded such that optional syntax plugins are + * automatically enabled. + */ + export function parse( + code: string, options: TransformOptions | undefined, callback: FileParseCallback): void; + + /** + * Given some code, parse it using Babel's standard behavior. + * Referenced presets and plugins will be loaded such that optional syntax plugins are + * automatically enabled. + */ + export function parse(code: string, options?: TransformOptions): ParseResult|null; + + /** + * Given some code, parse it using Babel's standard behavior. + * Referenced presets and plugins will be loaded such that optional syntax plugins are + * automatically enabled. + */ + export function parseSync(code: string, options?: TransformOptions): ParseResult|null; + + /** + * Given some code, parse it using Babel's standard behavior. + * Referenced presets and plugins will be loaded such that optional syntax plugins are + * automatically enabled. + */ + export function parseAsync(code: string, options?: TransformOptions): Promise; + + /** + * Resolve Babel's options fully, resulting in an options object where: + * + * * opts.plugins is a full list of Plugin instances. + * * opts.presets is empty and all presets are flattened into opts. + * * It can be safely passed back to Babel. Fields like babelrc have been set to false so that + * later calls to Babel + * will not make a second attempt to load config files. + * + * Plugin instances aren't meant to be manipulated directly, but often callers will serialize this + * opts to JSON to + * use it as a cache key representing the options Babel has received. Caching on this isn't 100% + * guaranteed to + * invalidate properly, but it is the best we have at the moment. + */ + export function loadOptions(options?: TransformOptions): object|null; + + /** + * To allow systems to easily manipulate and validate a user's config, this function resolves the + * plugins and + * presets and proceeds no further. The expectation is that callers will take the config's + * .options, manipulate it + * as then see fit and pass it back to Babel again. + * + * * `babelrc: string | void` - The path of the `.babelrc` file, if there was one. + * * `babelignore: string | void` - The path of the `.babelignore` file, if there was one. + * * `options: ValidatedOptions` - The partially resolved options, which can be manipulated and + * passed back + * to Babel again. + * * `plugins: Array` - See below. + * * `presets: Array` - See below. + * * It can be safely passed back to Babel. Fields like `babelrc` have been set to false so that + * later calls to + * Babel will not make a second attempt to load config files. + * + * `ConfigItem` instances expose properties to introspect the values, but each item should be + * treated as + * immutable. If changes are desired, the item should be removed from the list and replaced with + * either a normal + * Babel config value, or with a replacement item created by `babel.createConfigItem`. See that + * function for + * information about `ConfigItem` fields. + */ + export function loadPartialConfig(options?: TransformOptions): Readonly|null; + + export interface PartialConfig { + options: TransformOptions; + babelrc?: string; + babelignore?: string; + config?: string; + } + + export interface ConfigItem { + /** + * The name that the user gave the plugin instance, e.g. `plugins: [ ['env', {}, 'my-env'] ]` + */ + name?: string; + + /** + * The resolved value of the plugin. + */ + value: object|((...args: any[]) => any); + + /** + * The options object passed to the plugin. + */ + options?: object|false; + + /** + * The path that the options are relative to. + */ + dirname: string; + + /** + * Information about the plugin's file, if Babel knows it. + * * + */ + file?: { + /** + * The file that the user requested, e.g. `"@babel/env"` + */ + request: string; + + /** + * The full path of the resolved file, e.g. + * `"/tmp/node_modules/@babel/preset-env/lib/index.js"` + */ + resolved: string; + }|null; + } + + export type PluginOptions = object | undefined | false; + + export type PluginTarget = string | object | ((...args: any[]) => any); + + export type PluginItem = ConfigItem | PluginObj| PluginTarget | + [PluginTarget, PluginOptions] | [PluginTarget, PluginOptions, string | undefined]; + + export interface CreateConfigItemOptions { + dirname?: string; + type?: 'preset'|'plugin'; + } + + /** + * Allows build tooling to create and cache config items up front. If this function is called + * multiple times for a + * given plugin, Babel will call the plugin's function itself multiple times. If you have a clear + * set of expected + * plugins and presets to inject, pre-constructing the config items would be recommended. + */ + export function createConfigItem( + value: PluginTarget | [PluginTarget, PluginOptions] | + [PluginTarget, PluginOptions, string | undefined], + options?: CreateConfigItemOptions): ConfigItem; + + // NOTE: the documentation says the ConfigAPI also exposes @babel/core's exports, but it actually + // doesn't + /** + * @see https://babeljs.io/docs/en/next/config-files#config-function-api + */ + export interface ConfigAPI { + /** + * The version string for the Babel version that is loading the config file. + * + * @see https://babeljs.io/docs/en/next/config-files#apiversion + */ + version: string; + /** + * @see https://babeljs.io/docs/en/next/config-files#apicache + */ + cache: SimpleCacheConfigurator; + /** + * @see https://babeljs.io/docs/en/next/config-files#apienv + */ + env: EnvFunction; + // undocumented; currently hardcoded to return 'false' + // async(): boolean + /** + * This API is used as a way to access the `caller` data that has been passed to Babel. + * Since many instances of Babel may be running in the same process with different `caller` values, + * this API is designed to automatically configure `api.cache`, the same way `api.env()` does. + * + * The `caller` value is available as the first parameter of the callback function. + * It is best used with something like this to toggle configuration behavior + * based on a specific environment: + * + * @example + * function isBabelRegister(caller?: { name: string }) { + * return !!(caller && caller.name === "@babel/register") + * } + * api.caller(isBabelRegister) + * + * @see https://babeljs.io/docs/en/next/config-files#apicallercb + */ + caller(callerCallback: (caller: TransformOptions['caller']) => T): T; + /** + * While `api.version` can be useful in general, it's sometimes nice to just declare your version. + * This API exposes a simple way to do that with: + * + * @example + * api.assertVersion(7) // major version only + * api.assertVersion("^7.2") + * + * @see https://babeljs.io/docs/en/next/config-files#apiassertversionrange + */ + assertVersion(versionRange: number|string): boolean; + // NOTE: this is an undocumented reexport from "@babel/parser" but it's missing from its types + // tokTypes: typeof tokTypes + } + + /** + * JS configs are great because they can compute a config on the fly, + * but the downside there is that it makes caching harder. + * Babel wants to avoid re-executing the config function every time a file is compiled, + * because then it would also need to re-execute any plugin and preset functions + * referenced in that config. + * + * To avoid this, Babel expects users of config functions to tell it how to manage caching + * within a config file. + * + * @see https://babeljs.io/docs/en/next/config-files#apicache + */ + export interface SimpleCacheConfigurator { + // there is an undocumented call signature that is a shorthand for forever()/never()/using(). + // (ever: boolean): void + // (callback: CacheCallback): T + /** + * Permacache the computed config and never call the function again. + */ + forever(): void; + /** + * Do not cache this config, and re-execute the function every time. + */ + never(): void; + /** + * Any time the using callback returns a value other than the one that was expected, + * the overall config function will be called again and a new entry will be added to the cache. + * + * @example + * api.cache.using(() => process.env.NODE_ENV) + */ + using(callback: SimpleCacheCallback): T; + /** + * Any time the using callback returns a value other than the one that was expected, + * the overall config function will be called again and all entries in the cache will + * be replaced with the result. + * + * @example + * api.cache.invalidate(() => process.env.NODE_ENV) + */ + invalidate(callback: SimpleCacheCallback): T; + } + + // https://github.com/babel/babel/blob/v7.3.3/packages/babel-core/src/config/caching.js#L231 + export type SimpleCacheKey = string | boolean | number | null | undefined; + export type SimpleCacheCallback = () => T; + + /** + * Since `NODE_ENV` is a fairly common way to toggle behavior, Babel also includes an API function + * meant specifically for that. This API is used as a quick way to check the `"envName"` that Babel + * was loaded with, which takes `NODE_ENV` into account if no other overriding environment is set. + * + * @see https://babeljs.io/docs/en/next/config-files#apienv + */ + export interface EnvFunction { + /** + * @returns the current `envName` string + */ + (): string; + /** + * @returns `true` if the `envName` is `===` any of the given strings + */ + (envName: string|ReadonlyArray): boolean; + // the official documentation is misleading for this one... + // this just passes the callback to `cache.using` but with an additional argument. + // it returns its result instead of necessarily returning a boolean. + ( + envCallback: (envName: NonNullable) => T): T; + } + + export type ConfigFunction = (api: ConfigAPI) => TransformOptions; +} diff --git a/packages/localize/src/tools/types/babel/generator.d.ts b/packages/localize/src/tools/types/babel/generator.d.ts new file mode 100644 index 0000000000..9b78f812b6 --- /dev/null +++ b/packages/localize/src/tools/types/babel/generator.d.ts @@ -0,0 +1,123 @@ +declare module '@babel/generator' { + // Type definitions for @babel/generator 7.0 + // Project: https://github.com/babel/babel/tree/master/packages/babel-generator, + // https://babeljs.io + // Definitions by: Troy Gerwien + // Johnny Estilles + // Melvin Groenhoff + // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + // TypeScript Version: 2.9 + + import * as t from '@babel/types'; + + export interface GeneratorOptions { + /** + * Optional string to add as a block comment at the start of the output file. + */ + auxiliaryCommentBefore?: string; + + /** + * Optional string to add as a block comment at the end of the output file. + */ + auxiliaryCommentAfter?: string; + + /** + * Function that takes a comment (as a string) and returns true if the comment should be + * included in the output. + * By default, comments are included if `opts.comments` is `true` or if `opts.minifed` is + * `false` and the comment + * contains `@preserve` or `@license`. + */ + shouldPrintComment?(comment: string): boolean; + + /** + * Attempt to use the same line numbers in the output code as in the source code (helps preserve + * stack traces). + * Defaults to `false`. + */ + retainLines?: boolean; + + /** + * Should comments be included in output? Defaults to `true`. + */ + comments?: boolean; + + /** + * Set to true to avoid adding whitespace for formatting. Defaults to the value of + * `opts.minified`. + */ + compact?: boolean|'auto'; + + /** + * Should the output be minified. Defaults to `false`. + */ + minified?: boolean; + + /** + * Set to true to reduce whitespace (but not as much as opts.compact). Defaults to `false`. + */ + concise?: boolean; + + /** + * The type of quote to use in the output. If omitted, autodetects based on `ast.tokens`. + */ + quotes?: 'single'|'double'; + + /** + * Used in warning messages + */ + filename?: string; + + /** + * Enable generating source maps. Defaults to `false`. + */ + sourceMaps?: boolean; + + /** + * The filename of the generated code that the source map will be associated with. + */ + sourceMapTarget?: string; + + /** + * A root for all relative URLs in the source map. + */ + sourceRoot?: string; + + /** + * The filename for the source code (i.e. the code in the `code` argument). + * This will only be used if `code` is a string. + */ + sourceFileName?: string; + + /** + * Set to true to run jsesc with "json": true to print "\u00A9" vs. "©"; + */ + jsonCompatibleStrings?: boolean; + } + + export class CodeGenerator { + constructor(ast: t.Node, opts?: GeneratorOptions, code?: string); + generate(): GeneratorResult; + } + + /** + * Turns an AST into code, maintaining sourcemaps, user preferences, and valid output. + * @param ast - the abstract syntax tree from which to generate output code. + * @param opts - used for specifying options for code generation. + * @param code - the original source code, used for source maps. + * @returns - an object containing the output code and source map. + */ + export default function generate(ast: t.Node, opts?: GeneratorOptions, code?: string | { + [filename: string]: string; + }): GeneratorResult; + + export interface GeneratorResult { + code: string; + map: { + version: number; sources: string[]; names: string[]; sourceRoot?: string; + sourcesContent?: string[]; + mappings: string; + file: string; + }|null; + } +} \ No newline at end of file diff --git a/packages/localize/src/tools/types/babel/template.d.ts b/packages/localize/src/tools/types/babel/template.d.ts new file mode 100644 index 0000000000..2674764908 --- /dev/null +++ b/packages/localize/src/tools/types/babel/template.d.ts @@ -0,0 +1,78 @@ +declare module '@babel/template' { + // Type definitions for @babel/template 7.0 + // Project: https://github.com/babel/babel/tree/master/packages/babel-template, https://babeljs.io + // Definitions by: Troy Gerwien + // Marvin Hagemeister + // Melvin Groenhoff + // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + // TypeScript Version: 2.9 + + import {ParserOptions} from '@babel/parser'; + import {Expression, File, Program, Statement} from '@babel/types'; + + export interface TemplateBuilderOptions extends ParserOptions { + /** + * A set of placeholder names to automatically accept. Items in this list do not need to match + * the given placeholder pattern. + */ + placeholderWhitelist?: Set; + + /** + * A pattern to search for when looking for Identifier and StringLiteral nodes that should be + * considered placeholders. `false` will + * disable placeholder searching entirely, leaving only the `placeholderWhitelist` value to find + * placeholders. + */ + placeholderPattern?: RegExp|false; + + /** + * Set this to `true` to preserve any comments from the `code` parameter. + */ + preserveComments?: boolean; + } + + export interface TemplateBuilder { + /** + * Build a new builder, merging the given options with the previous ones. + */ + (opts: TemplateBuilderOptions): TemplateBuilder; + + /** + * Building from a string produces an AST builder function by default. + */ + (code: string, opts?: TemplateBuilderOptions): (arg?: PublicReplacements) => T; + + /** + * Building from a template literal produces an AST builder function by default. + */ + (tpl: TemplateStringsArray, ...args: any[]): (arg?: PublicReplacements) => T; + + // Allow users to explicitly create templates that produce ASTs, skipping the need for an + // intermediate function. + ast: { + (tpl: string, opts?: TemplateBuilderOptions): T; + (tpl: TemplateStringsArray, ...args: any[]): T; + }; + } + + export type PublicReplacements = {[index: string]: any;} | any[]; + + export const smart: TemplateBuilder; + export const statement: TemplateBuilder; + export const statements: TemplateBuilder; + export const expression: TemplateBuilder; + export const program: TemplateBuilder; + + type DefaultTemplateBuilder = typeof smart & { + smart: typeof smart; + statement: typeof statement; + statements: typeof statements; + expression: typeof expression; + program: typeof program; + ast: typeof smart.ast; + }; + + const templateBuilder: DefaultTemplateBuilder; + + export default templateBuilder; +} \ No newline at end of file diff --git a/packages/localize/src/tools/types/babel/traverse.d.ts b/packages/localize/src/tools/types/babel/traverse.d.ts new file mode 100644 index 0000000000..8813183980 --- /dev/null +++ b/packages/localize/src/tools/types/babel/traverse.d.ts @@ -0,0 +1,827 @@ +declare module '@babel/traverse' { + // Type definitions for @babel/traverse 7.0 + // Project: https://github.com/babel/babel/tree/master/packages/babel-traverse, https://babeljs.io + // Definitions by: Troy Gerwien + // Marvin Hagemeister + // Ryan Petrich + // Melvin Groenhoff + // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + // TypeScript Version: 2.9 + + import * as t from '@babel/types'; + + export type Node = t.Node; + + export default function traverse( + parent: Node | Node[], opts: TraverseOptions, scope: Scope | undefined, state: S, + parentPath?: NodePath, ): void; + export default function traverse( + parent: Node | Node[], opts: TraverseOptions, scope?: Scope, state?: any, + parentPath?: NodePath, ): void; + + export interface TraverseOptions extends Visitor { + scope?: Scope; + noScope?: boolean; + } + + export class Scope { + constructor(path: NodePath, parentScope?: Scope); + path: NodePath; + block: Node; + parentBlock: Node; + parent: Scope; + hub: Hub; + bindings: {[name: string]: Binding;}; + + /** Traverse node with current scope and path. */ + traverse(node: Node|Node[], opts: TraverseOptions, state: S): void; + traverse(node: Node|Node[], opts?: TraverseOptions, state?: any): void; + + /** Generate a unique identifier and add it to the current scope. */ + generateDeclaredUidIdentifier(name?: string): t.Identifier; + + /** Generate a unique identifier. */ + generateUidIdentifier(name?: string): t.Identifier; + + /** Generate a unique `_id1` binding. */ + generateUid(name?: string): string; + + /** Generate a unique identifier based on a node. */ + generateUidIdentifierBasedOnNode(parent: Node, defaultName?: string): t.Identifier; + + /** + * Determine whether evaluating the specific input `node` is a consequenceless reference. ie. + * evaluating it wont result in potentially arbitrary code from being ran. The following are + * whitelisted and determined not to cause side effects: + * + * - `this` expressions + * - `super` expressions + * - Bound identifiers + */ + isStatic(node: Node): boolean; + + /** Possibly generate a memoised identifier if it is not static and has consequences. */ + maybeGenerateMemoised(node: Node, dontPush?: boolean): t.Identifier; + + checkBlockScopedCollisions(local: Node, kind: string, name: string, id: object): void; + + rename(oldName: string, newName?: string, block?: Node): void; + + dump(): void; + + toArray(node: Node, i?: number): Node; + + registerDeclaration(path: NodePath): void; + + buildUndefinedNode(): Node; + + registerConstantViolation(path: NodePath): void; + + registerBinding(kind: string, path: NodePath, bindingPath?: NodePath): void; + + addGlobal(node: Node): void; + + hasUid(name: string): boolean; + + hasGlobal(name: string): boolean; + + hasReference(name: string): boolean; + + isPure(node: Node, constantsOnly?: boolean): boolean; + + setData(key: string, val: any): any; + + getData(key: string): any; + + removeData(key: string): void; + + push(opts: { + id: t.LVal, + init?: t.Expression, + unique?: boolean, + kind?: 'var'|'let'|'const', + }): void; + + getProgramParent(): Scope; + + getFunctionParent(): Scope|null; + + getBlockParent(): Scope; + + /** Walks the scope tree and gathers **all** bindings. */ + getAllBindings(...kinds: string[]): object; + + bindingIdentifierEquals(name: string, node: Node): boolean; + + getBinding(name: string): Binding|undefined; + + getOwnBinding(name: string): Binding|undefined; + + getBindingIdentifier(name: string): t.Identifier; + + getOwnBindingIdentifier(name: string): t.Identifier; + + hasOwnBinding(name: string): boolean; + + hasBinding(name: string, noGlobals?: boolean): boolean; + + parentHasBinding(name: string, noGlobals?: boolean): boolean; + + /** Move a binding of `name` to another `scope`. */ + moveBindingTo(name: string, scope: Scope): void; + + removeOwnBinding(name: string): void; + + removeBinding(name: string): void; + } + + export class Binding { + constructor(opts: { + existing: Binding; identifier: t.Identifier; scope: Scope; path: NodePath; + kind: 'var' | 'let' | 'const'; + }); + identifier: t.Identifier; + scope: Scope; + path: NodePath; + kind: 'var'|'let'|'const'|'module'; + referenced: boolean; + references: number; + referencePaths: NodePath[]; + constant: boolean; + constantViolations: NodePath[]; + } + + export type Visitor = VisitNodeObject& { + [Type in Node['type']]?: VisitNode < S, Extract < Node, { type: Type; } + >> ; + } + &{[K in keyof t.Aliases]?: VisitNode}; + + export type VisitNode = VisitNodeFunction| VisitNodeObject; + + export type VisitNodeFunction = (this: S, path: NodePath

, state: S) => void; + + export interface VisitNodeObject { + enter?: VisitNodeFunction; + exit?: VisitNodeFunction; + } + + export class NodePath { + constructor(hub: Hub, parent: Node); + parent: Node; + hub: Hub; + contexts: TraversalContext[]; + data: object; + shouldSkip: boolean; + shouldStop: boolean; + removed: boolean; + state: any; + opts: object; + skipKeys: object; + parentPath: NodePath; + context: TraversalContext; + container: object|object[]; + listKey: string; + inList: boolean; + parentKey: string; + key: string|number; + node: T; + scope: Scope; + type: T extends undefined|null? string|null: string; + typeAnnotation: object; + + getScope(scope: Scope): Scope; + + setData(key: string, val: any): any; + + getData(key: string, def?: any): any; + + buildCodeFrameError(msg: string, Error?: new (msg: string) => TError): + TError; + + traverse(visitor: Visitor, state: T): void; + traverse(visitor: Visitor): void; + + set(key: string, node: Node): void; + + getPathLocation(): string; + + // Example: + // https://github.com/babel/babel/blob/63204ae51e020d84a5b246312f5eeb4d981ab952/packages/babel-traverse/src/path/modification.js#L83 + debug(buildMessage: () => string): void; + + // ------------------------- ancestry ------------------------- + /** + * Call the provided `callback` with the `NodePath`s of all the parents. + * When the `callback` returns a truthy value, we return that node path. + */ + findParent(callback: (path: NodePath) => boolean): NodePath; + + find(callback: (path: NodePath) => boolean): NodePath; + + /** Get the parent function of the current path. */ + getFunctionParent(): NodePath; + + /** Walk up the tree until we hit a parent node path in a list. */ + getStatementParent(): NodePath; + + /** + * Get the deepest common ancestor and then from it, get the earliest relationship path + * to that ancestor. + * + * Earliest is defined as being "before" all the other nodes in terms of list container + * position and visiting key. + */ + getEarliestCommonAncestorFrom(paths: NodePath[]): NodePath[]; + + /** Get the earliest path in the tree where the provided `paths` intersect. */ + getDeepestCommonAncestorFrom( + paths: NodePath[], + filter?: (deepest: Node, i: number, ancestries: NodePath[]) => NodePath): NodePath; + + /** + * Build an array of node paths containing the entire ancestry of the current node path. + * + * NOTE: The current node path is included in this. + */ + getAncestry(): NodePath[]; + + inType(...candidateTypes: string[]): boolean; + + // ------------------------- inference ------------------------- + /** Infer the type of the current `NodePath`. */ + getTypeAnnotation(): t.FlowType; + + isBaseType(baseName: string, soft?: boolean): boolean; + + couldBeBaseType(name: string): boolean; + + baseTypeStrictlyMatches(right: NodePath): boolean; + + isGenericType(genericName: string): boolean; + + // ------------------------- replacement ------------------------- + /** + * Replace a node with an array of multiple. This method performs the following steps: + * + * - Inherit the comments of first provided node with that of the current node. + * - Insert the provided nodes after the current node. + * - Remove the current node. + */ + replaceWithMultiple(nodes: Node[]): void; + + /** + * Parse a string as an expression and replace the current node with the result. + * + * NOTE: This is typically not a good idea to use. Building source strings when + * transforming ASTs is an antipattern and SHOULD NOT be encouraged. Even if it's + * easier to use, your transforms will be extremely brittle. + */ + replaceWithSourceString(replacement: any): void; + + /** Replace the current node with another. */ + replaceWith(replacement: Node|NodePath): void; + + /** + * This method takes an array of statements nodes and then explodes it + * into expressions. This method retains completion records which is + * extremely important to retain original semantics. + */ + replaceExpressionWithStatements(nodes: Node[]): Node; + + replaceInline(nodes: Node|Node[]): void; + + // ------------------------- evaluation ------------------------- + /** + * Walk the input `node` and statically evaluate if it's truthy. + * + * Returning `true` when we're sure that the expression will evaluate to a + * truthy value, `false` if we're sure that it will evaluate to a falsy + * value and `undefined` if we aren't sure. Because of this please do not + * rely on coercion when using this method and check with === if it's false. + */ + evaluateTruthy(): boolean; + + /** + * Walk the input `node` and statically evaluate it. + * + * Returns an object in the form `{ confident, value }`. `confident` indicates + * whether or not we had to drop out of evaluating the expression because of + * hitting an unknown node that we couldn't confidently find the value of. + * + * Example: + * + * t.evaluate(parse("5 + 5")) // { confident: true, value: 10 } + * t.evaluate(parse("!true")) // { confident: true, value: false } + * t.evaluate(parse("foo + foo")) // { confident: false, value: undefined } + */ + evaluate(): {confident: boolean; value: any}; + + // ------------------------- introspection ------------------------- + /** + * Match the current node if it matches the provided `pattern`. + * + * For example, given the match `React.createClass` it would match the + * parsed nodes of `React.createClass` and `React["createClass"]`. + */ + matchesPattern(pattern: string, allowPartial?: boolean): boolean; + + /** + * Check whether we have the input `key`. If the `key` references an array then we check + * if the array has any items, otherwise we just check if it's falsy. + */ + has(key: string): boolean; + + isStatic(): boolean; + + /** Alias of `has`. */ + is(key: string): boolean; + + /** Opposite of `has`. */ + isnt(key: string): boolean; + + /** Check whether the path node `key` strict equals `value`. */ + equals(key: string, value: any): boolean; + + /** + * Check the type against our stored internal type of the node. This is handy when a node has + * been removed yet we still internally know the type and need it to calculate node replacement. + */ + isNodeType(type: string): boolean; + + /** + * This checks whether or not we're in one of the following positions: + * + * for (KEY in right); + * for (KEY;;); + * + * This is because these spots allow VariableDeclarations AND normal expressions so we need + * to tell the path replacement that it's ok to replace this with an expression. + */ + canHaveVariableDeclarationOrExpression(): boolean; + + /** + * This checks whether we are swapping an arrow function's body between an + * expression and a block statement (or vice versa). + * + * This is because arrow functions may implicitly return an expression, which + * is the same as containing a block statement. + */ + canSwapBetweenExpressionAndStatement(replacement: Node): boolean; + + /** Check whether the current path references a completion record */ + isCompletionRecord(allowInsideFunction?: boolean): boolean; + + /** + * Check whether or not the current `key` allows either a single statement or block statement + * so we can explode it if necessary. + */ + isStatementOrBlock(): boolean; + + /** Check if the currently assigned path references the `importName` of `moduleSource`. */ + referencesImport(moduleSource: string, importName: string): boolean; + + /** Get the source code associated with this node. */ + getSource(): string; + + /** Check if the current path will maybe execute before another path */ + willIMaybeExecuteBefore(path: NodePath): boolean; + + // ------------------------- context ------------------------- + call(key: string): boolean; + + isBlacklisted(): boolean; + + visit(): boolean; + + skip(): void; + + skipKey(key: string): void; + + stop(): void; + + setScope(): void; + + setContext(context: TraversalContext): NodePath; + + popContext(): void; + + pushContext(context: TraversalContext): void; + + // ------------------------- removal ------------------------- + remove(): void; + + // ------------------------- modification ------------------------- + /** Insert the provided nodes before the current one. */ + insertBefore(nodes: Node|Node[]): any; + + /** + * Insert the provided nodes after the current one. When inserting nodes after an + * expression, ensure that the completion record is correct by pushing the current node. + */ + insertAfter(nodes: Node|Node[]): any; + + /** Update all sibling node paths after `fromIndex` by `incrementBy`. */ + updateSiblingKeys(fromIndex: number, incrementBy: number): void; + + /** Hoist the current node to the highest scope possible and return a UID referencing it. */ + hoist(scope: Scope): void; + + // ------------------------- family ------------------------- + getOpposite(): NodePath; + + getCompletionRecords(): NodePath[]; + + getSibling(key: string|number): NodePath; + getAllPrevSiblings(): NodePath[]; + getAllNextSiblings(): NodePath[]; + + get(key: K, context?: boolean|TraversalContext): + T[K] extends Array? Array>: T[K] extends Node + |null|undefined? NodePath: never; + get(key: string, context?: boolean|TraversalContext): NodePath|NodePath[]; + + getBindingIdentifiers(duplicates?: boolean): Node[]; + + getOuterBindingIdentifiers(duplicates?: boolean): Node[]; + + // ------------------------- comments ------------------------- + /** Share comments amongst siblings. */ + shareCommentsWithSiblings(): void; + + addComment(type: string, content: string, line?: boolean): void; + + /** Give node `comments` of the specified `type`. */ + addComments(type: string, comments: any[]): void; + + // ------------------------- isXXX ------------------------- + isArrayExpression(opts?: object): this is NodePath; + isAssignmentExpression(opts?: object): this is NodePath; + isBinaryExpression(opts?: object): this is NodePath; + isDirective(opts?: object): this is NodePath; + isDirectiveLiteral(opts?: object): this is NodePath; + isBlockStatement(opts?: object): this is NodePath; + isBreakStatement(opts?: object): this is NodePath; + isCallExpression(opts?: object): this is NodePath; + isCatchClause(opts?: object): this is NodePath; + isConditionalExpression(opts?: object): this is NodePath; + isContinueStatement(opts?: object): this is NodePath; + isDebuggerStatement(opts?: object): this is NodePath; + isDoWhileStatement(opts?: object): this is NodePath; + isEmptyStatement(opts?: object): this is NodePath; + isExpressionStatement(opts?: object): this is NodePath; + isFile(opts?: object): this is NodePath; + isForInStatement(opts?: object): this is NodePath; + isForStatement(opts?: object): this is NodePath; + isFunctionDeclaration(opts?: object): this is NodePath; + isFunctionExpression(opts?: object): this is NodePath; + isIdentifier(opts?: object): this is NodePath; + isIfStatement(opts?: object): this is NodePath; + isLabeledStatement(opts?: object): this is NodePath; + isStringLiteral(opts?: object): this is NodePath; + isNumericLiteral(opts?: object): this is NodePath; + isNullLiteral(opts?: object): this is NodePath; + isBooleanLiteral(opts?: object): this is NodePath; + isRegExpLiteral(opts?: object): this is NodePath; + isLogicalExpression(opts?: object): this is NodePath; + isMemberExpression(opts?: object): this is NodePath; + isNewExpression(opts?: object): this is NodePath; + isProgram(opts?: object): this is NodePath; + isObjectExpression(opts?: object): this is NodePath; + isObjectMethod(opts?: object): this is NodePath; + isObjectProperty(opts?: object): this is NodePath; + isRestElement(opts?: object): this is NodePath; + isReturnStatement(opts?: object): this is NodePath; + isSequenceExpression(opts?: object): this is NodePath; + isSwitchCase(opts?: object): this is NodePath; + isSwitchStatement(opts?: object): this is NodePath; + isThisExpression(opts?: object): this is NodePath; + isThrowStatement(opts?: object): this is NodePath; + isTryStatement(opts?: object): this is NodePath; + isUnaryExpression(opts?: object): this is NodePath; + isUpdateExpression(opts?: object): this is NodePath; + isVariableDeclaration(opts?: object): this is NodePath; + isVariableDeclarator(opts?: object): this is NodePath; + isWhileStatement(opts?: object): this is NodePath; + isWithStatement(opts?: object): this is NodePath; + isAssignmentPattern(opts?: object): this is NodePath; + isArrayPattern(opts?: object): this is NodePath; + isArrowFunctionExpression(opts?: object): this is NodePath; + isClassBody(opts?: object): this is NodePath; + isClassDeclaration(opts?: object): this is NodePath; + isClassExpression(opts?: object): this is NodePath; + isExportAllDeclaration(opts?: object): this is NodePath; + isExportDefaultDeclaration(opts?: object): this is NodePath; + isExportNamedDeclaration(opts?: object): this is NodePath; + isExportSpecifier(opts?: object): this is NodePath; + isForOfStatement(opts?: object): this is NodePath; + isImportDeclaration(opts?: object): this is NodePath; + isImportDefaultSpecifier(opts?: object): this is NodePath; + isImportNamespaceSpecifier(opts?: object): this is NodePath; + isImportSpecifier(opts?: object): this is NodePath; + isMetaProperty(opts?: object): this is NodePath; + isClassMethod(opts?: object): this is NodePath; + isObjectPattern(opts?: object): this is NodePath; + isSpreadElement(opts?: object): this is NodePath; + isSuper(opts?: object): this is NodePath; + isTaggedTemplateExpression(opts?: object): this is NodePath; + isTemplateElement(opts?: object): this is NodePath; + isTemplateLiteral(opts?: object): this is NodePath; + isYieldExpression(opts?: object): this is NodePath; + isAnyTypeAnnotation(opts?: object): this is NodePath; + isArrayTypeAnnotation(opts?: object): this is NodePath; + isBooleanTypeAnnotation(opts?: object): this is NodePath; + isBooleanLiteralTypeAnnotation(opts?: object): this is NodePath; + isNullLiteralTypeAnnotation(opts?: object): this is NodePath; + isClassImplements(opts?: object): this is NodePath; + isClassProperty(opts?: object): this is NodePath; + isDeclareClass(opts?: object): this is NodePath; + isDeclareFunction(opts?: object): this is NodePath; + isDeclareInterface(opts?: object): this is NodePath; + isDeclareModule(opts?: object): this is NodePath; + isDeclareTypeAlias(opts?: object): this is NodePath; + isDeclareVariable(opts?: object): this is NodePath; + isFunctionTypeAnnotation(opts?: object): this is NodePath; + isFunctionTypeParam(opts?: object): this is NodePath; + isGenericTypeAnnotation(opts?: object): this is NodePath; + isInterfaceExtends(opts?: object): this is NodePath; + isInterfaceDeclaration(opts?: object): this is NodePath; + isIntersectionTypeAnnotation(opts?: object): this is NodePath; + isMixedTypeAnnotation(opts?: object): this is NodePath; + isNullableTypeAnnotation(opts?: object): this is NodePath; + isNumberTypeAnnotation(opts?: object): this is NodePath; + isStringLiteralTypeAnnotation(opts?: object): this is NodePath; + isStringTypeAnnotation(opts?: object): this is NodePath; + isThisTypeAnnotation(opts?: object): this is NodePath; + isTupleTypeAnnotation(opts?: object): this is NodePath; + isTypeofTypeAnnotation(opts?: object): this is NodePath; + isTypeAlias(opts?: object): this is NodePath; + isTypeAnnotation(opts?: object): this is NodePath; + isTypeCastExpression(opts?: object): this is NodePath; + isTypeParameterDeclaration(opts?: object): this is NodePath; + isTypeParameterInstantiation(opts?: object): this is NodePath; + isObjectTypeAnnotation(opts?: object): this is NodePath; + isObjectTypeCallProperty(opts?: object): this is NodePath; + isObjectTypeIndexer(opts?: object): this is NodePath; + isObjectTypeProperty(opts?: object): this is NodePath; + isQualifiedTypeIdentifier(opts?: object): this is NodePath; + isUnionTypeAnnotation(opts?: object): this is NodePath; + isVoidTypeAnnotation(opts?: object): this is NodePath; + isJSXAttribute(opts?: object): this is NodePath; + isJSXClosingElement(opts?: object): this is NodePath; + isJSXElement(opts?: object): this is NodePath; + isJSXEmptyExpression(opts?: object): this is NodePath; + isJSXExpressionContainer(opts?: object): this is NodePath; + isJSXIdentifier(opts?: object): this is NodePath; + isJSXMemberExpression(opts?: object): this is NodePath; + isJSXNamespacedName(opts?: object): this is NodePath; + isJSXOpeningElement(opts?: object): this is NodePath; + isJSXSpreadAttribute(opts?: object): this is NodePath; + isJSXText(opts?: object): this is NodePath; + isNoop(opts?: object): this is NodePath; + isParenthesizedExpression(opts?: object): this is NodePath; + isAwaitExpression(opts?: object): this is NodePath; + isBindExpression(opts?: object): this is NodePath; + isDecorator(opts?: object): this is NodePath; + isDoExpression(opts?: object): this is NodePath; + isExportDefaultSpecifier(opts?: object): this is NodePath; + isExportNamespaceSpecifier(opts?: object): this is NodePath; + isRestProperty(opts?: object): this is NodePath; + isSpreadProperty(opts?: object): this is NodePath; + isExpression(opts?: object): this is NodePath; + isBinary(opts?: object): this is NodePath; + isScopable(opts?: object): this is NodePath; + isBlockParent(opts?: object): this is NodePath; + isBlock(opts?: object): this is NodePath; + isStatement(opts?: object): this is NodePath; + isTerminatorless(opts?: object): this is NodePath; + isCompletionStatement(opts?: object): this is NodePath; + isConditional(opts?: object): this is NodePath; + isLoop(opts?: object): this is NodePath; + isWhile(opts?: object): this is NodePath; + isExpressionWrapper(opts?: object): this is NodePath; + isFor(opts?: object): this is NodePath; + isForXStatement(opts?: object): this is NodePath; + isFunction(opts?: object): this is NodePath; + isFunctionParent(opts?: object): this is NodePath; + isPureish(opts?: object): this is NodePath; + isDeclaration(opts?: object): this is NodePath; + isLVal(opts?: object): this is NodePath; + isLiteral(opts?: object): this is NodePath; + isImmutable(opts?: object): this is NodePath; + isUserWhitespacable(opts?: object): this is NodePath; + isMethod(opts?: object): this is NodePath; + isObjectMember(opts?: object): this is NodePath; + isProperty(opts?: object): this is NodePath; + isUnaryLike(opts?: object): this is NodePath; + isPattern(opts?: object): this is NodePath; + isClass(opts?: object): this is NodePath; + isModuleDeclaration(opts?: object): this is NodePath; + isExportDeclaration(opts?: object): this is NodePath; + isModuleSpecifier(opts?: object): this is NodePath; + isFlow(opts?: object): this is NodePath; + isFlowBaseAnnotation(opts?: object): this is NodePath; + isFlowDeclaration(opts?: object): this is NodePath; + isJSX(opts?: object): this is NodePath; + isNumberLiteral(opts?: object): this is NodePath; + isRegexLiteral(opts?: object): this is NodePath; + isReferencedIdentifier(opts?: object): this is NodePath; + isReferencedMemberExpression(opts?: object): this is NodePath; + isBindingIdentifier(opts?: object): this is NodePath; + isScope(opts?: object): this is NodePath; + isReferenced(opts?: object): boolean; + isBlockScoped(opts?: object): + this is NodePath; + isVar(opts?: object): this is NodePath; + isUser(opts?: object): boolean; + isGenerated(opts?: object): boolean; + isPure(opts?: object): boolean; + + // ------------------------- assertXXX ------------------------- + assertArrayExpression(opts?: object): void; + assertAssignmentExpression(opts?: object): void; + assertBinaryExpression(opts?: object): void; + assertDirective(opts?: object): void; + assertDirectiveLiteral(opts?: object): void; + assertBlockStatement(opts?: object): void; + assertBreakStatement(opts?: object): void; + assertCallExpression(opts?: object): void; + assertCatchClause(opts?: object): void; + assertConditionalExpression(opts?: object): void; + assertContinueStatement(opts?: object): void; + assertDebuggerStatement(opts?: object): void; + assertDoWhileStatement(opts?: object): void; + assertEmptyStatement(opts?: object): void; + assertExpressionStatement(opts?: object): void; + assertFile(opts?: object): void; + assertForInStatement(opts?: object): void; + assertForStatement(opts?: object): void; + assertFunctionDeclaration(opts?: object): void; + assertFunctionExpression(opts?: object): void; + assertIdentifier(opts?: object): void; + assertIfStatement(opts?: object): void; + assertLabeledStatement(opts?: object): void; + assertStringLiteral(opts?: object): void; + assertNumericLiteral(opts?: object): void; + assertNullLiteral(opts?: object): void; + assertBooleanLiteral(opts?: object): void; + assertRegExpLiteral(opts?: object): void; + assertLogicalExpression(opts?: object): void; + assertMemberExpression(opts?: object): void; + assertNewExpression(opts?: object): void; + assertProgram(opts?: object): void; + assertObjectExpression(opts?: object): void; + assertObjectMethod(opts?: object): void; + assertObjectProperty(opts?: object): void; + assertRestElement(opts?: object): void; + assertReturnStatement(opts?: object): void; + assertSequenceExpression(opts?: object): void; + assertSwitchCase(opts?: object): void; + assertSwitchStatement(opts?: object): void; + assertThisExpression(opts?: object): void; + assertThrowStatement(opts?: object): void; + assertTryStatement(opts?: object): void; + assertUnaryExpression(opts?: object): void; + assertUpdateExpression(opts?: object): void; + assertVariableDeclaration(opts?: object): void; + assertVariableDeclarator(opts?: object): void; + assertWhileStatement(opts?: object): void; + assertWithStatement(opts?: object): void; + assertAssignmentPattern(opts?: object): void; + assertArrayPattern(opts?: object): void; + assertArrowFunctionExpression(opts?: object): void; + assertClassBody(opts?: object): void; + assertClassDeclaration(opts?: object): void; + assertClassExpression(opts?: object): void; + assertExportAllDeclaration(opts?: object): void; + assertExportDefaultDeclaration(opts?: object): void; + assertExportNamedDeclaration(opts?: object): void; + assertExportSpecifier(opts?: object): void; + assertForOfStatement(opts?: object): void; + assertImportDeclaration(opts?: object): void; + assertImportDefaultSpecifier(opts?: object): void; + assertImportNamespaceSpecifier(opts?: object): void; + assertImportSpecifier(opts?: object): void; + assertMetaProperty(opts?: object): void; + assertClassMethod(opts?: object): void; + assertObjectPattern(opts?: object): void; + assertSpreadElement(opts?: object): void; + assertSuper(opts?: object): void; + assertTaggedTemplateExpression(opts?: object): void; + assertTemplateElement(opts?: object): void; + assertTemplateLiteral(opts?: object): void; + assertYieldExpression(opts?: object): void; + assertAnyTypeAnnotation(opts?: object): void; + assertArrayTypeAnnotation(opts?: object): void; + assertBooleanTypeAnnotation(opts?: object): void; + assertBooleanLiteralTypeAnnotation(opts?: object): void; + assertNullLiteralTypeAnnotation(opts?: object): void; + assertClassImplements(opts?: object): void; + assertClassProperty(opts?: object): void; + assertDeclareClass(opts?: object): void; + assertDeclareFunction(opts?: object): void; + assertDeclareInterface(opts?: object): void; + assertDeclareModule(opts?: object): void; + assertDeclareTypeAlias(opts?: object): void; + assertDeclareVariable(opts?: object): void; + assertExistentialTypeParam(opts?: object): void; + assertFunctionTypeAnnotation(opts?: object): void; + assertFunctionTypeParam(opts?: object): void; + assertGenericTypeAnnotation(opts?: object): void; + assertInterfaceExtends(opts?: object): void; + assertInterfaceDeclaration(opts?: object): void; + assertIntersectionTypeAnnotation(opts?: object): void; + assertMixedTypeAnnotation(opts?: object): void; + assertNullableTypeAnnotation(opts?: object): void; + assertNumericLiteralTypeAnnotation(opts?: object): void; + assertNumberTypeAnnotation(opts?: object): void; + assertStringLiteralTypeAnnotation(opts?: object): void; + assertStringTypeAnnotation(opts?: object): void; + assertThisTypeAnnotation(opts?: object): void; + assertTupleTypeAnnotation(opts?: object): void; + assertTypeofTypeAnnotation(opts?: object): void; + assertTypeAlias(opts?: object): void; + assertTypeAnnotation(opts?: object): void; + assertTypeCastExpression(opts?: object): void; + assertTypeParameterDeclaration(opts?: object): void; + assertTypeParameterInstantiation(opts?: object): void; + assertObjectTypeAnnotation(opts?: object): void; + assertObjectTypeCallProperty(opts?: object): void; + assertObjectTypeIndexer(opts?: object): void; + assertObjectTypeProperty(opts?: object): void; + assertQualifiedTypeIdentifier(opts?: object): void; + assertUnionTypeAnnotation(opts?: object): void; + assertVoidTypeAnnotation(opts?: object): void; + assertJSXAttribute(opts?: object): void; + assertJSXClosingElement(opts?: object): void; + assertJSXElement(opts?: object): void; + assertJSXEmptyExpression(opts?: object): void; + assertJSXExpressionContainer(opts?: object): void; + assertJSXIdentifier(opts?: object): void; + assertJSXMemberExpression(opts?: object): void; + assertJSXNamespacedName(opts?: object): void; + assertJSXOpeningElement(opts?: object): void; + assertJSXSpreadAttribute(opts?: object): void; + assertJSXText(opts?: object): void; + assertNoop(opts?: object): void; + assertParenthesizedExpression(opts?: object): void; + assertAwaitExpression(opts?: object): void; + assertBindExpression(opts?: object): void; + assertDecorator(opts?: object): void; + assertDoExpression(opts?: object): void; + assertExportDefaultSpecifier(opts?: object): void; + assertExportNamespaceSpecifier(opts?: object): void; + assertRestProperty(opts?: object): void; + assertSpreadProperty(opts?: object): void; + assertExpression(opts?: object): void; + assertBinary(opts?: object): void; + assertScopable(opts?: object): void; + assertBlockParent(opts?: object): void; + assertBlock(opts?: object): void; + assertStatement(opts?: object): void; + assertTerminatorless(opts?: object): void; + assertCompletionStatement(opts?: object): void; + assertConditional(opts?: object): void; + assertLoop(opts?: object): void; + assertWhile(opts?: object): void; + assertExpressionWrapper(opts?: object): void; + assertFor(opts?: object): void; + assertForXStatement(opts?: object): void; + assertFunction(opts?: object): void; + assertFunctionParent(opts?: object): void; + assertPureish(opts?: object): void; + assertDeclaration(opts?: object): void; + assertLVal(opts?: object): void; + assertLiteral(opts?: object): void; + assertImmutable(opts?: object): void; + assertUserWhitespacable(opts?: object): void; + assertMethod(opts?: object): void; + assertObjectMember(opts?: object): void; + assertProperty(opts?: object): void; + assertUnaryLike(opts?: object): void; + assertPattern(opts?: object): void; + assertClass(opts?: object): void; + assertModuleDeclaration(opts?: object): void; + assertExportDeclaration(opts?: object): void; + assertModuleSpecifier(opts?: object): void; + assertFlow(opts?: object): void; + assertFlowBaseAnnotation(opts?: object): void; + assertFlowDeclaration(opts?: object): void; + assertJSX(opts?: object): void; + assertNumberLiteral(opts?: object): void; + assertRegexLiteral(opts?: object): void; + } + + export class Hub { + constructor(file: any, options: any); + file: any; + options: any; + } + + export interface TraversalContext { + parentPath: NodePath; + scope: Scope; + state: any; + opts: any; + } +} \ No newline at end of file diff --git a/packages/localize/src/translate.ts b/packages/localize/src/translate.ts index 78f52acc8f..83a92f5a64 100644 --- a/packages/localize/src/translate.ts +++ b/packages/localize/src/translate.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {LocalizeFn} from './localize'; -import {MessageId, TargetMessage} from './utils/messages'; -import {ParsedTranslation, parseTranslation, translate as _translate} from './utils/translations'; +import {MessageId, ParsedTranslation, TargetMessage, parseTranslation, translate as _translate} from './utils'; /** * We augment the `$localize` object to also store the translations. diff --git a/packages/localize/src/utils/BUILD.bazel b/packages/localize/src/utils/BUILD.bazel new file mode 100644 index 0000000000..ad07f16b7f --- /dev/null +++ b/packages/localize/src/utils/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "utils", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), + module_name = "@angular/localize/src/utils", + deps = [ + "//packages/compiler", + ], +) diff --git a/packages/localize/src/utils/index.ts b/packages/localize/src/utils/index.ts new file mode 100644 index 0000000000..22b57e3ba1 --- /dev/null +++ b/packages/localize/src/utils/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export * from './src/constants'; +export * from './src/messages'; +export * from './src/translations'; \ No newline at end of file diff --git a/packages/localize/src/utils/constants.ts b/packages/localize/src/utils/src/constants.ts similarity index 100% rename from packages/localize/src/utils/constants.ts rename to packages/localize/src/utils/src/constants.ts diff --git a/packages/localize/src/utils/messages.ts b/packages/localize/src/utils/src/messages.ts similarity index 92% rename from packages/localize/src/utils/messages.ts rename to packages/localize/src/utils/src/messages.ts index 0ebcc1d141..5188caa1d2 100644 --- a/packages/localize/src/utils/messages.ts +++ b/packages/localize/src/utils/src/messages.ts @@ -74,6 +74,18 @@ export interface ParsedMessage { * The meaning of the `message`, used to distinguish identical `messageString`s. */ meaning: string; + /** + * The description of the `message`, used to aid translation. + */ + description: string; + /** + * The static parts of the message. + */ + messageParts: string[]; + /** + * The names of the placeholders that will be replaced with substitutions. + */ + placeholderNames: string[]; } /** @@ -85,6 +97,8 @@ export function parseMessage( messageParts: TemplateStringsArray, expressions?: readonly any[]): ParsedMessage { const substitutions: {[placeholderName: string]: any} = {}; const metadata = parseMetadata(messageParts[0], messageParts.raw[0]); + const cleanedMessageParts: string[] = [metadata.text]; + const placeholderNames: string[] = []; let messageString = metadata.text; for (let i = 1; i < messageParts.length; i++) { const {text: messagePart, block: placeholderName = computePlaceholderName(i)} = @@ -93,12 +107,16 @@ export function parseMessage( if (expressions !== undefined) { substitutions[placeholderName] = expressions[i - 1]; } + placeholderNames.push(placeholderName); + cleanedMessageParts.push(messagePart); } return { messageId: metadata.id || computeMsgId(messageString, metadata.meaning || ''), substitutions, messageString, meaning: metadata.meaning || '', + description: metadata.description || '', + messageParts: cleanedMessageParts, placeholderNames, }; } diff --git a/packages/localize/src/utils/translations.ts b/packages/localize/src/utils/src/translations.ts similarity index 70% rename from packages/localize/src/utils/translations.ts rename to packages/localize/src/utils/src/translations.ts index 616de96482..dd64685c71 100644 --- a/packages/localize/src/utils/translations.ts +++ b/packages/localize/src/utils/src/translations.ts @@ -22,6 +22,16 @@ export interface ParsedTranslation { */ export type ParsedTranslations = Record; +export class MissingTranslationError extends Error { + private readonly type = 'MissingTranslationError'; + constructor(readonly parsedMessage: ParsedMessage) { + super(`No translation found for ${describeMessage(parsedMessage)}.`); + } +} + +export function isMissingTranslationError(e: any): e is MissingTranslationError { + return e.type === 'MissingTranslationError'; +} /** * Translate the text of the `$localize` tagged-string (i.e. `messageParts` and @@ -43,20 +53,19 @@ export function translate( substitutions: readonly any[]): [TemplateStringsArray, readonly any[]] { const message = parseMessage(messageParts, substitutions); const translation = translations[message.messageId]; - if (translation !== undefined) { - return [ - translation.messageParts, translation.placeholderNames.map(placeholder => { - if (message.substitutions.hasOwnProperty(placeholder)) { - return message.substitutions[placeholder]; - } else { - throw new Error( - `No placeholder found with name ${placeholder} in message ${describeMessage(message)}.`); - } - }) - ]; - } else { - throw new Error(`No translation found for ${describeMessage(message)}.`); + if (translation === undefined) { + throw new MissingTranslationError(message); } + return [ + translation.messageParts, translation.placeholderNames.map(placeholder => { + if (message.substitutions.hasOwnProperty(placeholder)) { + return message.substitutions[placeholder]; + } else { + throw new Error( + `No placeholder found with name ${placeholder} in message ${describeMessage(message)}.`); + } + }) + ]; } /** @@ -80,6 +89,17 @@ export function parseTranslation(message: TargetMessage): ParsedTranslation { return {messageParts: makeTemplateObject(messageParts, rawMessageParts), placeholderNames}; } +/** + * Create a `ParsedTranslation` from a set of `messageParts` and `placeholderNames`. + * + * @param messageParts The message parts to appear in the ParsedTranslation. + * @param placeholderNames The names of the placeholders to intersperse between the `messageParts`. + */ +export function makeParsedTranslation( + messageParts: string[], placeholderNames: string[] = []): ParsedTranslation { + return {messageParts: makeTemplateObject(messageParts, messageParts), placeholderNames}; +} + /** * Create the specialized array that is passed to tagged-string tag functions. * diff --git a/packages/localize/src/utils/test/BUILD.bazel b/packages/localize/src/utils/test/BUILD.bazel new file mode 100644 index 0000000000..aaf43dcf63 --- /dev/null +++ b/packages/localize/src/utils/test/BUILD.bazel @@ -0,0 +1,24 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["**/*_spec.ts"], + ), + deps = [ + "//packages:types", + "//packages/localize/src/utils", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = [ + "angular/tools/testing/init_node_no_angular_spec.js", + ], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/localize/test/utils/messages_spec.ts b/packages/localize/src/utils/test/messages_spec.ts similarity index 78% rename from packages/localize/test/utils/messages_spec.ts rename to packages/localize/src/utils/test/messages_spec.ts index 83e09625fd..2167e8cfdd 100644 --- a/packages/localize/test/utils/messages_spec.ts +++ b/packages/localize/src/utils/test/messages_spec.ts @@ -5,8 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {findEndOfBlock, parseMessage, parseMetadata, splitBlock} from '../../src/utils/messages'; -import {makeTemplateObject} from '../../src/utils/translations'; +import {findEndOfBlock, makeTemplateObject, parseMessage, parseMetadata, splitBlock} from '..'; describe('messages utils', () => { describe('parseMessage', () => { @@ -42,23 +41,54 @@ describe('messages utils', () => { expect(message3.messageId).not.toEqual(message1.messageId); }); - it('should compute the translation key, inferring placeholder names if not given', () => { - const message = parseMessage(makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), [1, 2]); - expect(message.messageId).toEqual('8107531564991075946'); + it('should infer placeholder names if not given', () => { + const parts1 = ['a', 'b', 'c']; + const message1 = parseMessage(makeTemplateObject(parts1, parts1), [1, 2]); + expect(message1.messageId).toEqual('8107531564991075946'); + + const parts2 = ['a', ':custom1:b', ':custom2:c']; + const message2 = parseMessage(makeTemplateObject(parts2, parts2), [1, 2]); + expect(message2.messageId).toEqual('1822117095464505589'); + + // Note that the placeholder names are part of the message so affect the message id. + expect(message1.messageId).not.toEqual(message2.messageId); + expect(message1.messageString).not.toEqual(message2.messageString); }); - it('should compute the translation key, ignoring escaped placeholder names', () => { + it('should ignore placeholder blocks whose markers have been escaped', () => { const message = parseMessage( makeTemplateObject(['a', ':one:b', ':two:c'], ['a', '\\:one:b', '\\:two:c']), [1, 2]); expect(message.messageId).toEqual('2623373088949454037'); }); - it('should compute the translation key, handling empty raw values', () => { + it('should handle raw values that are empty (from synthesized AST)', () => { const message = parseMessage(makeTemplateObject(['a', ':one:b', ':two:c'], ['', '', '']), [1, 2]); expect(message.messageId).toEqual('8865273085679272414'); }); + it('should extract the meaning, description and placeholder names', () => { + const message1 = parseMessage(makeTemplateObject(['abc'], ['abc']), []); + expect(message1.messageParts).toEqual(['abc']); + expect(message1.meaning).toEqual(''); + expect(message1.description).toEqual(''); + expect(message1.placeholderNames).toEqual([]); + + const message2 = parseMessage( + makeTemplateObject([':meaning|description:abc'], [':meaning|description:abc']), []); + expect(message2.messageParts).toEqual(['abc']); + expect(message2.meaning).toEqual('meaning'); + expect(message2.description).toEqual('description'); + expect(message2.placeholderNames).toEqual([]); + + const message3 = parseMessage( + makeTemplateObject(['a', ':custom:b', 'c'], ['a', ':custom:b', 'c']), [0, 1]); + expect(message3.messageParts).toEqual(['a', 'b', 'c']); + expect(message3.meaning).toEqual(''); + expect(message3.description).toEqual(''); + expect(message3.placeholderNames).toEqual(['custom', 'PH_1']); + }); + it('should build a map of named placeholders to expressions', () => { const message = parseMessage( makeTemplateObject(['a', ':one:b', ':two:c'], ['a', ':one:b', ':two:c']), [1, 2]); diff --git a/packages/localize/test/utils/translations_spec.ts b/packages/localize/src/utils/test/translations_spec.ts similarity index 97% rename from packages/localize/test/utils/translations_spec.ts rename to packages/localize/src/utils/test/translations_spec.ts index 89c98884aa..a4f7899659 100644 --- a/packages/localize/test/utils/translations_spec.ts +++ b/packages/localize/src/utils/test/translations_spec.ts @@ -5,8 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {TargetMessage, computeMsgId} from '../../src/utils/messages'; -import {ParsedTranslation, makeTemplateObject, parseTranslation, translate} from '../../src/utils/translations'; +import {ParsedTranslation, TargetMessage, computeMsgId, makeTemplateObject, parseTranslation, translate} from '..'; describe('utils', () => { describe('makeTemplateObject', () => { diff --git a/packages/localize/test/BUILD.bazel b/packages/localize/test/BUILD.bazel index 9098fa604d..13381b2dad 100644 --- a/packages/localize/test/BUILD.bazel +++ b/packages/localize/test/BUILD.bazel @@ -10,6 +10,7 @@ ts_library( "//packages:types", "//packages/localize", "//packages/localize/init", + "//packages/localize/src/utils", ], ) diff --git a/packages/localize/test/translate_spec.ts b/packages/localize/test/translate_spec.ts index 607a5fbac4..246978b4dd 100644 --- a/packages/localize/test/translate_spec.ts +++ b/packages/localize/test/translate_spec.ts @@ -8,8 +8,8 @@ // Ensure that `$localize` is loaded to the global scope. import '@angular/localize/init'; -import {clearTranslations, loadTranslations} from '../src/translate'; -import {MessageId, TargetMessage, computeMsgId} from '../src/utils/messages'; +import {clearTranslations, loadTranslations} from '../localize'; +import {MessageId, TargetMessage, computeMsgId} from '../src/utils'; describe('$localize tag with translations', () => { describe('identities', () => { diff --git a/test-main.js b/test-main.js index 45bbc1c456..65b1fb79ff 100644 --- a/test-main.js +++ b/test-main.js @@ -51,6 +51,7 @@ System.config({ '@angular/router': {main: 'index.js', defaultExtension: 'js'}, '@angular/http/testing': {main: 'index.js', defaultExtension: 'js'}, '@angular/http': {main: 'index.js', defaultExtension: 'js'}, + '@angular/localize/src/utils': {main: 'index.js', defaultExtension: 'js'}, '@angular/localize/src/localize': {main: 'index.js', defaultExtension: 'js'}, '@angular/localize/init': {main: 'index.js', defaultExtension: 'js'}, '@angular/localize': {main: 'index.js', defaultExtension: 'js'}, diff --git a/tools/gulp-tasks/lint.js b/tools/gulp-tasks/lint.js index ff9c36a215..f5ef3b7056 100644 --- a/tools/gulp-tasks/lint.js +++ b/tools/gulp-tasks/lint.js @@ -42,6 +42,9 @@ module.exports = (gulp) => () => { // TODO(JiaLiPassion): add zone.js back later '!packages/zone.js/**/*.js', '!packages/zone.js/**/*.ts', + + // Ignore test files + '!packages/localize/**/test_files/**', ]) .pipe(tslint({ configuration: path.resolve(__dirname, '../../tslint.json'), diff --git a/yarn.lock b/yarn.lock index 14205071cb..14dcc69979 100644 --- a/yarn.lock +++ b/yarn.lock @@ -81,6 +81,85 @@ dependencies: "@babel/highlight" "^7.0.0" +"@babel/code-frame@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" + integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw== + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/core@^7.5.5": + version "7.6.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.6.3.tgz#44de824e89eaa089bb12da7337bc9bdff2ab68f9" + integrity sha512-QfQ5jTBgXLzJuo7Mo8bZK/ePywmgNRgk/UQykiKwEtZPiFIn8ZqE6jB+AnD1hbB1S2xQyL4//it5vuAUOVAMTw== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.6.3" + "@babel/helpers" "^7.6.2" + "@babel/parser" "^7.6.3" + "@babel/template" "^7.6.0" + "@babel/traverse" "^7.6.3" + "@babel/types" "^7.6.3" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.13" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.6.1" + +"@babel/generator@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.6.2.tgz#dac8a3c2df118334c2a29ff3446da1636a8f8c03" + integrity sha512-j8iHaIW4gGPnViaIHI7e9t/Hl8qLjERI6DcV9kEpAIDJsAOrcnXqRS7t+QbhL76pwbtqP+QCQLL0z1CyVmtjjQ== + dependencies: + "@babel/types" "^7.6.0" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + +"@babel/generator@^7.6.3": + version "7.6.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.6.3.tgz#71d5375264f93ec7bac7d9f35a67067733f5578e" + integrity sha512-hLhYbAb3pHwxjlijC4AQ7mqZdcoujiNaW7izCT04CIowHK8psN0IN8QjDv0iyFtycF5FowUOTwDloIheI25aMw== + dependencies: + "@babel/types" "^7.6.3" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.6.1" + +"@babel/helper-function-name@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz#a0ceb01685f73355d4360c1247f582bfafc8ff53" + integrity sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw== + dependencies: + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/template" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-get-function-arity@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3" + integrity sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-split-export-declaration@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz#ff94894a340be78f53f06af038b205c49d993677" + integrity sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q== + dependencies: + "@babel/types" "^7.4.4" + +"@babel/helpers@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.6.2.tgz#681ffe489ea4dcc55f23ce469e58e59c1c045153" + integrity sha512-3/bAUL8zZxYs1cdX2ilEE0WobqbCmKWr/889lf2SS0PpDcpEIY8pb1CCyz0pEcX3pEb+MCbks1jIokz2xLtGTA== + dependencies: + "@babel/template" "^7.6.0" + "@babel/traverse" "^7.6.2" + "@babel/types" "^7.6.0" + "@babel/highlight@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" @@ -90,6 +169,96 @@ esutils "^2.0.2" js-tokens "^4.0.0" +"@babel/parser@^7.4.4": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b" + integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g== + +"@babel/parser@^7.6.0", "@babel/parser@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.6.2.tgz#205e9c95e16ba3b8b96090677a67c9d6075b70a1" + integrity sha512-mdFqWrSPCmikBoaBYMuBulzTIKuXVPtEISFbRRVNwMWpCms/hmE2kRq0bblUHaNRKrjRlmVbx1sDHmjmRgD2Xg== + +"@babel/parser@^7.6.3": + version "7.6.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.6.3.tgz#9eff8b9c3eeae16a74d8d4ff30da2bd0d6f0487e" + integrity sha512-sUZdXlva1dt2Vw2RqbMkmfoImubO0D0gaCrNngV6Hi0DA4x3o4mlrq0tbfY0dZEUIccH8I6wQ4qgEtwcpOR6Qg== + +"@babel/template@^7.1.0": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.4.tgz#f4b88d1225689a08f5bc3a17483545be9e4ed237" + integrity sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.4.4" + "@babel/types" "^7.4.4" + +"@babel/template@^7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.6.0.tgz#7f0159c7f5012230dad64cca42ec9bdb5c9536e6" + integrity sha512-5AEH2EXD8euCk446b7edmgFdub/qfH1SN6Nii3+fyXP807QRx9Q73A2N5hNwRRslC2H9sNzaFhsPubkS4L8oNQ== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.6.0" + "@babel/types" "^7.6.0" + +"@babel/traverse@^7.6.2": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.6.2.tgz#b0e2bfd401d339ce0e6c05690206d1e11502ce2c" + integrity sha512-8fRE76xNwNttVEF2TwxJDGBLWthUkHWSldmfuBzVRmEDWOtu4XdINTgN7TDWzuLg4bbeIMLvfMFD9we5YcWkRQ== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.6.2" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.4.4" + "@babel/parser" "^7.6.2" + "@babel/types" "^7.6.0" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + +"@babel/traverse@^7.6.3": + version "7.6.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.6.3.tgz#66d7dba146b086703c0fb10dd588b7364cec47f9" + integrity sha512-unn7P4LGsijIxaAJo/wpoU11zN+2IaClkQAxcJWBNCMS6cmVh802IyLHNkAjQ0iYnRS3nnxk5O3fuXW28IMxTw== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.6.3" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.4.4" + "@babel/parser" "^7.6.3" + "@babel/types" "^7.6.3" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + +"@babel/types@^7.0.0", "@babel/types@^7.4.4": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.5.5.tgz#97b9f728e182785909aa4ab56264f090a028d18a" + integrity sha512-s63F9nJioLqOlW3UkyMd+BYhXt44YuaFm/VV0VwuteqjYwRrObkU7ra9pY4wAJR3oXi8hJrMcrcJdO/HH33vtw== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + +"@babel/types@^7.6.0": + version "7.6.1" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.6.1.tgz#53abf3308add3ac2a2884d539151c57c4b3ac648" + integrity sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + +"@babel/types@^7.6.3": + version "7.6.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.6.3.tgz#3f07d96f854f98e2fbd45c64b0cb942d11e8ba09" + integrity sha512-CqbcpTxMcpuQTMhjI37ZHVgjBkysg5icREQIEZ0eG1yCNwg3oy+5AaLiOKmjsCj6nqOsa6Hf0ObjRVwokb7srA== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + "@bazel/bazel-darwin_x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@bazel/bazel-darwin_x64/-/bazel-darwin_x64-0.28.1.tgz#415658785e1dbd6f7ab5c8f2b98c1c99c614e1d5" @@ -2820,6 +2989,13 @@ conventional-commits-parser@^3.0.0: through2 "^2.0.0" trim-off-newlines "^1.0.0" +convert-source-map@^1.1.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== + dependencies: + safe-buffer "~5.1.1" + convert-source-map@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5" @@ -3090,7 +3266,7 @@ debug@^3.0.0, debug@^3.1.0, debug@^3.2.6: dependencies: ms "^2.1.1" -debug@^4.1.1: +debug@^4.1.0, debug@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -4880,6 +5056,11 @@ global-prefix@^1.0.1: is-windows "^1.0.1" which "^1.2.14" +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + globby@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" @@ -6337,6 +6518,11 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + json-buffer@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" @@ -6386,6 +6572,13 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" +json5@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" + integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== + dependencies: + minimist "^1.2.0" + jsonfile@^2.1.0: version "2.4.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" @@ -7066,7 +7259,7 @@ lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== -lodash@^4.17.14, lodash@~4.17.15: +lodash@^4.17.13, lodash@^4.17.14, lodash@~4.17.15: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -10293,7 +10486,7 @@ source-map@^0.4.4, source-map@~0.4.1: dependencies: amdefine ">=0.0.4" -source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.0, source-map@~0.5.1: +source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.0, source-map@~0.5.1: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -11009,6 +11202,11 @@ to-buffer@^1.1.0: resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + to-object-path@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"