From f433d6604b141b880d745771e83f84dffb866048 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 11 Oct 2019 13:42:43 +0100 Subject: [PATCH] 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 --- .../cli-hello-world-ivy-i18n/angular.json | 4 ++ .../e2e/en/app.e2e-spec.ts | 15 +++++ .../e2e/en/protractor.conf.js | 5 ++ .../cli-hello-world-ivy-i18n/package.json | 7 ++- .../asset_files/asset_translation_handler.ts | 9 ++- .../localize/src/tools/src/translate/main.ts | 16 ++++- .../source_file_translation_handler.ts | 63 ++++++++++++------- .../src/tools/src/translate/translator.ts | 10 ++- .../asset_file_translation_handler_spec.ts | 15 +++++ .../source_file_translation_handler_spec.ts | 31 +++++++++ .../tools/test/translate/translator_spec.ts | 34 +++++++--- 11 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 integration/cli-hello-world-ivy-i18n/e2e/en/app.e2e-spec.ts create mode 100644 integration/cli-hello-world-ivy-i18n/e2e/en/protractor.conf.js diff --git a/integration/cli-hello-world-ivy-i18n/angular.json b/integration/cli-hello-world-ivy-i18n/angular.json index 46a643daab..669b9d7473 100644 --- a/integration/cli-hello-world-ivy-i18n/angular.json +++ b/integration/cli-hello-world-ivy-i18n/angular.json @@ -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" diff --git a/integration/cli-hello-world-ivy-i18n/e2e/en/app.e2e-spec.ts b/integration/cli-hello-world-ivy-i18n/e2e/en/app.e2e-spec.ts new file mode 100644 index 0000000000..6e2d887348 --- /dev/null +++ b/integration/cli-hello-world-ivy-i18n/e2e/en/app.e2e-spec.ts @@ -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.'); }); +}); diff --git a/integration/cli-hello-world-ivy-i18n/e2e/en/protractor.conf.js b/integration/cli-hello-world-ivy-i18n/e2e/en/protractor.conf.js new file mode 100644 index 0000000000..d743044ee3 --- /dev/null +++ b/integration/cli-hello-world-ivy-i18n/e2e/en/protractor.conf.js @@ -0,0 +1,5 @@ +const {config} = require('../protractor.conf'); +exports.config = { + ...config, + specs: ['./app.e2e-spec.ts'], +}; diff --git a/integration/cli-hello-world-ivy-i18n/package.json b/integration/cli-hello-world-ivy-i18n/package.json index 2f026d1f64..d1c02d8171 100644 --- a/integration/cli-hello-world-ivy-i18n/package.json +++ b/integration/cli-hello-world-ivy-i18n/package.json @@ -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}}\"", diff --git a/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts b/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts index ac5d5221d0..aea057bff1 100644 --- a/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts +++ b/packages/localize/src/tools/src/translate/asset_files/asset_translation_handler.ts @@ -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); + } + } } } diff --git a/packages/localize/src/tools/src/translate/main.ts b/packages/localize/src/tools/src/translate/main.ts index 15a63fa813..209426c2d9 100644 --- a/packages/localize/src/tools/src/translate/main.ts +++ b/packages/localize/src/tools/src/translate/main.ts @@ -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); } diff --git a/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts b/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts index 1511ec9293..912ed2cb5f 100644 --- a/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts +++ b/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts @@ -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; + } + } } diff --git a/packages/localize/src/tools/src/translate/translator.ts b/packages/localize/src/tools/src/translate/translator.ts index 56a6fc4428..a99f1fdc13 100644 --- a/packages/localize/src/tools/src/translate/translator.ts +++ b/packages/localize/src/tools/src/translate/translator.ts @@ -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}`); diff --git a/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts b/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts index 681f164954..791656729d 100644 --- a/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts +++ b/packages/localize/src/tools/test/translate/asset_files/asset_file_translation_handler_spec.ts @@ -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); + }); }); }); diff --git a/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts b/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts index 788d21124d..62a4ec472d 100644 --- a/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts +++ b/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts @@ -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(); diff --git a/packages/localize/src/tools/test/translate/translator_spec.ts b/packages/localize/src/tools/test/translate/translator_spec.ts index 8fec6f79c6..9b1191189c 100644 --- a/packages/localize/src/tools/test/translate/translator_spec.ts +++ b/packages/localize/src/tools/test/translate/translator_spec.ts @@ -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})` : ')')); } }