feat(ivy): i18n - support source locale inlining (#33101)

Add a new flag to `localize-translate` that allows the
source locale to be specified. When this locale is
provided an extra copy of the files is made for this
locale where the is no translation but all the calls to
`$localize` are stripped out.

Resolves FW-1623

PR Close #33101
This commit is contained in:
Pete Bacon Darwin 2019-10-11 13:42:43 +01:00 committed by Miško Hevery
parent e409ed0eab
commit f433d6604b
11 changed files with 172 additions and 37 deletions

View File

@ -156,6 +156,10 @@
"devServerTarget": "",
"protractorConfig": "e2e/legacy/protractor.conf.js"
},
"translated-en": {
"devServerTarget": "",
"protractorConfig": "e2e/en/protractor.conf.js"
},
"translated-fr": {
"devServerTarget": "",
"protractorConfig": "e2e/fr/protractor.conf.js"

View File

@ -0,0 +1,15 @@
import {AppPage} from '../app.po';
describe('cli-hello-world-ivy App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
page.navigateTo();
});
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.'); });
});

View File

@ -0,0 +1,5 @@
const {config} = require('../protractor.conf');
exports.config = {
...config,
specs: ['./app.e2e-spec.ts'],
};

View File

@ -11,9 +11,9 @@
"start": "ng serve",
"pretest": "ng version",
"test": "ng test --progress=false --watch=false && yarn e2e --configuration=ci && yarn e2e --configuration=ci-production && yarn translated:test && yarn translated:legacy:test",
"translate": "localize-translate -r \"dist/\" -s \"**/*\" -t \"src/locales/messages.*\" -o \"tmp/translations/{{LOCALE}}\"",
"translate": "localize-translate -r \"dist/\" -s \"**/*\" -l \"en-US\" -t \"src/locales/messages.*\" -o \"tmp/translations/{{LOCALE}}\"",
"translated:test": "yarn build && yarn translate && yarn translated:fr:e2e && yarn translated:de:e2e",
"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",
"translated:fr:e2e": "npm-run-all -p -r translated:fr:serve \"ng e2e --configuration=translated-fr --webdriver-update=false\"",
@ -21,6 +21,9 @@
"translated:de:serve": "serve tmp/translations/de --listen 4200",
"translated:de:e2e": "npm-run-all -p -r translated:de:serve \"ng e2e --configuration=translated-de --webdriver-update=false\"",
"translated:en:serve": "serve tmp/translations/en-US --listen 4200",
"translated:en:e2e": "npm-run-all -p -r translated:en:serve \"ng e2e --configuration=translated-en --webdriver-update=false\"",
"translated:legacy:test": "yarn translated:legacy:extract-and-update && ng build --configuration=translated-legacy && yarn translated:legacy:translate && yarn translated:legacy:e2e",
"translated:legacy:extract-and-update": "ng xi18n && sed -i.bak -e 's/source>/target>'/ -e 's/Hello/Bonjour/' -e 's/source-language=\"en-US\"/source-language=\"en-US\" target-language=\"legacy\"/' tmp/legacy-locales/messages.legacy.xlf",
"translated:legacy:translate": "localize-translate -r \"dist/\" -s \"**/*\" -t \"tmp/legacy-locales/messages.legacy.xlf\" -o \"tmp/translations/{{LOCALE}}\"",

View File

