From 72f534f7f8ea42fcd7bbf9de414180cd79d2bca5 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 21 Apr 2020 19:58:58 +0100 Subject: [PATCH] feat(localize): support merging multiple translation files (#36792) Previously only one translation file per locale could be loaded. Now the user can specify multiple files per locale, and the translations from each of these files will be merged together by message id. The merging is on a first-wins approach. So if to you have three files to be merged: ``` ['a.xlf', 'b.xmb', 'c.json'] ``` Then any message from `a.xlf` will be used rather than a message from `b.xmb` or `c.json` and so on. In practice this means that you should put the files in order of most important first, with "fallback" translations later. PR Close #36792 --- integration/ivy-i18n/e2e/de/app.e2e-spec.ts | 8 +- integration/ivy-i18n/e2e/en/app.e2e-spec.ts | 18 +- integration/ivy-i18n/e2e/fr/app.e2e-spec.ts | 13 +- integration/ivy-i18n/package.json | 4 +- .../ivy-i18n/src/app/app.component.html | 21 +- integration/ivy-i18n/src/app/app.component.ts | 3 +- .../ivy-i18n/src/locales/extra.de.json | 6 + .../ivy-i18n/src/locales/extra.fr.json | 6 + integration/ivy-i18n/yarn.lock | 271 ++++++++++-------- .../localize/src/tools/src/translate/main.ts | 67 ++++- .../translation_files/translation_loader.ts | 138 ++++++--- .../locales/messages-extra.de.json | 6 + .../test/translate/integration/main_spec.ts | 45 ++- .../integration/test_files/test-extra.js | 3 + .../translation_loader_spec.ts | 143 +++++++-- 15 files changed, 528 insertions(+), 224 deletions(-) create mode 100644 integration/ivy-i18n/src/locales/extra.de.json create mode 100644 integration/ivy-i18n/src/locales/extra.fr.json create mode 100644 packages/localize/src/tools/test/translate/integration/locales/messages-extra.de.json create mode 100644 packages/localize/src/tools/test/translate/integration/test_files/test-extra.js diff --git a/integration/ivy-i18n/e2e/de/app.e2e-spec.ts b/integration/ivy-i18n/e2e/de/app.e2e-spec.ts index 0bc71b5b29..e147aa8348 100644 --- a/integration/ivy-i18n/e2e/de/app.e2e-spec.ts +++ b/integration/ivy-i18n/e2e/de/app.e2e-spec.ts @@ -15,7 +15,13 @@ describe('cli-hello-world-ivy App', () => { expect(page.getParagraph('message')).toEqual('Willkommen in der i18n App. (inline)'); }); - it('should display the locale', () => { expect(page.getParagraph('locale')).toEqual('de'); }); + it('should display extra message', () => { + expect(page.getParagraph('extra')).toEqual('Zusätzliche Nachricht'); + }); + + it('should display the locale', () => { + expect(page.getParagraph('locale')).toEqual('de'); + }); // TODO : Re-enable when CLI translation inlining supports locale inlining (and so we can use it // to load the correct locale data) diff --git a/integration/ivy-i18n/e2e/en/app.e2e-spec.ts b/integration/ivy-i18n/e2e/en/app.e2e-spec.ts index b7935f18cb..66d2d1b7f9 100644 --- a/integration/ivy-i18n/e2e/en/app.e2e-spec.ts +++ b/integration/ivy-i18n/e2e/en/app.e2e-spec.ts @@ -7,13 +7,21 @@ describe('cli-hello-world-ivy App', () => { page.navigateTo(); }); - it('should display title', - () => { expect(page.getHeading()).toEqual('Hello cli-hello-world-ivy-compat!'); }); + it('should display title', () => { + expect(page.getHeading()).toEqual('Hello cli-hello-world-ivy-compat!'); + }); - it('should display welcome message', - () => { expect(page.getParagraph('message')).toEqual('Welcome to the i18n app.'); }); + it('should display welcome message', () => { + expect(page.getParagraph('message')).toEqual('Welcome to the i18n app.'); + }); - it('should display the locale', () => { expect(page.getParagraph('locale')).toEqual('en-US'); }); + it('should display extra message', () => { + expect(page.getParagraph('extra')).toEqual('Extra message'); + }); + + it('should display the locale', () => { + expect(page.getParagraph('locale')).toEqual('en-US'); + }); it('the date pipe should show the localized month', () => { page.navigateTo(); diff --git a/integration/ivy-i18n/e2e/fr/app.e2e-spec.ts b/integration/ivy-i18n/e2e/fr/app.e2e-spec.ts index 795b80e169..93876089bb 100644 --- a/integration/ivy-i18n/e2e/fr/app.e2e-spec.ts +++ b/integration/ivy-i18n/e2e/fr/app.e2e-spec.ts @@ -7,14 +7,21 @@ describe('cli-hello-world-ivy App', () => { page.navigateTo(); }); - it('should display title', - () => { expect(page.getHeading()).toEqual('Bonjour, cli-hello-world-ivy-compat! (inline)'); }); + it('should display title', () => { + expect(page.getHeading()).toEqual('Bonjour, cli-hello-world-ivy-compat! (inline)'); + }); it('should display welcome message', () => { expect(page.getParagraph('message')).toEqual('Bienvenue sur l\'application i18n. (inline)'); }); - it('should display the locale', () => { expect(page.getParagraph('locale')).toEqual('fr'); }); + it('should display extra message', () => { + expect(page.getParagraph('extra')).toEqual('Message supplémentaire'); + }); + + it('should display the locale', () => { + expect(page.getParagraph('locale')).toEqual('fr'); + }); it('the date pipe should show the localized month', () => { page.navigateTo(); diff --git a/integration/ivy-i18n/package.json b/integration/ivy-i18n/package.json index c3ecf05c23..1d8cf1a87c 100644 --- a/integration/ivy-i18n/package.json +++ b/integration/ivy-i18n/package.json @@ -11,7 +11,7 @@ "start": "ng serve", "pretest": "ng version", "test": "ng test && yarn e2e --configuration=ci && yarn e2e --configuration=ci-production && yarn translated:test && yarn translated:legacy-xlf:test && yarn translated:legacy-xmb:test", - "translate": "localize-translate -r \"dist/\" -s \"**/*\" -l \"en-US\" -t \"src/locales/messages.de.json\" \"src/locales/messages.fr.json\" -o \"../tmp/translations/{{LOCALE}}\"", + "translate": "localize-translate -r \"dist/\" -s \"**/*\" -l \"en-US\" -t \"[src/locales/messages.de.json, src/locales/extra.de.json]\" [src/locales/messages.fr.json,src/locales/extra.fr.json] -o \"../tmp/translations/{{LOCALE}}\"", "runtime:test": "yarn e2e --configuration=runtime-translations", "translated:test": "yarn build && yarn translate && yarn translated:fr:e2e && yarn translated:de:e2e && yarn translated:en:e2e", "translated:fr:serve": "serve ../tmp/translations/fr --listen 4200", @@ -72,4 +72,4 @@ "resolutions": { "**/webdriver-manager": "file:../../node_modules/webdriver-manager" } -} +} \ No newline at end of file diff --git a/integration/ivy-i18n/src/app/app.component.html b/integration/ivy-i18n/src/app/app.component.html index eb7616861d..819c20e303 100644 --- a/integration/ivy-i18n/src/app/app.component.html +++ b/integration/ivy-i18n/src/app/app.component.html @@ -1,25 +1,10 @@ - -
+

Hello {{ title }}!

-

{{ message }}

- Angular Logo +

{{extra}}

{{ locale }}

{{ 1 | percent }} awesome

-

{{ jan | date : 'LLLL' }}

-

Here are some links to help you start:

- - +

{{ jan | date : 'LLLL' }}

\ No newline at end of file diff --git a/integration/ivy-i18n/src/app/app.component.ts b/integration/ivy-i18n/src/app/app.component.ts index c6aafcce33..ddf6cb97d0 100644 --- a/integration/ivy-i18n/src/app/app.component.ts +++ b/integration/ivy-i18n/src/app/app.component.ts @@ -5,6 +5,7 @@ import {Component, Inject, LOCALE_ID} from '@angular/core'; export class AppComponent { constructor(@Inject(LOCALE_ID) public locale: string) {} title = `cli-hello-world-ivy-compat`; - message = $localize `Welcome to the i18n app.`; + message = $localize`Welcome to the i18n app.`; jan = new Date(2000, 0, 1); + extra = $localize`:@@custom:Extra message`; } diff --git a/integration/ivy-i18n/src/locales/extra.de.json b/integration/ivy-i18n/src/locales/extra.de.json new file mode 100644 index 0000000000..ce3cbf509c --- /dev/null +++ b/integration/ivy-i18n/src/locales/extra.de.json @@ -0,0 +1,6 @@ +{ + "locale": "de", + "translations": { + "custom": "Zusätzliche Nachricht" + } +} \ No newline at end of file diff --git a/integration/ivy-i18n/src/locales/extra.fr.json b/integration/ivy-i18n/src/locales/extra.fr.json new file mode 100644 index 0000000000..10c9704f7c --- /dev/null +++ b/integration/ivy-i18n/src/locales/extra.fr.json @@ -0,0 +1,6 @@ +{ + "locale": "fr", + "translations": { + "custom": "Message supplémentaire" + } +} \ No newline at end of file diff --git a/integration/ivy-i18n/yarn.lock b/integration/ivy-i18n/yarn.lock index db8afee3a5..45fd3a3efa 100644 --- a/integration/ivy-i18n/yarn.lock +++ b/integration/ivy-i18n/yarn.lock @@ -2,25 +2,25 @@ # yarn lockfile v1 -"@angular-devkit/architect@0.900.0-rc.11": - version "0.900.0-rc.11" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.900.0-rc.11.tgz#e9f3e5e372d467a220027cf53231b88e8e857fbc" - integrity sha512-rRbq4ipppnY4FvVo89Cv+yC7rlt1/VFE/jaB77Ra2tI6zVlFWCTjnMzuc9TYz/3jK1ssThzgEA2sebPDmjH47w== +"@angular-devkit/architect@0.900.3": + version "0.900.3" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.900.3.tgz#9c396733abd12fbb1d5bbc4542b2ee52418adb02" + integrity sha512-4UHc58Dlc5XHY3eiYSX9gytLyPNYixGSRwLcc/LRwuPgrmUFKPzCN3nwgB+9kc03/HN89CsJ1rS1scid6N6vxQ== dependencies: - "@angular-devkit/core" "9.0.0-rc.11" + "@angular-devkit/core" "9.0.3" rxjs "6.5.3" "@angular-devkit/build-angular@file:../../node_modules/@angular-devkit/build-angular": - version "0.900.0-rc.11" + version "0.900.3" dependencies: - "@angular-devkit/architect" "0.900.0-rc.11" - "@angular-devkit/build-optimizer" "0.900.0-rc.11" - "@angular-devkit/build-webpack" "0.900.0-rc.11" - "@angular-devkit/core" "9.0.0-rc.11" + "@angular-devkit/architect" "0.900.3" + "@angular-devkit/build-optimizer" "0.900.3" + "@angular-devkit/build-webpack" "0.900.3" + "@angular-devkit/core" "9.0.3" "@babel/core" "7.7.7" "@babel/generator" "7.7.7" "@babel/preset-env" "7.7.7" - "@ngtools/webpack" "9.0.0-rc.11" + "@ngtools/webpack" "9.0.3" ajv "6.10.2" autoprefixer "9.7.1" babel-loader "8.0.6" @@ -75,10 +75,10 @@ webpack-subresource-integrity "1.3.4" worker-plugin "3.2.0" -"@angular-devkit/build-optimizer@0.900.0-rc.11": - version "0.900.0-rc.11" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-optimizer/-/build-optimizer-0.900.0-rc.11.tgz#96c2446fa9cd2e90700ab8a68312b28b3907f6d9" - integrity sha512-GJC+7H7ER6bxDC2UdAGwW357EYHpv8ISKKmS19wdJV5gZPMPANcpbg9FIpl27SDhUyZX9C2DOrcATvYYFoYgDQ== +"@angular-devkit/build-optimizer@0.900.3": + version "0.900.3" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-optimizer/-/build-optimizer-0.900.3.tgz#91f90c56affb0be9f7910dfc1d414f16c21c2c3f" + integrity sha512-VLAWtAXpOzOoYUJrN6sT90UdIdvrVIipkzGz7nfI1kscDvxUFwVZnsNNHtFinaY2SfZAunHhYQOA/B9FJ8WPdQ== dependencies: loader-utils "1.2.3" source-map "0.7.3" @@ -86,19 +86,19 @@ typescript "3.6.4" webpack-sources "1.4.3" -"@angular-devkit/build-webpack@0.900.0-rc.11": - version "0.900.0-rc.11" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.900.0-rc.11.tgz#d9a91c2b67a629f6adfe87980d26e495f2e30e0a" - integrity sha512-utBAnkO6WLi323Rto1s7TJpaDRqDNR8jkD0C0PG5Zm3y1U9ARbAjTkugkrB/7bc4gEIqWZD+1dLYaaJCidye2Q== +"@angular-devkit/build-webpack@0.900.3": + version "0.900.3" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.900.3.tgz#4a2fd13cebe190c091606e18397a1f7cccfab6bb" + integrity sha512-9gSTLWf7yq/XBOec0CtZcjNMsC7L8IuVDProBQHps2SvTfr982DtHfEge95J2lc9BjRbqidv+phImFsQ1J3mFA== dependencies: - "@angular-devkit/architect" "0.900.0-rc.11" - "@angular-devkit/core" "9.0.0-rc.11" + "@angular-devkit/architect" "0.900.3" + "@angular-devkit/core" "9.0.3" rxjs "6.5.3" -"@angular-devkit/core@9.0.0-rc.11": - version "9.0.0-rc.11" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-9.0.0-rc.11.tgz#9e69545eb21284a573ad78e4c33003f2ea25afd5" - integrity sha512-ki7Sln+mQdCctJNBalzy70tiFn2hOCY2Yyte8B0xKWVHnofZySvG+ANzoLgodnKFOBH18AQy35FhgzZM++N9tQ== +"@angular-devkit/core@9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-9.0.3.tgz#a027862d2edd981afcc6245176e9f27768c631c9" + integrity sha512-3+abmv9K9d+BVgUAolYgoOqlGAA2Jb1pWo2biapSDG6KjUZHUCJdnsKigLtLorCdv0SrjTp56FFplkcqKsFQgA== dependencies: ajv "6.10.2" fast-json-stable-stringify "2.0.0" @@ -106,26 +106,26 @@ rxjs "6.5.3" source-map "0.7.3" -"@angular-devkit/schematics@9.0.0-rc.11": - version "9.0.0-rc.11" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-9.0.0-rc.11.tgz#e0d4d271d8d783ebf05eced576262f20e6c3562c" - integrity sha512-aJqOLzsoAkVj3AVTf1ehH2hA9wHHz1+7TTtfqI+Yx+S3jFyvGmnKrNBCKtMuIV5JdEHiXmhhuGbNBHwRFWpOow== +"@angular-devkit/schematics@9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-9.0.3.tgz#e65fa1ce08a3d5ef0af594b623024439c1110a0d" + integrity sha512-BQnZtFQPLZZOijhuEndtzL6cOnhaE8nNxupkRHavWohOMStnLsRyvVJj6JVDkf37wvT5koqTNjHhbdMxcCRc6A== dependencies: - "@angular-devkit/core" "9.0.0-rc.11" + "@angular-devkit/core" "9.0.3" ora "4.0.2" rxjs "6.5.3" "@angular/animations@file:../../dist/packages-dist/animations": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/cli@file:../../node_modules/@angular/cli": - version "9.0.0-rc.11" + version "9.0.3" dependencies: - "@angular-devkit/architect" "0.900.0-rc.11" - "@angular-devkit/core" "9.0.0-rc.11" - "@angular-devkit/schematics" "9.0.0-rc.11" - "@schematics/angular" "9.0.0-rc.11" - "@schematics/update" "0.900.0-rc.11" + "@angular-devkit/architect" "0.900.3" + "@angular-devkit/core" "9.0.3" + "@angular-devkit/schematics" "9.0.3" + "@schematics/angular" "9.0.3" + "@schematics/update" "0.900.3" "@yarnpkg/lockfile" "1.1.0" ansi-colors "4.1.1" debug "^4.1.1" @@ -143,10 +143,10 @@ uuid "^3.3.2" "@angular/common@file:../../dist/packages-dist/common": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/compiler-cli@file:../../dist/packages-dist/compiler-cli": - version "9.0.0-rc.1" + version "10.0.0-next.2" dependencies: canonical-path "1.0.0" chokidar "^3.0.0" @@ -158,35 +158,36 @@ reflect-metadata "^0.1.2" semver "^6.3.0" source-map "^0.6.1" - yargs "13.1.0" + sourcemap-codec "^1.4.8" + yargs "15.3.0" "@angular/compiler@file:../../dist/packages-dist/compiler": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/core@file:../../dist/packages-dist/core": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/forms@file:../../dist/packages-dist/forms": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/language-service@file:../../dist/packages-dist/language-service": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/localize@file:../../dist/packages-dist/localize": - version "9.0.0-rc.1" + version "10.0.0-next.2" dependencies: "@babel/core" "7.8.3" glob "7.1.2" - yargs "13.1.0" + yargs "15.3.0" "@angular/platform-browser-dynamic@file:../../dist/packages-dist/platform-browser-dynamic": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/platform-browser@file:../../dist/packages-dist/platform-browser": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@angular/router@file:../../dist/packages-dist/router": - version "9.0.0-rc.1" + version "10.0.0-next.2" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5": version "7.5.5" @@ -1057,31 +1058,31 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@ngtools/webpack@9.0.0-rc.11": - version "9.0.0-rc.11" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-9.0.0-rc.11.tgz#10b5997bec7cf48d1b144c8b4d46ffd0039c522a" - integrity sha512-qeW81ISiO8GVEndOaCYv0k6fzRIxzZs6jrXGl1pcLH1H6qv2mxhA5DA0vC/9TN6wenrS43RGjDIQpp+RvkiLwA== +"@ngtools/webpack@9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-9.0.3.tgz#d05b5a15584909262a4db027919f03ccb074dc11" + integrity sha512-pMIXfq1IJLbvwmkPonGs7nrpuBCXrlZTf9A4OYsMBZcfU8JMn0pRdx7G2+bC9Q/f+uSw2uvPSv76xJXLBOntmA== dependencies: - "@angular-devkit/core" "9.0.0-rc.11" + "@angular-devkit/core" "9.0.3" enhanced-resolve "4.1.1" rxjs "6.5.3" webpack-sources "1.4.3" -"@schematics/angular@9.0.0-rc.11": - version "9.0.0-rc.11" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-9.0.0-rc.11.tgz#d544c0d4e7b3dd59ed56be5183e038ebe06a165e" - integrity sha512-9InC+F71KiPXE0jl7Ow4iPFJ2AZZDbfTM6yWZoYLk3hzTCohAZZciBl00Tfyu2uerGshx8akbJMLySjXtf+q0g== +"@schematics/angular@9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-9.0.3.tgz#8b0fb91fa18dd909001ac0d888479a96810aa640" + integrity sha512-6XSnPW4G7aoKXccg0FTpZ02y/yi9y/bj7swnSL9Z4RRPIvPVapDjB7uJPg8sm8+PTIpcMhEFQrchIqM3LXW4zA== dependencies: - "@angular-devkit/core" "9.0.0-rc.11" - "@angular-devkit/schematics" "9.0.0-rc.11" + "@angular-devkit/core" "9.0.3" + "@angular-devkit/schematics" "9.0.3" -"@schematics/update@0.900.0-rc.11": - version "0.900.0-rc.11" - resolved "https://registry.yarnpkg.com/@schematics/update/-/update-0.900.0-rc.11.tgz#d22df30f13a6f38970b759db61ad84d3f9b03a78" - integrity sha512-nV0oCPzzd0vi2Exo1910rWXwz/RnMc4zF9FxSOCZzsIv+AkwIehhL815OKyjUSCzU9+IM0/o1LKkPPrSWK7QEA== +"@schematics/update@0.900.3": + version "0.900.3" + resolved "https://registry.yarnpkg.com/@schematics/update/-/update-0.900.3.tgz#9141ee2e1b6356e66f6269b92c284c86e4faf065" + integrity sha512-mlRsm3/HM1f/10Wdz4xMYA+mpW3EDCB+whlV5cJ7PGMhjUMaxA9DuWvoP06h05le6XmgnjIEoxL6NJ7CgesHcA== dependencies: - "@angular-devkit/core" "9.0.0-rc.11" - "@angular-devkit/schematics" "9.0.0-rc.11" + "@angular-devkit/core" "9.0.3" + "@angular-devkit/schematics" "9.0.3" "@yarnpkg/lockfile" "1.1.0" ini "1.3.5" npm-package-arg "^7.0.0" @@ -1090,6 +1091,11 @@ semver "6.3.0" semver-intersect "1.4.0" +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + "@types/estree@*": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -1114,15 +1120,11 @@ resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.4.2.tgz#49f672de24043b3c1fb919901fd3cd36f027bc93" integrity sha512-SaSSGOzwUnBEn64c+HTyVTJhRf8F1CXZLnxYx2ww3UrgGBmEEw38RSux2l3fYiT9brVLP67DU5omWA6V9OHI5Q== -"@types/jasmine@3.4.4": - version "3.4.4" - resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.4.4.tgz#be3fbd73e72725edb44e6f7f509cd52912d1550c" - integrity sha512-+/sHcTPyDS1JQacDRRRWb+vNrjBwnD+cKvTaWlxlJ/uOOFvzCkjOwNaqVjYMLfsjzNi0WtDH9RyReDXPG1Cdug== +"@types/jasmine@file:../../node_modules/@types/jasmine": + version "3.5.10" -"@types/jasminewd2@2.0.8": +"@types/jasminewd2@file:../../node_modules/@types/jasminewd2": version "2.0.8" - resolved "https://registry.yarnpkg.com/@types/jasminewd2/-/jasminewd2-2.0.8.tgz#67afe5098d5ef2386073a7b7384b69a840dfe93b" - integrity sha512-d9p31r7Nxk0ZH0U39PTH0hiDlJ+qNVGjlt1ucOoTUptxb2v+Y5VMnsxfwN+i3hK4yQnqBi3FMmoMFcd1JHDxdg== dependencies: "@types/jasmine" "*" @@ -1142,7 +1144,7 @@ integrity sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw== "@types/node@file:../../node_modules/@types/node": - version "12.11.1" + version "12.12.34" "@types/q@^0.0.32": version "0.0.32" @@ -1493,6 +1495,11 @@ ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -1505,6 +1512,14 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -2392,6 +2407,15 @@ cliui@^4.0.0: strip-ansi "^4.0.0" wrap-ansi "^2.0.0" +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -2455,12 +2479,19 @@ color-convert@^1.9.0, color-convert@^1.9.1: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.0.0: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -2723,7 +2754,7 @@ core-js@^3.1.3: integrity sha512-0xmD4vUJRY8nfLyV9zcpC17FtSie5STXzw+HyYw2t8IIvmDnbq7RJUULECCo+NstpJtwK9kx8S+898iyqgeUow== "core-js@file:../../node_modules/core-js": - version "2.5.7" + version "2.6.11" core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -3353,11 +3384,6 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -3889,7 +3915,7 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" -find-up@^4.0.0: +find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== @@ -6323,7 +6349,7 @@ os-homedir@^1.0.0: resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= -os-locale@^3.0.0, os-locale@^3.1.0: +os-locale@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== @@ -7145,7 +7171,7 @@ punycode@^2.1.0: integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== "puppeteer@file:../../node_modules/puppeteer": - version "2.1.0" + version "2.1.1" dependencies: "@types/mime-types" "^2.1.0" debug "^4.1.0" @@ -7623,15 +7649,15 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@6.5.3, "rxjs@file:../../node_modules/rxjs": +rxjs@6.5.3: version "6.5.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.3.tgz#510e26317f4db91a7eb1de77d9dd9ba0a4899a3a" + integrity sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA== dependencies: tslib "^1.9.0" -rxjs@^6.4.0: +rxjs@^6.4.0, "rxjs@file:../../node_modules/rxjs": version "6.5.4" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" - integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== dependencies: tslib "^1.9.0" @@ -8124,6 +8150,11 @@ sourcemap-codec@^1.4.4: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.6.tgz#e30a74f0402bad09807640d39e971090a08ce1e9" integrity sha512-1ZooVLYFxC448piVLBbtOxFcXwnymH9oUF8nRd3CuYDVvkRBxRl6pB4Mtas5a4drtL+E8LDgFkQNcgIw6tc8Hg== +sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + spdx-correct@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" @@ -8310,15 +8341,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" -string-width@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - string-width@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.1.0.tgz#ba846d1daa97c3c596155308063e075ed1c99aff" @@ -8328,6 +8350,15 @@ string-width@^4.1.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^5.2.0" +string-width@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + string.prototype.padend@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz#f3aaef7c1719f170c5eab1c32bf780d96e21f2f0" @@ -8404,6 +8435,13 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -8717,7 +8755,7 @@ tslib@1.10.0, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== "tslib@file:../../node_modules/tslib": - version "1.10.0" + version "1.11.1" tslint@5.18.0: version "5.18.0" @@ -8786,7 +8824,7 @@ typescript@3.6.4: integrity sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg== "typescript@file:../../node_modules/typescript": - version "3.7.4" + version "3.8.3" uglify-js@^3.1.4: version "3.6.1" @@ -9259,6 +9297,15 @@ wrap-ansi@^2.0.0: string-width "^1.0.1" strip-ansi "^3.0.1" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -9332,10 +9379,10 @@ yargs-parser@^11.1.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^13.0.0: - version "13.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" - integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== +yargs-parser@^18.1.0: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -9358,22 +9405,22 @@ yargs@12.0.5: y18n "^3.2.1 || ^4.0.0" yargs-parser "^11.1.1" -yargs@13.1.0: - version "13.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.1.0.tgz#b2729ce4bfc0c584939719514099d8a916ad2301" - integrity sha512-1UhJbXfzHiPqkfXNHYhiz79qM/kZqjTE8yGlEjZa85Q+3+OwcV6NRkV7XOV1W2Eom2bzILeUn55pQYffjVOLAg== +yargs@15.3.0: + version "15.3.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.3.0.tgz#403af6edc75b3ae04bf66c94202228ba119f0976" + integrity sha512-g/QCnmjgOl1YJjGsnUg2SatC7NUYEiLXJqxNOQU9qSpjzGtGXda9b+OKccr1kLTy8BN9yqEyqfq5lxlwdc13TA== dependencies: - cliui "^4.0.0" - find-up "^3.0.0" + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" get-caller-file "^2.0.1" - os-locale "^3.1.0" require-directory "^2.1.1" require-main-filename "^2.0.0" set-blocking "^2.0.0" - string-width "^3.0.0" + string-width "^4.2.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^13.0.0" + yargs-parser "^18.1.0" yauzl@2.4.1: version "2.4.1" diff --git a/packages/localize/src/tools/src/translate/main.ts b/packages/localize/src/tools/src/translate/main.ts index 55fa300e62..9178ed810d 100644 --- a/packages/localize/src/tools/src/translate/main.ts +++ b/packages/localize/src/tools/src/translate/main.ts @@ -10,8 +10,7 @@ import * as glob from 'glob'; import {resolve} from 'path'; import * as yargs from 'yargs'; -import {Diagnostics} from '../diagnostics'; -import {MissingTranslationStrategy} from '../source_file_utils'; +import {DiagnosticHandlingStrategy, Diagnostics} from '../diagnostics'; import {AssetTranslationHandler} from './asset_files/asset_translation_handler'; import {getOutputPathFn, OutputPathFn} from './output_path'; import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler'; @@ -51,7 +50,10 @@ if (require.main === module) { array: true, describe: 'A list of paths to the translation files to load, either absolute or relative to the current working directory.\n' + - 'E.g. "-t src/locale/messages.en.xlf src/locale/messages.fr.xlf src/locale/messages.de.xlf".', + 'E.g. `-t src/locale/messages.en.xlf src/locale/messages.fr.xlf src/locale/messages.de.xlf`.\n' + + 'If you want to merge multiple translation files for each locale, then provide the list of files in an array.\n' + + 'Note that the arrays must be in double quotes if you include any whitespace within the array.\n' + + 'E.g. `-t "[src/locale/messages.en.xlf, src/locale/messages-2.en.xlf]" [src/locale/messages.fr.xlf,src/locale/messages-2.fr.xlf]`', }) .option('target-locales', { @@ -74,6 +76,14 @@ if (require.main === module) { choices: ['error', 'warning', 'ignore'], default: 'warning', }) + + .option('d', { + alias: 'duplicateTranslation', + describe: 'How to handle duplicate translations.', + choices: ['error', 'warning', 'ignore'], + default: 'warning', + }) + .strict() .help() .parse(args); @@ -81,10 +91,11 @@ if (require.main === module) { const sourceRootPath = options['r']; const sourceFilePaths = glob.sync(options['s'], {absolute: true, cwd: sourceRootPath, nodir: true}); - const translationFilePaths: string[] = options['t']; + const translationFilePaths: (string|string[])[] = convertArraysFromArgs(options['t']); const outputPathFn = getOutputPathFn(options['o']); const diagnostics = new Diagnostics(); const missingTranslation: DiagnosticHandlingStrategy = options['m']; + const duplicateTranslation: DiagnosticHandlingStrategy = options['d']; const sourceLocale: string|undefined = options['l']; const translationFileLocales: string[] = options['target-locales'] || []; @@ -96,6 +107,7 @@ if (require.main === module) { outputPathFn, diagnostics, missingTranslation, + duplicateTranslation, sourceLocale }); @@ -116,10 +128,29 @@ export interface TranslateFilesOptions { /** * An array of paths to the translation files to load, either absolute or relative to the current * working directory. + * + * For each locale to be translated, there should be an element in `translationFilePaths`. + * Each element is either an absolute path to the translation file, or an array of absolute paths + * to translation files, for that locale. + * + * If the element contains more than one translation file, then the translations are merged. + * + * If allowed by the `duplicateTranslation` property, when more than one translation has the same + * message id, the message from the earlier translation file in the array is used. + * + * For example, if the files are `[app.xlf, lib-1.xlf, lib-2.xlif]` then a message that appears in + * `app.xlf` will override the same message in `lib-1.xlf` or `lib-2.xlf`. */ - translationFilePaths: string[]; + translationFilePaths: (string|string[])[]; /** * A collection of the target locales for the translation files. + * + * If there is a locale provided in `translationFileLocales` then this is used rather than a + * locale extracted from the file itself. + * If there is neither a provided locale nor a locale parsed from the file, then an error is + * thrown. + * If there are both a provided locale and a locale parsed from the file, and they are not the + * same, then a warning is reported. */ translationFileLocales: (string|undefined)[]; /** @@ -135,6 +166,10 @@ export interface TranslateFilesOptions { * How to handle missing translations. */ missingTranslation: DiagnosticHandlingStrategy; + /** + * How to handle duplicate translations. + */ + duplicateTranslation: DiagnosticHandlingStrategy; /** * The locale of the source files. * If this is provided then a copy of the application will be created with no translation but just @@ -151,6 +186,7 @@ export function translateFiles({ outputPathFn, diagnostics, missingTranslation, + duplicateTranslation, sourceLocale }: TranslateFilesOptions) { const translationLoader = new TranslationLoader( @@ -160,7 +196,7 @@ export function translateFiles({ new XtbTranslationParser(), new SimpleJsonTranslationParser(), ], - diagnostics); + duplicateTranslation, diagnostics); const resourceProcessor = new Translator( [ @@ -169,8 +205,25 @@ export function translateFiles({ ], diagnostics); - const translations = translationLoader.loadBundles(translationFilePaths, translationFileLocales); + // Convert all the `translationFilePaths` elements to arrays. + const translationFilePathsArrays = + translationFilePaths.map(filePaths => Array.isArray(filePaths) ? filePaths : [filePaths]); + + const translations = + translationLoader.loadBundles(translationFilePathsArrays, translationFileLocales); sourceRootPath = resolve(sourceRootPath); resourceProcessor.translateFiles( sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale); } + +/** + * Parse each of the given string `args` and convert it to an array if it is of the form + * `[abc, def, ghi]`, i.e. it is enclosed in square brackets with comma delimited items. + * @param args The string to potentially convert to arrays. + */ +function convertArraysFromArgs(args: string[]): (string|string[])[] { + return args.map( + arg => (arg.startsWith('[') && arg.endsWith(']')) ? + arg.slice(1, -1).split(',').map(arg => arg.trim()) : + arg); +} \ No newline at end of file diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts b/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts index 58e51b7c47..a7cf016f82 100644 --- a/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts +++ b/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts @@ -5,9 +5,10 @@ * 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 {DiagnosticHandlingStrategy, Diagnostics} from '../../diagnostics'; import {FileUtils} from '../../file_utils'; import {TranslationBundle} from '../translator'; + import {TranslationParser} from './translation_parsers/translation_parser'; /** @@ -16,61 +17,108 @@ import {TranslationParser} from './translation_parsers/translation_parser'; export class TranslationLoader { constructor( private translationParsers: TranslationParser[], + private duplicateTranslation: DiagnosticHandlingStrategy, /** @deprecated */ private diagnostics?: Diagnostics) {} /** * Load and parse the translation files into a collection of `TranslationBundles`. * - * If there is a locale provided in `translationFileLocales` then this is used rather than the + * @param translationFilePaths An array, per locale, of absolute paths to translation files. + * + * For each locale to be translated, there is an element in `translationFilePaths`. Each element + * is an array of absolute paths to translation files for that locale. + * If the array contains more than one translation file, then the translations are merged. + * If allowed by the `duplicateTranslation` property, when more than one translation has the same + * message id, the message from the earlier translation file in the array is used. + * For example, if the files are `[app.xlf, lib-1.xlf, lib-2.xlif]` then a message that appears in + * `app.xlf` will override the same message in `lib-1.xlf` or `lib-2.xlf`. + * + * @param translationFileLocales An array of locales for each of the translation files. + * + * If there is a locale provided in `translationFileLocales` then this is used rather than a * locale extracted from the file itself. * If there is neither a provided locale nor a locale parsed from the file, then an error is * thrown. * If there are both a provided locale and a locale parsed from the file, and they are not the - * same, then a warning is reported . - * - * @param translationFilePaths An array of absolute paths to the translation files. - * @param translationFileLocales An array of locales for each of the translation files. + * same, then a warning is reported. */ - loadBundles(translationFilePaths: string[], translationFileLocales: (string|undefined)[]): + loadBundles(translationFilePaths: string[][], translationFileLocales: (string|undefined)[]): TranslationBundle[] { - return translationFilePaths.map((filePath, index) => { - const fileContents = FileUtils.readFile(filePath); - for (const translationParser of this.translationParsers) { - const result = translationParser.canParse(filePath, fileContents); - if (!result) { - continue; - } - - const {locale: parsedLocale, translations, diagnostics} = - translationParser.parse(filePath, fileContents, result); - if (diagnostics.hasErrors) { - throw new Error(diagnostics.formatDiagnostics( - `The translation file "${filePath}" could not be parsed.`)); - } - - const providedLocale = translationFileLocales[index]; - const locale = providedLocale || parsedLocale; - if (locale === undefined) { - throw new Error(`The translation file "${ - filePath}" does not contain a target locale and no explicit locale was provided for this file.`); - } - - if (parsedLocale !== undefined && providedLocale !== undefined && - parsedLocale !== providedLocale) { - diagnostics.warn( - `The provided locale "${providedLocale}" does not match the target locale "${ - parsedLocale}" found in the translation file "${filePath}".`); - } - - // If we were passed a diagnostics object then copy the messages over to it. - if (this.diagnostics) { - this.diagnostics.merge(diagnostics); - } - - return {locale, translations, diagnostics}; - } - throw new Error( - `There is no "TranslationParser" that can parse this translation file: ${filePath}.`); + return translationFilePaths.map((filePaths, index) => { + const providedLocale = translationFileLocales[index]; + return this.mergeBundles(filePaths, providedLocale); }); } + + /** + * Load all the translations from the file at the given `filePath`. + */ + private loadBundle(filePath: string, providedLocale: string|undefined): TranslationBundle { + const fileContents = FileUtils.readFile(filePath); + for (const translationParser of this.translationParsers) { + const result = translationParser.canParse(filePath, fileContents); + if (!result) { + continue; + } + + const {locale: parsedLocale, translations, diagnostics} = + translationParser.parse(filePath, fileContents, result); + if (diagnostics.hasErrors) { + throw new Error(diagnostics.formatDiagnostics( + `The translation file "${filePath}" could not be parsed.`)); + } + + const locale = providedLocale || parsedLocale; + if (locale === undefined) { + throw new Error(`The translation file "${ + filePath}" does not contain a target locale and no explicit locale was provided for this file.`); + } + + if (parsedLocale !== undefined && providedLocale !== undefined && + parsedLocale !== providedLocale) { + diagnostics.warn( + `The provided locale "${providedLocale}" does not match the target locale "${ + parsedLocale}" found in the translation file "${filePath}".`); + } + + // If we were passed a diagnostics object then copy the messages over to it. + if (this.diagnostics) { + this.diagnostics.merge(diagnostics); + } + + return {locale, translations, diagnostics}; + } + throw new Error( + `There is no "TranslationParser" that can parse this translation file: ${filePath}.`); + } + + /** + * There is more than one `filePath` for this locale, so load each as a bundle and then merge them + * all together. + */ + private mergeBundles(filePaths: string[], providedLocale: string|undefined): TranslationBundle { + const bundles = filePaths.map(filePath => this.loadBundle(filePath, providedLocale)); + const bundle = bundles[0]; + for (let i = 1; i < bundles.length; i++) { + const nextBundle = bundles[i]; + if (nextBundle.locale !== bundle.locale) { + if (this.diagnostics) { + const previousFiles = filePaths.slice(0, i).map(f => `"${f}"`).join(', '); + this.diagnostics.warn(`When merging multiple translation files, the target locale "${ + nextBundle.locale}" found in "${filePaths[i]}" does not match the target locale "${ + bundle.locale}" found in earlier files [${previousFiles}].`); + } + } + Object.keys(nextBundle.translations).forEach(messageId => { + if (bundle.translations[messageId] !== undefined) { + this.diagnostics?.add( + this.duplicateTranslation, + `Duplicate translations for message "${messageId}" when merging "${filePaths[i]}".`); + } else { + bundle.translations[messageId] = nextBundle.translations[messageId]; + } + }); + } + return bundle; + } } diff --git a/packages/localize/src/tools/test/translate/integration/locales/messages-extra.de.json b/packages/localize/src/tools/test/translate/integration/locales/messages-extra.de.json new file mode 100644 index 0000000000..b0edfd5010 --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/locales/messages-extra.de.json @@ -0,0 +1,6 @@ +{ + "locale": "de", + "translations": { + "customExtra": "Auf wiedersehen, {$PH}!" + } +} \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/integration/main_spec.ts b/packages/localize/src/tools/test/translate/integration/main_spec.ts index 07a9835196..94a9a30a11 100644 --- a/packages/localize/src/tools/test/translate/integration/main_spec.ts +++ b/packages/localize/src/tools/test/translate/integration/main_spec.ts @@ -35,7 +35,8 @@ describe('translateFiles()', () => { ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), translationFileLocales: [], diagnostics, - missingTranslation: 'error' + missingTranslation: 'error', + duplicateTranslation: 'error', }); expect(diagnostics.messages.length).toEqual(0); @@ -71,6 +72,7 @@ describe('translateFiles()', () => { translationFileLocales: [], diagnostics, missingTranslation: 'error', + duplicateTranslation: 'error', }); expect(diagnostics.messages.length).toEqual(0); @@ -98,6 +100,7 @@ describe('translateFiles()', () => { translationFileLocales: ['xde', undefined, 'fr'], diagnostics, missingTranslation: 'error', + duplicateTranslation: 'error', }); expect(diagnostics.messages.length).toEqual(1); @@ -118,6 +121,40 @@ describe('translateFiles()', () => { .toEqual(`var name="World";var message="Ciao, "+name+"!";`); }); + it('should merge translation files, if more than one provided, and translate source-code', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: resolve(__dirname, 'test_files'), + sourceFilePaths: resolveAll(__dirname + '/test_files', ['test-extra.js']), + outputPathFn, + translationFilePaths: resolveAllRecursive( + __dirname + '/locales', + [['messages.de.json', 'messages-extra.de.json'], 'messages.es.xlf']), + translationFileLocales: [], + diagnostics, + missingTranslation: 'error', + duplicateTranslation: 'error', + }); + + expect(diagnostics.messages.length).toEqual(1); + // There is no "extra" translation in the `es` locale translation file. + expect(diagnostics.messages[0]).toEqual({ + type: 'error', + message: 'No translation found for "customExtra" ("Goodbye, {$PH}!").' + }); + + // The `de` locale translates the `customExtra` message because it is in the + // `messages-extra.de.json` file that was merged. + expect(FileUtils.readFile(resolve(testDir, 'de', 'test-extra.js'))) + .toEqual( + `var name="World";var message="Guten Tag, "+name+"!";var message="Auf wiedersehen, "+name+"!";`); + // The `es` locale does not translate `customExtra` because there is no translation for it. + expect(FileUtils.readFile(resolve(testDir, 'es', 'test-extra.js'))) + .toEqual( + `var name="World";var message="Hola, "+name+"!";var message="Goodbye, "+name+"!";`); + }); + it('should transform and/or copy files to the destination folders', () => { const diagnostics = new Diagnostics(); const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); @@ -132,6 +169,7 @@ describe('translateFiles()', () => { translationFileLocales: [], diagnostics, missingTranslation: 'error', + duplicateTranslation: 'error', }); expect(diagnostics.messages.length).toEqual(0); @@ -167,3 +205,8 @@ describe('translateFiles()', () => { function resolveAll(rootPath: string, paths: string[]): string[] { return paths.map(p => resolve(rootPath, p)); } + +function resolveAllRecursive(rootPath: string, paths: (string|string[])[]): (string|string[])[] { + return paths.map( + p => Array.isArray(p) ? p.map(p2 => resolve(rootPath, p2)) : resolve(rootPath, p)); +} diff --git a/packages/localize/src/tools/test/translate/integration/test_files/test-extra.js b/packages/localize/src/tools/test/translate/integration/test_files/test-extra.js new file mode 100644 index 0000000000..5662eb3e5c --- /dev/null +++ b/packages/localize/src/tools/test/translate/integration/test_files/test-extra.js @@ -0,0 +1,3 @@ +var name = 'World'; +var message = $localize`Hello, ${name}!`; +var message = $localize`:@@customExtra:Goodbye, ${name}!`; \ No newline at end of file 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 index 48bd2be07b..147a12185c 100644 --- 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 @@ -5,24 +5,27 @@ * 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 {ɵParsedTranslation, ɵparseTranslation} from '@angular/localize'; -import {Diagnostics} from '../../../src/diagnostics'; +import {DiagnosticHandlingStrategy, Diagnostics} from '../../../src/diagnostics'; import {FileUtils} from '../../../src/file_utils'; import {TranslationLoader} from '../../../src/translate/translation_files/translation_loader'; import {TranslationParser} from '../../../src/translate/translation_files/translation_parsers/translation_parser'; describe('TranslationLoader', () => { describe('loadBundles()', () => { + const alwaysCanParse = () => true; + const neverCanParse = () => false; + beforeEach(() => { spyOn(FileUtils, 'readFile').and.returnValues('english messages', 'french messages'); }); - it('should `canParse()` and `parse()` for each file', () => { + it('should call `canParse()` and `parse()` for each file', () => { const diagnostics = new Diagnostics(); - const parser = new MockTranslationParser(true, 'fr'); - const loader = new TranslationLoader([parser], diagnostics); - loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []); + const parser = new MockTranslationParser(alwaysCanParse, 'fr'); + const loader = new TranslationLoader([parser], 'error', diagnostics); + 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)', @@ -33,11 +36,11 @@ describe('TranslationLoader', () => { it('should stop at the first parser that can parse each file', () => { const diagnostics = new Diagnostics(); - const parser1 = new MockTranslationParser(false); - const parser2 = new MockTranslationParser(true, 'fr'); - const parser3 = new MockTranslationParser(true, 'en'); - const loader = new TranslationLoader([parser1, parser2, parser3], diagnostics); - loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []); + const parser1 = new MockTranslationParser(neverCanParse); + const parser2 = new MockTranslationParser(alwaysCanParse, 'fr'); + const parser3 = new MockTranslationParser(alwaysCanParse, 'en'); + const loader = new TranslationLoader([parser1, parser2, parser3], 'error', diagnostics); + 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)', @@ -53,10 +56,10 @@ describe('TranslationLoader', () => { it('should return locale and translations parsed from each file', () => { const translations = {}; const diagnostics = new Diagnostics(); - const parser = new MockTranslationParser(true, 'pl', translations); - const loader = new TranslationLoader([parser], diagnostics); - const result = - loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []); + const parser = new MockTranslationParser(alwaysCanParse, 'pl', translations); + const loader = new TranslationLoader([parser], 'error', diagnostics); + const result = loader.loadBundles( + [['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], []); expect(result).toEqual([ {locale: 'pl', translations, diagnostics: new Diagnostics()}, {locale: 'pl', translations, diagnostics: new Diagnostics()}, @@ -66,23 +69,70 @@ describe('TranslationLoader', () => { it('should return the provided locale if there is no parsed locale', () => { const translations = {}; const diagnostics = new Diagnostics(); - const parser = new MockTranslationParser(true, undefined, translations); - const loader = new TranslationLoader([parser], diagnostics); + const parser = new MockTranslationParser(alwaysCanParse, undefined, translations); + const loader = new TranslationLoader([parser], 'error', diagnostics); const result = loader.loadBundles( - ['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], ['en', 'fr']); + [['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], ['en', 'fr']); expect(result).toEqual([ {locale: 'en', translations, diagnostics: new Diagnostics()}, {locale: 'fr', translations, diagnostics: new Diagnostics()}, ]); }); + it('should merge multiple translation files, if given, for a each locale', () => { + const diagnostics = new Diagnostics(); + const parser1 = new MockTranslationParser( + f => f.includes('messages.fr'), 'fr', {'a': ɵparseTranslation('A')}); + const parser2 = new MockTranslationParser( + f => f.includes('extra.fr'), 'fr', {'b': ɵparseTranslation('B')}); + const loader = new TranslationLoader([parser1, parser2], 'error', diagnostics); + const result = + loader.loadBundles([['/src/locale/messages.fr.xlf', '/src/locale/extra.fr.xlf']], []); + expect(result).toEqual([ + { + locale: 'fr', + translations: {'a': ɵparseTranslation('A'), 'b': ɵparseTranslation('B')}, + diagnostics: new Diagnostics(), + }, + ]); + }); + + const allDiagnosticModes: DiagnosticHandlingStrategy[] = ['ignore', 'warning', 'error']; + allDiagnosticModes.forEach( + mode => it( + `should ${mode} on duplicate messages when merging multiple translation files`, () => { + const diagnostics = new Diagnostics(); + const parser1 = new MockTranslationParser( + f => f.includes('messages.fr'), 'fr', {'a': ɵparseTranslation('A')}); + const parser2 = new MockTranslationParser( + f => f.includes('extra.fr'), 'fr', {'a': ɵparseTranslation('B')}); + const loader = new TranslationLoader([parser1, parser2], mode, diagnostics); + const result = loader.loadBundles( + [['/src/locale/messages.fr.xlf', '/src/locale/extra.fr.xlf']], []); + expect(result).toEqual([ + { + locale: 'fr', + translations: {'a': ɵparseTranslation('A')}, + diagnostics: jasmine.any(Diagnostics), + }, + ]); + + if (mode === 'error' || mode === 'warning') { + expect(diagnostics.messages).toEqual([{ + type: mode, + message: + `Duplicate translations for message "a" when merging "/src/locale/extra.fr.xlf".` + }]); + } + })); + it('should warn if the provided locales do not match the parsed locales', () => { const translations = {}; const diagnostics = new Diagnostics(); - const parser = new MockTranslationParser(true, 'pl', translations); - const loader = new TranslationLoader([parser], diagnostics); + const parser = new MockTranslationParser(alwaysCanParse, 'pl', translations); + const loader = new TranslationLoader([parser], 'error', diagnostics); loader.loadBundles( - ['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], [undefined, 'FR']); + [['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], [undefined, 'FR']); expect(diagnostics.messages.length).toEqual(1); expect(diagnostics.messages) .toContain( @@ -94,23 +144,58 @@ describe('TranslationLoader', () => { ); }); + it('should warn on differing target locales when merging multiple translation files', () => { + const diagnostics = new Diagnostics(); + const parser1 = new MockTranslationParser( + f => f.includes('messages-1.fr'), 'fr', {'a': ɵparseTranslation('A')}); + const parser2 = new MockTranslationParser( + f => f.includes('messages-2.fr'), 'fr', {'b': ɵparseTranslation('B')}); + const parser3 = new MockTranslationParser( + f => f.includes('messages.de'), 'de', {'c': ɵparseTranslation('C')}); + const loader = new TranslationLoader([parser1, parser2, parser3], 'error', diagnostics); + const result = loader.loadBundles( + [[ + '/src/locale/messages-1.fr.xlf', '/src/locale/messages-2.fr.xlf', + '/src/locale/messages.de.xlf' + ]], + []); + expect(result).toEqual([ + { + locale: 'fr', + translations: { + 'a': ɵparseTranslation('A'), + 'b': ɵparseTranslation('B'), + 'c': ɵparseTranslation('C') + }, + diagnostics: jasmine.any(Diagnostics), + }, + ]); + + expect(diagnostics.messages).toEqual([{ + type: 'warning', + message: + `When merging multiple translation files, the target locale "de" found in "/src/locale/messages.de.xlf" ` + + `does not match the target locale "fr" found in earlier files ["/src/locale/messages-1.fr.xlf", "/src/locale/messages-2.fr.xlf"].` + }]); + }); + it('should throw an error if there is no provided nor parsed target locale', () => { const translations = {}; const diagnostics = new Diagnostics(); - const parser = new MockTranslationParser(true, undefined, translations); - const loader = new TranslationLoader([parser], diagnostics); - expect(() => loader.loadBundles(['/src/locale/messages.en.xlf'], [])) + const parser = new MockTranslationParser(alwaysCanParse, undefined, translations); + const loader = new TranslationLoader([parser], 'error', diagnostics); + expect(() => loader.loadBundles([['/src/locale/messages.en.xlf']], [])) .toThrowError( 'The translation file "/src/locale/messages.en.xlf" does not contain a target locale and no explicit locale was provided for this file.'); }); it('should error if none of the parsers can parse the file', () => { const diagnostics = new Diagnostics(); - const parser = new MockTranslationParser(false); - const loader = new TranslationLoader([parser], diagnostics); + const parser = new MockTranslationParser(neverCanParse); + const loader = new TranslationLoader([parser], 'error', diagnostics); expect( () => loader.loadBundles( - ['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], [])) + [['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], [])) .toThrowError( 'There is no "TranslationParser" that can parse this translation file: /src/locale/messages.en.xlf.'); }); @@ -120,12 +205,12 @@ describe('TranslationLoader', () => { class MockTranslationParser implements TranslationParser { log: string[] = []; constructor( - private _canParse: boolean = true, private _locale?: string, + private _canParse: (filePath: string) => boolean, private _locale?: string, private _translations: Record = {}) {} canParse(filePath: string, fileContents: string) { this.log.push(`canParse(${filePath}, ${fileContents})`); - return this._canParse; + return this._canParse(filePath); } parse(filePath: string, fileContents: string) {