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})` : ')')); } }