@ -19,7 +19,7 @@ export class AssetTranslationHandler implements TranslationHandler {
canTranslate(_relativeFilePath: string, _contents: Buffer): boolean { return true; }
translate(
diagnostics: Diagnostics, _sourceRoot: string, relativeFilePath: string, contents: Buffer,
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void {
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void {
for (const translation of translations) {
try {
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents);
@ -27,5 +27,12 @@ export class AssetTranslationHandler implements TranslationHandler {
diagnostics.error(e.message);
}
}
if (sourceLocale !== undefined) {
try {
FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents);
} catch (e) {
diagnostics.error(e.message);
}
}
}
}

View File

@ -38,6 +38,12 @@ if (require.main === module) {
'A glob pattern indicating what files to translate, relative to the `root` path. E.g. `bundles/**/*`.',
})
.option('l', {
alias: 'source-locale',
describe:
'The source locale of the application. If this is provided then a copy of the application will be created with no translation but just the `$localize` calls stripped out.',
})
.option('t', {
alias: 'translations',
required: true,
@ -66,9 +72,10 @@ if (require.main === module) {
const outputPathFn = getOutputPathFn(options['o']);
const diagnostics = new Diagnostics();
const missingTranslation: MissingTranslationStrategy = options['m'];
const sourceLocale: string|undefined = options['l'];
translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, diagnostics,
missingTranslation});
missingTranslation, sourceLocale});
diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`));
process.exit(diagnostics.hasErrors ? 1 : 0);
@ -81,10 +88,12 @@ export interface TranslateFilesOptions {
outputPathFn: OutputPathFn;
diagnostics: Diagnostics;
missingTranslation: MissingTranslationStrategy;
sourceLocale?: string;
}
export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn,
diagnostics, missingTranslation}: TranslateFilesOptions) {
diagnostics, missingTranslation,
sourceLocale}: TranslateFilesOptions) {
const translationLoader = new TranslationLoader([
new Xliff2TranslationParser(),
new Xliff1TranslationParser(),
@ -100,5 +109,6 @@ export function translateFiles({sourceRootPath, sourceFilePaths, translationFile
const translations = translationLoader.loadBundles(translationFilePaths);
sourceRootPath = resolve(sourceRootPath);
resourceProcessor.translateFiles(sourceFilePaths, sourceRootPath, outputPathFn, translations);
resourceProcessor.translateFiles(
sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale);
}

View File

@ -5,7 +5,9 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize/private';
import {parseSync, transformFromAstSync} from '@babel/core';
import {File, Program} from '@babel/types';
import {extname, join} from 'path';
import {Diagnostics} from '../../diagnostics';
@ -17,20 +19,23 @@ import {makeEs2015TranslatePlugin} from './es2015_translate_plugin';
import {makeEs5TranslatePlugin} from './es5_translate_plugin';
import {TranslatePluginOptions} from './source_file_utils';
/**
* Translate a file by inlining all messages tagged by `$localize` with the appropriate translated
* message.
*/
export class SourceFileTranslationHandler implements TranslationHandler {
private sourceLocaleOptions:
TranslatePluginOptions = {...this.translationOptions, missingTranslation: 'ignore'};
constructor(private translationOptions: TranslatePluginOptions = {}) {}
canTranslate(relativeFilePath: string, contents: Buffer): boolean {
canTranslate(relativeFilePath: string, _contents: Buffer): boolean {
return extname(relativeFilePath) === '.js';
}
translate(
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer,
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void {
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void {
const sourceCode = contents.toString('utf8');
// A short-circuit check to avoid parsing the file into an AST if it does not contain any
// `$localize` identifiers.
@ -38,33 +43,49 @@ export class SourceFileTranslationHandler implements TranslationHandler {
for (const translation of translations) {
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents);
}
if (sourceLocale !== undefined) {
FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents);
}
} else {
const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath});
if (!ast) {
diagnostics.error(`Unable to parse source file: ${join(sourceRoot, relativeFilePath)}`);
return;
}
// Output a translated copy of the file for each locale.
for (const translationBundle of translations) {
const translated = transformFromAstSync(ast, sourceCode, {
compact: true,
generatorOpts: {minified: true},
plugins: [
makeEs2015TranslatePlugin(
diagnostics, translationBundle.translations, this.translationOptions),
makeEs5TranslatePlugin(
diagnostics, translationBundle.translations, this.translationOptions),
],
filename: relativeFilePath,
});
if (translated && translated.code) {
FileUtils.writeFile(
outputPathFn(translationBundle.locale, relativeFilePath), translated.code);
} else {
diagnostics.error(
`Unable to translate source file: ${join(sourceRoot, relativeFilePath)}`);
return;
}
this.translateFile(
diagnostics, ast, translationBundle, sourceRoot, relativeFilePath, outputPathFn,
this.translationOptions);
}
if (sourceLocale !== undefined) {
// Also output a copy of the file for the source locale.
// There will be no translations - by definition - so we "ignore" `missingTranslations`.
this.translateFile(
diagnostics, ast, {locale: sourceLocale, translations: {}}, sourceRoot,
relativeFilePath, outputPathFn, this.sourceLocaleOptions);
}
}
}
private translateFile(
diagnostics: Diagnostics, ast: File|Program, translationBundle: TranslationBundle,
sourceRoot: string, filename: string, outputPathFn: OutputPathFn,
options: TranslatePluginOptions) {
const translated = transformFromAstSync(ast, undefined, {
compact: true,
generatorOpts: {minified: true},
plugins: [
makeEs2015TranslatePlugin(diagnostics, translationBundle.translations, options),
makeEs5TranslatePlugin(diagnostics, translationBundle.translations, options),
],
filename,
});
if (translated && translated.code) {
FileUtils.writeFile(outputPathFn(translationBundle.locale, filename), translated.code);
} else {
diagnostics.error(`Unable to translate source file: ${join(sourceRoot, filename)}`);
return;
}
}
}

View File

@ -51,10 +51,13 @@ export interface TranslationHandler {
* @param outputPathFn A function that returns an absolute path where the output file should be
* written.
* @param translations A collection of translations to apply to this file.
* @param sourceLocale The locale of the original application source. If provided then an
* additional copy of the application is created under this locale just with the `$localize` calls
* stripped out.
*/
translate(
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer,
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void;
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void;
}
/**
@ -66,14 +69,15 @@ export class Translator {
translateFiles(
inputPaths: string[], rootPath: string, outputPathFn: OutputPathFn,
translations: TranslationBundle[]): void {
translations: TranslationBundle[], sourceLocale?: string): void {
inputPaths.forEach(inputPath => {
const contents = FileUtils.readFileBuffer(inputPath);
const relativePath = relative(rootPath, inputPath);
for (const resourceHandler of this.resourceHandlers) {
if (resourceHandler.canTranslate(relativePath, contents)) {
return resourceHandler.translate(
this.diagnostics, rootPath, relativePath, contents, outputPathFn, translations);
this.diagnostics, rootPath, relativePath, contents, outputPathFn, translations,
sourceLocale);
}
}
this.diagnostics.error(`Unable to handle resource file: ${inputPath}`);

View File

@ -8,6 +8,7 @@
import {Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils';
import {AssetTranslationHandler} from '../../../src/translate/asset_files/asset_translation_handler';
import {TranslationBundle} from '../../../src/translate/translator';
describe('AssetTranslationHandler', () => {
describe('canTranslate()', () => {
@ -37,6 +38,20 @@ describe('AssetTranslationHandler', () => {
expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/en/relative/path', contents);
expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/fr/relative/path', contents);
});
it('should write the translated file to the source locale if provided', () => {
const diagnostics = new Diagnostics();
const handler = new AssetTranslationHandler();
const translations: TranslationBundle[] = [];
const contents = Buffer.from('contents');
const sourceLocale = 'en-US';
handler.translate(
diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations,
sourceLocale);
expect(FileUtils.writeFile)
.toHaveBeenCalledWith('/translations/en-US/relative/path', contents);
});
});
});

View File

@ -8,6 +8,7 @@
import {Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils';
import {SourceFileTranslationHandler} from '../../../src/translate/source_files/source_file_translation_handler';
import {TranslationBundle} from '../../../src/translate/translator';
describe('SourceFileTranslationHandler', () => {
describe('canTranslate()', () => {
@ -39,6 +40,19 @@ describe('SourceFileTranslationHandler', () => {
.toHaveBeenCalledWith('/translations/fr/relative/path', contents);
});
it('should copy files to the source locale if they contain no reference to `$localize` and `sourceLocale` is provided',
() => {
const diagnostics = new Diagnostics();
const handler = new SourceFileTranslationHandler();
const translations: TranslationBundle[] = [];
const contents = Buffer.from('contents');
handler.translate(
diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations,
'en-US');
expect(FileUtils.writeFile)
.toHaveBeenCalledWith('/translations/en-US/relative/path', contents);
});
it('should transform each $localize template tag', () => {
const diagnostics = new Diagnostics();
const handler = new SourceFileTranslationHandler();
@ -57,6 +71,23 @@ describe('SourceFileTranslationHandler', () => {
expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/fr/relative/path.js', output);
});
it('should transform each $localize template tag and write it to the source locale if provided',
() => {
const diagnostics = new Diagnostics();
const handler = new SourceFileTranslationHandler();
const translations: TranslationBundle[] = [];
const contents = Buffer.from(
'$localize`a${1}b${2}c`;\n' +
'$localize(__makeTemplateObject(["a", "b", "c"], ["a", "b", "c"]), 1, 2);');
const output = '"a"+1+"b"+2+"c";"a"+1+"b"+2+"c";';
handler.translate(
diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn,
translations, 'en-US');
expect(FileUtils.writeFile)
.toHaveBeenCalledWith('/translations/en-US/relative/path.js', output);
});
it('should error if the file is not valid JS', () => {
const diagnostics = new Diagnostics();
const handler = new SourceFileTranslationHandler();

View File

@ -7,7 +7,8 @@
*/
import {Diagnostics as Diagnostics} from '../../src/diagnostics';
import {FileUtils} from '../../src/file_utils';
import {TranslationHandler, Translator} from '../../src/translate/translator';
import {OutputPathFn} from '../../src/translate/output_path';
import {TranslationBundle, TranslationHandler, Translator} from '../../src/translate/translator';
describe('Translator', () => {
describe('translateFiles()', () => {
@ -34,9 +35,24 @@ describe('Translator', () => {
expect(handler.log).toEqual([
'canTranslate(file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1, ...)',
'canTranslate(images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2, ...)',
]);
});
it('should pass the sourceLocale through to `translate()` if provided', () => {
const diagnostics = new Diagnostics();
const handler = new MockTranslationHandler(true);
const translator = new Translator([handler], diagnostics);
translator.translateFiles(
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, [], 'en-US');
expect(handler.log).toEqual([
'canTranslate(file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1, ..., en-US)',
'canTranslate(images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2, ..., en-US)',
]);
});
@ -55,9 +71,9 @@ describe('Translator', () => {
]);
expect(handler2.log).toEqual([
'canTranslate(file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1, ...)',
'canTranslate(images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2, ...)',
]);
});
@ -86,8 +102,12 @@ class MockTranslationHandler implements TranslationHandler {
return this._canTranslate;
}
translate(_diagnostics: Diagnostics, rootPath: string, relativePath: string, contents: Buffer) {
this.log.push(`translate(${rootPath}, ${relativePath}, ${contents})`);
translate(
_diagnostics: Diagnostics, rootPath: string, relativePath: string, contents: Buffer,
_outputPathFn: OutputPathFn, _translations: TranslationBundle[], sourceLocale?: string) {
this.log.push(
`translate(${rootPath}, ${relativePath}, ${contents}, ...` +
(sourceLocale !== undefined ? `, ${sourceLocale})` : ')'));
}
}