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
This commit is contained in:
Pete Bacon Darwin 2020-04-21 19:58:58 +01:00 committed by Alex Rickabaugh
parent 3cb9b43851
commit 72f534f7f8
15 changed files with 528 additions and 224 deletions

View File

@ -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)

View File

@ -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();

View File

@ -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();

View File

@ -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"
}
}
}

View File

@ -1,25 +1,10 @@
<!--The content below is only a placeholder and can be replaced.-->
<div style="text-align:center">
<div>
<h1 i18n="some:description">
Hello {{ title }}!
</h1>
<p id="message">{{ message }}</p>
<img width="300" alt="Angular Logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==">
<p id="extra">{{extra}}</p>
</div>
<p id="locale">{{ locale }}</p>
<p id="pipe">{{ 1 | percent }} awesome</p>
<p id="date">{{ jan | date : 'LLLL' }}</p>
<h2>Here are some links to help you start: </h2>
<ul>
<li>
<h2><a target="_blank" rel="noopener" href="https://angular.io/tutorial">Tour of Heroes</a></h2>
</li>
<li>
<h2><a target="_blank" rel="noopener" href="https://angular.io/cli">CLI Documentation</a></h2>
</li>
<li>
<h2><a target="_blank" rel="noopener" href="https://blog.angular.io/">Angular blog</a></h2>
</li>
</ul>
<p id="date">{{ jan | date : 'LLLL' }}</p>

View File

@ -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`;
}

View File

@ -0,0 +1,6 @@
{
"locale": "de",
"translations": {
"custom": "Zusätzliche Nachricht"
}
}

View File

@ -0,0 +1,6 @@
{
"locale": "fr",
"translations": {
"custom": "Message supplémentaire"
}
}

View File

@ -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"

View File

@ -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);
}

View File

@ -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<any>[],
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;
}
}

View File

@ -0,0 +1,6 @@
{
"locale": "de",
"translations": {
"customExtra": "Auf wiedersehen, {$PH}!"
}
}

View File

@ -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));
}

View File

@ -0,0 +1,3 @@
var name = 'World';
var message = $localize`Hello, ${name}!`;
var message = $localize`:@@customExtra:Goodbye, ${name}!`;

View File

@ -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<string, ɵParsedTranslation> = {}) {}
canParse(filePath: string, fileContents: string) {
this.log.push(`canParse(${filePath}, ${fileContents})`);
return this._canParse;
return this._canParse(filePath);
}
parse(filePath: string, fileContents: string) {