diff --git a/packages/localize/package.json b/packages/localize/package.json index 53dc3de304..27aa1dd296 100644 --- a/packages/localize/package.json +++ b/packages/localize/package.json @@ -28,7 +28,11 @@ "glob": "7.1.2", "yargs": "15.3.0" }, - "publishConfig":{ - "registry":"https://wombat-dressing-room.appspot.com" + "peerDependencies": { + "@angular/compiler": "0.0.0-PLACEHOLDER", + "@angular/compiler-cli": "0.0.0-PLACEHOLDER" + }, + "publishConfig": { + "registry": "https://wombat-dressing-room.appspot.com" } -} +} \ No newline at end of file diff --git a/packages/localize/src/tools/BUILD.bazel b/packages/localize/src/tools/BUILD.bazel index daf193f862..6f37b1b7c2 100644 --- a/packages/localize/src/tools/BUILD.bazel +++ b/packages/localize/src/tools/BUILD.bazel @@ -19,6 +19,7 @@ ts_library( tsconfig = ":tsconfig", deps = [ "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/file_system", "//packages/localize", "@npm//@babel/core", "@npm//@babel/types", diff --git a/packages/localize/src/tools/src/file_utils.ts b/packages/localize/src/tools/src/file_utils.ts deleted file mode 100644 index d34f57abcc..0000000000 --- a/packages/localize/src/tools/src/file_utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * 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 * as fs from 'fs'; -import * as path from 'path'; - -export class FileUtils { - static readFile(absolutePath: string): string { - return fs.readFileSync(absolutePath, 'utf8'); - } - - static readFileBuffer(absolutePath: string): Buffer { - return fs.readFileSync(absolutePath); - } - - static writeFile(absolutePath: string, contents: string|Buffer) { - FileUtils.ensureDir(path.dirname(absolutePath)); - fs.writeFileSync(absolutePath, contents); - } - - static ensureDir(absolutePath: string): void { - const parents: string[] = []; - while (!FileUtils.isRoot(absolutePath) && !fs.existsSync(absolutePath)) { - parents.push(absolutePath); - absolutePath = path.dirname(absolutePath); - } - while (parents.length) { - fs.mkdirSync(parents.pop()!); - } - } - - static remove(p: string): void { - const stat = fs.statSync(p); - if (stat.isFile()) { - fs.unlinkSync(p); - } else if (stat.isDirectory()) { - fs.readdirSync(p).forEach(child => { - const absChild = path.resolve(p, child); - FileUtils.remove(absChild); - }); - fs.rmdirSync(p); - } - } - - static isRoot(absolutePath: string): boolean { - return path.dirname(absolutePath) === absolutePath; - } -} 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 ae70ba7c86..0f4d5293cc 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 @@ -5,8 +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 {absoluteFrom, AbsoluteFsPath, FileSystem, PathSegment} from '@angular/compiler-cli/src/ngtsc/file_system'; + import {Diagnostics} from '../../diagnostics'; -import {FileUtils} from '../../file_utils'; import {OutputPathFn} from '../output_path'; import {TranslationBundle, TranslationHandler} from '../translator'; @@ -14,25 +15,34 @@ import {TranslationBundle, TranslationHandler} from '../translator'; * Translate an asset file by simply copying it to the appropriate translation output paths. */ export class AssetTranslationHandler implements TranslationHandler { - canTranslate(_relativeFilePath: string, _contents: Buffer): boolean { + constructor(private fs: FileSystem) {} + + canTranslate(_relativeFilePath: PathSegment, _contents: Buffer): boolean { return true; } + translate( - diagnostics: Diagnostics, _sourceRoot: string, relativeFilePath: string, contents: Buffer, - outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void { + diagnostics: Diagnostics, _sourceRoot: AbsoluteFsPath, relativeFilePath: PathSegment, + contents: Buffer, outputPathFn: OutputPathFn, translations: TranslationBundle[], + sourceLocale?: string): void { for (const translation of translations) { - try { - FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents); - } catch (e) { - diagnostics.error(e.message); - } + this.writeAssetFile( + diagnostics, outputPathFn, translation.locale, relativeFilePath, contents); } if (sourceLocale !== undefined) { - try { - FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents); - } catch (e) { - diagnostics.error(e.message); - } + this.writeAssetFile(diagnostics, outputPathFn, sourceLocale, relativeFilePath, contents); + } + } + + private writeAssetFile( + diagnostics: Diagnostics, outputPathFn: OutputPathFn, locale: string, + relativeFilePath: PathSegment, contents: Buffer): void { + try { + const outputPath = absoluteFrom(outputPathFn(locale, relativeFilePath)); + this.fs.ensureDir(this.fs.dirname(outputPath)); + this.fs.writeFile(outputPath, 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 9178ed810d..0cfcc4cd98 100644 --- a/packages/localize/src/tools/src/translate/main.ts +++ b/packages/localize/src/tools/src/translate/main.ts @@ -6,8 +6,8 @@ * 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 {getFileSystem, NodeJSFileSystem, setFileSystem, relativeFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; import * as glob from 'glob'; -import {resolve} from 'path'; import * as yargs from 'yargs'; import {DiagnosticHandlingStrategy, Diagnostics} from '../diagnostics'; @@ -66,8 +66,9 @@ if (require.main === module) { .option('o', { alias: 'outputPath', required: true, - describe: - 'A output path pattern to where the translated files will be written. The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.' + describe: 'A output path pattern to where the translated files will be written.\n' + + 'The path must be either absolute or relative to the current working directory.\n' + + 'The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.' }) .option('m', { @@ -88,11 +89,13 @@ if (require.main === module) { .help() .parse(args); + const fs = new NodeJSFileSystem(); + setFileSystem(fs); + const sourceRootPath = options['r']; - const sourceFilePaths = - glob.sync(options['s'], {absolute: true, cwd: sourceRootPath, nodir: true}); + const sourceFilePaths = glob.sync(options['s'], {cwd: sourceRootPath, nodir: true}); const translationFilePaths: (string|string[])[] = convertArraysFromArgs(options['t']); - const outputPathFn = getOutputPathFn(options['o']); + const outputPathFn = getOutputPathFn(fs.resolve(options['o'])); const diagnostics = new Diagnostics(); const missingTranslation: DiagnosticHandlingStrategy = options['m']; const duplicateTranslation: DiagnosticHandlingStrategy = options['d']; @@ -154,8 +157,9 @@ export interface TranslateFilesOptions { */ translationFileLocales: (string|undefined)[]; /** - * A function that computes the output path of where the translated files will be written. - * The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`. + * A function that computes the output path of where the translated files will be + * written. The marker `{{LOCALE}}` will be replaced with the target locale. E.g. + * `dist/{{LOCALE}}`. */ outputPathFn: OutputPathFn; /** @@ -189,7 +193,9 @@ export function translateFiles({ duplicateTranslation, sourceLocale }: TranslateFilesOptions) { + const fs = getFileSystem(); const translationLoader = new TranslationLoader( + fs, [ new Xliff2TranslationParser(), new Xliff1TranslationParser(), @@ -199,21 +205,24 @@ export function translateFiles({ duplicateTranslation, diagnostics); const resourceProcessor = new Translator( + fs, [ - new SourceFileTranslationHandler({missingTranslation}), - new AssetTranslationHandler(), + new SourceFileTranslationHandler(fs, {missingTranslation}), + new AssetTranslationHandler(fs), ], diagnostics); // Convert all the `translationFilePaths` elements to arrays. - const translationFilePathsArrays = - translationFilePaths.map(filePaths => Array.isArray(filePaths) ? filePaths : [filePaths]); + const translationFilePathsArrays = translationFilePaths.map( + filePaths => + Array.isArray(filePaths) ? filePaths.map(p => fs.resolve(p)) : [fs.resolve(filePaths)]); const translations = translationLoader.loadBundles(translationFilePathsArrays, translationFileLocales); - sourceRootPath = resolve(sourceRootPath); + sourceRootPath = fs.resolve(sourceRootPath); resourceProcessor.translateFiles( - sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale); + sourceFilePaths.map(relativeFrom), fs.resolve(sourceRootPath), outputPathFn, translations, + sourceLocale); } /** diff --git a/packages/localize/src/tools/src/translate/output_path.ts b/packages/localize/src/tools/src/translate/output_path.ts index f33a65c5ce..28ee6750c3 100644 --- a/packages/localize/src/tools/src/translate/output_path.ts +++ b/packages/localize/src/tools/src/translate/output_path.ts @@ -5,8 +5,13 @@ * 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 {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {join} from 'path'; +/** + * A function that will return an absolute path to where a file is to be written, given a locale and + * a relative path. + */ export interface OutputPathFn { (locale: string, relativePath: string): string; } @@ -18,7 +23,7 @@ export interface OutputPathFn { * The special `{{LOCALE}}` marker will be replaced with the locale code of the current translation. * @param outputFolder An absolute path to the folder containing this set of translations. */ -export function getOutputPathFn(outputFolder: string): OutputPathFn { +export function getOutputPathFn(outputFolder: AbsoluteFsPath): OutputPathFn { const [pre, post] = outputFolder.split('{{LOCALE}}'); return post === undefined ? (_locale, relativePath) => join(pre, relativePath) : (locale, relativePath) => join(pre + locale + post, relativePath); 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 60b93dba96..e481092386 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,14 +5,15 @@ * 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 {absoluteFrom, AbsoluteFsPath, FileSystem, PathSegment, relativeFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; import {parseSync, transformFromAstSync} from '@babel/core'; import {File, Program} from '@babel/types'; -import {extname, join} from 'path'; + import {Diagnostics} from '../../diagnostics'; -import {FileUtils} from '../../file_utils'; import {TranslatePluginOptions} from '../../source_file_utils'; import {OutputPathFn} from '../output_path'; import {TranslationBundle, TranslationHandler} from '../translator'; + import {makeEs2015TranslatePlugin} from './es2015_translate_plugin'; import {makeEs5TranslatePlugin} from './es5_translate_plugin'; import {makeLocalePlugin} from './locale_plugin'; @@ -24,29 +25,32 @@ import {makeLocalePlugin} from './locale_plugin'; export class SourceFileTranslationHandler implements TranslationHandler { private sourceLocaleOptions: TranslatePluginOptions = {...this.translationOptions, missingTranslation: 'ignore'}; - constructor(private translationOptions: TranslatePluginOptions = {}) {} + constructor(private fs: FileSystem, private translationOptions: TranslatePluginOptions = {}) {} - canTranslate(relativeFilePath: string, _contents: Buffer): boolean { - return extname(relativeFilePath) === '.js'; + canTranslate(relativeFilePath: PathSegment, _contents: Buffer): boolean { + return this.fs.extname(relativeFrom(relativeFilePath)) === '.js'; } translate( - diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer, - outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void { + diagnostics: Diagnostics, sourceRoot: AbsoluteFsPath, relativeFilePath: PathSegment, + contents: Buffer, 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. if (!sourceCode.includes('$localize')) { for (const translation of translations) { - FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents); + this.writeSourceFile( + diagnostics, outputPathFn, translation.locale, relativeFilePath, contents); } if (sourceLocale !== undefined) { - FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents); + this.writeSourceFile(diagnostics, outputPathFn, sourceLocale, relativeFilePath, contents); } } else { const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath}); if (!ast) { - diagnostics.error(`Unable to parse source file: ${join(sourceRoot, relativeFilePath)}`); + diagnostics.error( + `Unable to parse source file: ${this.fs.join(sourceRoot, relativeFilePath)}`); return; } // Output a translated copy of the file for each locale. @@ -67,7 +71,7 @@ export class SourceFileTranslationHandler implements TranslationHandler { private translateFile( diagnostics: Diagnostics, ast: File|Program, translationBundle: TranslationBundle, - sourceRoot: string, filename: string, outputPathFn: OutputPathFn, + sourceRoot: AbsoluteFsPath, filename: PathSegment, outputPathFn: OutputPathFn, options: TranslatePluginOptions) { const translated = transformFromAstSync(ast, undefined, { compact: true, @@ -80,10 +84,26 @@ export class SourceFileTranslationHandler implements TranslationHandler { filename, }); if (translated && translated.code) { - FileUtils.writeFile(outputPathFn(translationBundle.locale, filename), translated.code); + this.writeSourceFile( + diagnostics, outputPathFn, translationBundle.locale, filename, translated.code); + const outputPath = absoluteFrom(outputPathFn(translationBundle.locale, filename)); + this.fs.ensureDir(this.fs.dirname(outputPath)); + this.fs.writeFile(outputPath, translated.code); } else { - diagnostics.error(`Unable to translate source file: ${join(sourceRoot, filename)}`); + diagnostics.error(`Unable to translate source file: ${this.fs.join(sourceRoot, filename)}`); return; } } + + private writeSourceFile( + diagnostics: Diagnostics, outputPathFn: OutputPathFn, locale: string, + relativeFilePath: PathSegment, contents: string|Buffer): void { + try { + const outputPath = absoluteFrom(outputPathFn(locale, relativeFilePath)); + this.fs.ensureDir(this.fs.dirname(outputPath)); + this.fs.writeFile(outputPath, contents); + } catch (e) { + diagnostics.error(e.message); + } + } } diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts b/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts index a7cf016f82..7524074c38 100644 --- a/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts +++ b/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts @@ -5,8 +5,8 @@ * 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 {AbsoluteFsPath, FileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; import {DiagnosticHandlingStrategy, Diagnostics} from '../../diagnostics'; -import {FileUtils} from '../../file_utils'; import {TranslationBundle} from '../translator'; import {TranslationParser} from './translation_parsers/translation_parser'; @@ -16,7 +16,7 @@ import {TranslationParser} from './translation_parsers/translation_parser'; */ export class TranslationLoader { constructor( - private translationParsers: TranslationParser[], + private fs: FileSystem, private translationParsers: TranslationParser[], private duplicateTranslation: DiagnosticHandlingStrategy, /** @deprecated */ private diagnostics?: Diagnostics) {} @@ -42,8 +42,9 @@ export class TranslationLoader { * 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. */ - loadBundles(translationFilePaths: string[][], translationFileLocales: (string|undefined)[]): - TranslationBundle[] { + loadBundles( + translationFilePaths: AbsoluteFsPath[][], + translationFileLocales: (string|undefined)[]): TranslationBundle[] { return translationFilePaths.map((filePaths, index) => { const providedLocale = translationFileLocales[index]; return this.mergeBundles(filePaths, providedLocale); @@ -53,8 +54,9 @@ export class TranslationLoader { /** * Load all the translations from the file at the given `filePath`. */ - private loadBundle(filePath: string, providedLocale: string|undefined): TranslationBundle { - const fileContents = FileUtils.readFile(filePath); + private loadBundle(filePath: AbsoluteFsPath, providedLocale: string|undefined): + TranslationBundle { + const fileContents = this.fs.readFile(filePath); for (const translationParser of this.translationParsers) { const result = translationParser.canParse(filePath, fileContents); if (!result) { @@ -96,7 +98,8 @@ export class TranslationLoader { * 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 { + private mergeBundles(filePaths: AbsoluteFsPath[], 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++) { diff --git a/packages/localize/src/tools/src/translate/translator.ts b/packages/localize/src/tools/src/translate/translator.ts index 74ccebada5..aff3efc726 100644 --- a/packages/localize/src/tools/src/translate/translator.ts +++ b/packages/localize/src/tools/src/translate/translator.ts @@ -5,11 +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 {AbsoluteFsPath, FileSystem, PathSegment} from '@angular/compiler-cli/src/ngtsc/file_system'; import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; -import {relative} from 'path'; import {Diagnostics} from '../diagnostics'; -import {FileUtils} from '../file_utils'; import {OutputPathFn} from './output_path'; @@ -36,7 +35,7 @@ export interface TranslationHandler { * @param relativeFilePath A relative path from the sourceRoot to the resource file to handle. * @param contents The contents of the file to handle. */ - canTranslate(relativeFilePath: string, contents: Buffer): boolean; + canTranslate(relativeFilePath: PathSegment, contents: Buffer): boolean; /** * Translate the file at `relativeFilePath` containing `contents`, using the given `translations`, @@ -54,8 +53,9 @@ export interface TranslationHandler { * stripped out. */ translate( - diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer, - outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void; + diagnostics: Diagnostics, sourceRoot: AbsoluteFsPath, relativeFilePath: PathSegment, + contents: Buffer, outputPathFn: OutputPathFn, translations: TranslationBundle[], + sourceLocale?: string): void; } /** @@ -63,14 +63,17 @@ export interface TranslationHandler { * The file will be translated by the first handler that returns true for `canTranslate()`. */ export class Translator { - constructor(private resourceHandlers: TranslationHandler[], private diagnostics: Diagnostics) {} + constructor( + private fs: FileSystem, private resourceHandlers: TranslationHandler[], + private diagnostics: Diagnostics) {} translateFiles( - inputPaths: string[], rootPath: string, outputPathFn: OutputPathFn, + inputPaths: PathSegment[], rootPath: AbsoluteFsPath, outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void { inputPaths.forEach(inputPath => { - const contents = FileUtils.readFileBuffer(inputPath); - const relativePath = relative(rootPath, inputPath); + const absInputPath = this.fs.resolve(rootPath, inputPath); + const contents = this.fs.readFileBuffer(absInputPath); + const relativePath = this.fs.relative(rootPath, absInputPath); for (const resourceHandler of this.resourceHandlers) { if (resourceHandler.canTranslate(relativePath, contents)) { return resourceHandler.translate( diff --git a/packages/localize/src/tools/test/BUILD.bazel b/packages/localize/src/tools/test/BUILD.bazel index b828ba9ec8..0ebf350f39 100644 --- a/packages/localize/src/tools/test/BUILD.bazel +++ b/packages/localize/src/tools/test/BUILD.bazel @@ -9,6 +9,8 @@ ts_library( deps = [ "//packages:types", "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/file_system/testing", "//packages/localize", "//packages/localize/src/tools", "@npm//@babel/core", 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 791656729d..22ee8920cd 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 @@ -5,56 +5,70 @@ * 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 {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, PathSegment, relativeFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + 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()', () => { - it('should always return true', () => { - const handler = new AssetTranslationHandler(); - expect(handler.canTranslate('relative/path', Buffer.from('contents'))).toBe(true); - }); - }); +runInEachFileSystem(() => { + describe('AssetTranslationHandler', () => { + let fs: FileSystem; + let rootPath: AbsoluteFsPath; + let filePath: PathSegment; + let enTranslationPath: AbsoluteFsPath; + let enUSTranslationPath: AbsoluteFsPath; + let frTranslationPath: AbsoluteFsPath; - describe('translate()', () => { beforeEach(() => { - spyOn(FileUtils, 'writeFile'); - spyOn(FileUtils, 'ensureDir'); + fs = getFileSystem(); + rootPath = absoluteFrom('/root/path'); + filePath = relativeFrom('relative/path'); + enTranslationPath = absoluteFrom('/translations/en/relative/path'); + enUSTranslationPath = absoluteFrom('/translations/en-US/relative/path'); + frTranslationPath = absoluteFrom('/translations/fr/relative/path'); }); - it('should write the translated file for each translation locale', () => { - const diagnostics = new Diagnostics(); - const handler = new AssetTranslationHandler(); - const translations = [ - {locale: 'en', translations: {}}, - {locale: 'fr', translations: {}}, - ]; - const contents = Buffer.from('contents'); - handler.translate( - diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations); - - expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/en/relative/path', contents); - expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/fr/relative/path', contents); + describe('canTranslate()', () => { + it('should always return true', () => { + const handler = new AssetTranslationHandler(fs); + expect(handler.canTranslate(filePath, Buffer.from('contents'))).toBe(true); + }); }); - 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); + describe('translate()', () => { + it('should write the translated file for each translation locale', () => { + const diagnostics = new Diagnostics(); + const handler = new AssetTranslationHandler(fs); + const translations = [ + {locale: 'en', translations: {}}, + {locale: 'fr', translations: {}}, + ]; + const contents = Buffer.from('contents'); + handler.translate( + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations); - expect(FileUtils.writeFile) - .toHaveBeenCalledWith('/translations/en-US/relative/path', contents); + expect(fs.readFileBuffer(enTranslationPath)).toEqual(contents); + expect(fs.readFileBuffer(frTranslationPath)).toEqual(contents); + }); + + it('should write the translated file to the source locale if provided', () => { + const diagnostics = new Diagnostics(); + const handler = new AssetTranslationHandler(fs); + const translations: TranslationBundle[] = []; + const contents = Buffer.from('contents'); + const sourceLocale = 'en-US'; + handler.translate( + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations, + sourceLocale); + + expect(fs.readFileBuffer(enUSTranslationPath)).toEqual(contents); + }); }); }); -}); -function mockOutputPathFn(locale: string, relativePath: string) { - return `/translations/${locale}/${relativePath}`; -} + function mockOutputPathFn(locale: string, relativePath: string) { + return `/translations/${locale}/${relativePath}`; + } +}); diff --git a/packages/localize/src/tools/test/translate/integration/BUILD.bazel b/packages/localize/src/tools/test/translate/integration/BUILD.bazel index d114c63fff..eb8fca76a1 100644 --- a/packages/localize/src/tools/test/translate/integration/BUILD.bazel +++ b/packages/localize/src/tools/test/translate/integration/BUILD.bazel @@ -8,6 +8,9 @@ ts_library( ), deps = [ "//packages:types", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/file_system/testing", + "//packages/compiler-cli/test/helpers", "//packages/localize/src/tools", ], ) diff --git a/packages/localize/src/tools/test/translate/integration/main_spec.ts b/packages/localize/src/tools/test/translate/integration/main_spec.ts index 94a9a30a11..4b2442d8d8 100644 --- a/packages/localize/src/tools/test/translate/integration/main_spec.ts +++ b/packages/localize/src/tools/test/translate/integration/main_spec.ts @@ -5,208 +5,216 @@ * 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 {resolve} from 'path'; +import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {loadTestDirectory} from '@angular/compiler-cli/test/helpers'; +import {resolve as realResolve} from 'path'; import {Diagnostics} from '../../../src/diagnostics'; -import {FileUtils} from '../../../src/file_utils'; import {translateFiles} from '../../../src/translate/main'; import {getOutputPathFn} from '../../../src/translate/output_path'; -describe('translateFiles()', () => { - const tmpDir = process.env.TEST_TMPDIR; - if (tmpDir === undefined) return; +runInEachFileSystem(() => { + describe('translateFiles()', () => { + let fs: FileSystem; + let testDir: AbsoluteFsPath; + let testFilesDir: AbsoluteFsPath; + let translationFilesDir: AbsoluteFsPath; - const testDir = resolve(tmpDir, 'translatedFiles_tests'); + beforeEach(() => { + fs = getFileSystem(); + testDir = absoluteFrom('/test'); - beforeEach(() => FileUtils.ensureDir(testDir)); - afterEach(() => { - FileUtils.remove(testDir); - }); - - it('should copy non-code files to the destination folders', () => { - const diagnostics = new Diagnostics(); - const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); - translateFiles({ - sourceRootPath: resolve(__dirname, 'test_files'), - sourceFilePaths: resolveAll(__dirname + '/test_files', ['test-1.txt', 'test-2.txt']), - outputPathFn, - translationFilePaths: resolveAll( - __dirname + '/locales', - ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), - translationFileLocales: [], - diagnostics, - missingTranslation: 'error', - duplicateTranslation: 'error', + testFilesDir = fs.resolve(testDir, 'test_files'); + loadTestDirectory(fs, realResolve(__dirname, 'test_files'), testFilesDir); + translationFilesDir = fs.resolve(testDir, 'test_files'); + loadTestDirectory(fs, realResolve(__dirname, 'locales'), translationFilesDir); }); - expect(diagnostics.messages.length).toEqual(0); + it('should copy non-code files to the destination folders', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: testFilesDir, + sourceFilePaths: ['test-1.txt', 'test-2.txt'], + outputPathFn, + translationFilePaths: resolveAll( + translationFilesDir, + ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), + translationFileLocales: [], + diagnostics, + missingTranslation: 'error', + duplicateTranslation: 'error', + }); - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - expect(FileUtils.readFile(resolve(testDir, 'de', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'de', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - }); + expect(diagnostics.messages.length).toEqual(0); - it('should translate and copy source-code files to the destination folders', () => { - const diagnostics = new Diagnostics(); - const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); - translateFiles({ - sourceRootPath: resolve(__dirname, 'test_files'), - sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), - outputPathFn, - translationFilePaths: resolveAll( - __dirname + '/locales', - ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), - translationFileLocales: [], - diagnostics, - missingTranslation: 'error', - duplicateTranslation: 'error', + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(fs.readFile(fs.resolve(testDir, 'de', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'de', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); }); - expect(diagnostics.messages.length).toEqual(0); + it('should translate and copy source-code files to the destination folders', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: testFilesDir, + sourceFilePaths: ['test.js'], + outputPathFn, + translationFilePaths: resolveAll( + translationFilesDir, + ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), + translationFileLocales: [], + diagnostics, + missingTranslation: 'error', + duplicateTranslation: 'error', + }); - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test.js'))) - .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'de', 'test.js'))) - .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js'))) - .toEqual(`var name="World";var message="Hola, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test.js'))) - .toEqual(`var name="World";var message="Ciao, "+name+"!";`); - }); + expect(diagnostics.messages.length).toEqual(0); - it('should translate and copy source-code files overriding the locales', () => { - const diagnostics = new Diagnostics(); - const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); - translateFiles({ - sourceRootPath: resolve(__dirname, 'test_files'), - sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), - outputPathFn, - translationFilePaths: resolveAll( - __dirname + '/locales', - ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), - translationFileLocales: ['xde', undefined, 'fr'], - diagnostics, - missingTranslation: 'error', - duplicateTranslation: 'error', + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test.js'))) + .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'de', 'test.js'))) + .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test.js'))) + .toEqual(`var name="World";var message="Hola, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test.js'))) + .toEqual(`var name="World";var message="Ciao, "+name+"!";`); }); - expect(diagnostics.messages.length).toEqual(1); - expect(diagnostics.messages).toContain({ - type: 'warning', - message: - `The provided locale "xde" does not match the target locale "de" found in the translation file "${ - resolve(__dirname, 'locales', 'messages.de.json')}".` + it('should translate and copy source-code files overriding the locales', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: testFilesDir, + sourceFilePaths: ['test.js'], + outputPathFn, + translationFilePaths: resolveAll( + translationFilesDir, + ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), + translationFileLocales: ['xde', undefined, 'fr'], + diagnostics, + missingTranslation: 'error', + duplicateTranslation: 'error', + }); + + expect(diagnostics.messages.length).toEqual(1); + expect(diagnostics.messages).toContain({ + type: 'warning', + message: + `The provided locale "xde" does not match the target locale "de" found in the translation file "${ + fs.resolve(translationFilesDir, 'messages.de.json')}".` + }); + + expect(fs.readFile(fs.resolve(testDir, 'xde', 'test.js'))) + .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test.js'))) + .toEqual(`var name="World";var message="Hola, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test.js'))) + .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test.js'))) + .toEqual(`var name="World";var message="Ciao, "+name+"!";`); }); - expect(FileUtils.readFile(resolve(testDir, 'xde', 'test.js'))) - .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js'))) - .toEqual(`var name="World";var message="Hola, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test.js'))) - .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test.js'))) - .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(fs.resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: testFilesDir, + sourceFilePaths: ['test-extra.js'], + outputPathFn, + translationFilePaths: resolveAllRecursive( + translationFilesDir, + [['messages.de.json', 'messages-extra.de.json'], 'messages.es.xlf']), + translationFileLocales: [], + diagnostics, + missingTranslation: 'error', + duplicateTranslation: 'error', + }); - 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(fs.readFile(fs.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(fs.readFile(fs.resolve(testDir, 'es', 'test-extra.js'))) + .toEqual( + `var name="World";var message="Hola, "+name+"!";var message="Goodbye, "+name+"!";`); }); - 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}!").' + it('should transform and/or copy files to the destination folders', () => { + const diagnostics = new Diagnostics(); + const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}')); + translateFiles({ + sourceRootPath: testFilesDir, + sourceFilePaths: ['test-1.txt', 'test-2.txt', 'test.js'], + outputPathFn, + translationFilePaths: resolveAll( + translationFilesDir, + ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), + translationFileLocales: [], + diagnostics, + missingTranslation: 'error', + duplicateTranslation: 'error', + }); + + expect(diagnostics.messages.length).toEqual(0); + + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(fs.readFile(fs.resolve(testDir, 'de', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'de', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test-1.txt'))) + .toEqual('Contents of test-1.txt'); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test-2.txt'))) + .toEqual('Contents of test-2.txt'); + + expect(fs.readFile(fs.resolve(testDir, 'fr', 'test.js'))) + .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'de', 'test.js'))) + .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'es', 'test.js'))) + .toEqual(`var name="World";var message="Hola, "+name+"!";`); + expect(fs.readFile(fs.resolve(testDir, 'it', 'test.js'))) + .toEqual(`var name="World";var message="Ciao, "+name+"!";`); }); - // 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}}')); - translateFiles({ - sourceRootPath: resolve(__dirname, 'test_files'), - sourceFilePaths: - resolveAll(__dirname + '/test_files', ['test-1.txt', 'test-2.txt', 'test.js']), - outputPathFn, - translationFilePaths: resolveAll( - __dirname + '/locales', - ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), - translationFileLocales: [], - diagnostics, - missingTranslation: 'error', - duplicateTranslation: 'error', - }); - - expect(diagnostics.messages.length).toEqual(0); - - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - expect(FileUtils.readFile(resolve(testDir, 'de', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'de', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test-1.txt'))) - .toEqual('Contents of test-1.txt'); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test-2.txt'))) - .toEqual('Contents of test-2.txt'); - - expect(FileUtils.readFile(resolve(testDir, 'fr', 'test.js'))) - .toEqual(`var name="World";var message="Bonjour, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'de', 'test.js'))) - .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js'))) - .toEqual(`var name="World";var message="Hola, "+name+"!";`); - expect(FileUtils.readFile(resolve(testDir, 'it', 'test.js'))) - .toEqual(`var name="World";var message="Ciao, "+name+"!";`); + function resolveAll(rootPath: string, paths: string[]): string[] { + return paths.map(p => fs.resolve(rootPath, p)); + } + function resolveAllRecursive( + rootPath: string, paths: (string|string[])[]): (string|string[])[] { + return paths.map( + p => Array.isArray(p) ? p.map(p2 => fs.resolve(rootPath, p2)) : fs.resolve(rootPath, p)); + } }); }); - -function resolveAll(rootPath: string, paths: string[]): string[] { - return paths.map(p => resolve(rootPath, p)); -} - -function resolveAllRecursive(rootPath: string, paths: (string|string[])[]): (string|string[])[] { - return paths.map( - p => Array.isArray(p) ? p.map(p2 => resolve(rootPath, p2)) : resolve(rootPath, p)); -} diff --git a/packages/localize/src/tools/test/translate/output_path_spec.ts b/packages/localize/src/tools/test/translate/output_path_spec.ts index f952d54706..185ac93d65 100644 --- a/packages/localize/src/tools/test/translate/output_path_spec.ts +++ b/packages/localize/src/tools/test/translate/output_path_spec.ts @@ -5,40 +5,38 @@ * 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 {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + import {getOutputPathFn} from '../../src/translate/output_path'; -describe('getOutputPathFn()', () => { - it('should return a function that joins the `outputPath` and the `relativePath`', () => { - const fn = getOutputPathFn('/output/path'); - expect(fn('en', 'relative/path')).toEqual('/output/path/relative/path'); - expect(fn('en', '../parent/path')).toEqual('/output/parent/path'); +runInEachFileSystem(() => { + describe('getOutputPathFn()', () => { + it('should return a function that joins the `outputPath` and the `relativePath`', () => { + const fn = getOutputPathFn(absoluteFrom('/output/path')); + expect(fn('en', 'relative/path')).toEqual(absoluteFrom('/output/path/relative/path')); + expect(fn('en', '../parent/path')).toEqual(absoluteFrom('/output/parent/path')); + }); + + it('should return a function that interpolates the `{{LOCALE}}` in the middle of the `outputPath`', + () => { + const fn = getOutputPathFn(absoluteFrom('/output/{{LOCALE}}/path')); + expect(fn('en', 'relative/path')).toEqual(absoluteFrom('/output/en/path/relative/path')); + expect(fn('fr', 'relative/path')).toEqual(absoluteFrom('/output/fr/path/relative/path')); + }); + + it('should return a function that interpolates the `{{LOCALE}}` in the middle of a path segment in the `outputPath`', + () => { + const fn = getOutputPathFn(absoluteFrom('/output-{{LOCALE}}-path')); + expect(fn('en', 'relative/path')).toEqual(absoluteFrom('/output-en-path/relative/path')); + expect(fn('fr', 'relative/path')).toEqual(absoluteFrom('/output-fr-path/relative/path')); + }); + + it('should return a function that interpolates the `{{LOCALE}}` at the end of the `outputPath`', + () => { + const fn = getOutputPathFn(absoluteFrom('/output/{{LOCALE}}')); + expect(fn('en', 'relative/path')).toEqual(absoluteFrom('/output/en/relative/path')); + expect(fn('fr', 'relative/path')).toEqual(absoluteFrom('/output/fr/relative/path')); + }); }); - - it('should return a function that interpolates the `{{LOCALE}}` in the middle of the `outputPath`', - () => { - const fn = getOutputPathFn('/output/{{LOCALE}}/path'); - expect(fn('en', 'relative/path')).toEqual('/output/en/path/relative/path'); - expect(fn('fr', 'relative/path')).toEqual('/output/fr/path/relative/path'); - }); - - it('should return a function that interpolates the `{{LOCALE}}` in the middle of a path segment in the `outputPath`', - () => { - const fn = getOutputPathFn('/output-{{LOCALE}}-path'); - expect(fn('en', 'relative/path')).toEqual('/output-en-path/relative/path'); - expect(fn('fr', 'relative/path')).toEqual('/output-fr-path/relative/path'); - }); - - it('should return a function that interpolates the `{{LOCALE}}` at the start of the `outputPath`', - () => { - const fn = getOutputPathFn('{{LOCALE}}/path'); - expect(fn('en', 'relative/path')).toEqual('en/path/relative/path'); - expect(fn('fr', 'relative/path')).toEqual('fr/path/relative/path'); - }); - - it('should return a function that interpolates the `{{LOCALE}}` at the end of the `outputPath`', - () => { - const fn = getOutputPathFn('/output/{{LOCALE}}'); - expect(fn('en', 'relative/path')).toEqual('/output/en/relative/path'); - expect(fn('fr', 'relative/path')).toEqual('/output/fr/relative/path'); - }); -}); \ No newline at end of file +}); 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 ef56567330..bef3127419 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 @@ -5,128 +5,135 @@ * 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 {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, PathSegment, relativeFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + 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()', () => { - it('should return true if the path ends in ".js"', () => { - const handler = new SourceFileTranslationHandler(); - expect(handler.canTranslate('relative/path', Buffer.from('contents'))).toBe(false); - expect(handler.canTranslate('relative/path.js', Buffer.from('contents'))).toBe(true); - }); - }); +runInEachFileSystem(() => { + describe('SourceFileTranslationHandler', () => { + let fs: FileSystem; + let rootPath: AbsoluteFsPath; + let filePath: PathSegment; + let enTranslationPath: AbsoluteFsPath; + let enUSTranslationPath: AbsoluteFsPath; + let frTranslationPath: AbsoluteFsPath; - describe('translate()', () => { beforeEach(() => { - spyOn(FileUtils, 'writeFile'); + fs = getFileSystem(); + rootPath = absoluteFrom('/root/path'); + filePath = relativeFrom('relative/path.js'); + enTranslationPath = absoluteFrom('/translations/en/relative/path.js'); + enUSTranslationPath = absoluteFrom('/translations/en-US/relative/path.js'); + frTranslationPath = absoluteFrom('/translations/fr/relative/path.js'); }); - it('should copy files for each translation locale if they contain no reference to `$localize`', - () => { - const diagnostics = new Diagnostics(); - const handler = new SourceFileTranslationHandler(); - const translations = [ - {locale: 'en', translations: {}}, - {locale: 'fr', translations: {}}, - ]; - const contents = Buffer.from('contents'); - handler.translate( - diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations); - - expect(FileUtils.writeFile) - .toHaveBeenCalledWith('/translations/en/relative/path', contents); - expect(FileUtils.writeFile) - .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(); - const translations = [ - {locale: 'en', translations: {}}, - {locale: 'fr', translations: {}}, - ]; - 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); - - expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/en/relative/path.js', output); - expect(FileUtils.writeFile).toHaveBeenCalledWith('/translations/fr/relative/path.js', output); + describe('canTranslate()', () => { + it('should return true if the path ends in ".js"', () => { + const handler = new SourceFileTranslationHandler(fs); + expect(handler.canTranslate(relativeFrom('relative/path'), Buffer.from('contents'))) + .toBe(false); + expect(handler.canTranslate(filePath, Buffer.from('contents'))).toBe(true); + }); }); - 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'); + describe('translate()', () => { + it('should copy files for each translation locale if they contain no reference to `$localize`', + () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(fs); + const translations = [ + {locale: 'en', translations: {}}, + {locale: 'fr', translations: {}}, + ]; + const contents = Buffer.from('contents'); + handler.translate( + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations); - expect(FileUtils.writeFile) - .toHaveBeenCalledWith('/translations/en-US/relative/path.js', output); - }); + expect(fs.readFileBuffer(enTranslationPath)).toEqual(contents); + expect(fs.readFileBuffer(frTranslationPath)).toEqual(contents); + }); - it('should transform `$localize.locale` identifiers', () => { - const diagnostics = new Diagnostics(); - const handler = new SourceFileTranslationHandler(); - const translations: TranslationBundle[] = [ - {locale: 'fr', translations: {}}, - ]; - const contents = Buffer.from( - 'const x = $localize.locale;\n' + - 'const y = typeof $localize !== "undefined" && $localize.locale;\n' + - 'const z = "undefined" !== typeof $localize && $localize.locale || "default";'); - const getOutput = (locale: string) => - `const x="${locale}";const y="${locale}";const z="${locale}"||"default";`; + 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(fs); + const translations: TranslationBundle[] = []; + const contents = Buffer.from('contents'); + handler.translate( + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations, 'en-US'); + expect(fs.readFileBuffer(enUSTranslationPath)).toEqual(contents); + }); - handler.translate( - diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, translations, - 'en-US'); + it('should transform each $localize template tag', () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(fs); + const translations = [ + {locale: 'en', translations: {}}, + {locale: 'fr', translations: {}}, + ]; + 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, rootPath, filePath, contents, mockOutputPathFn, translations); - expect(FileUtils.writeFile) - .toHaveBeenCalledWith('/translations/fr/relative/path.js', getOutput('fr')); - expect(FileUtils.writeFile) - .toHaveBeenCalledWith('/translations/en-US/relative/path.js', getOutput('en-US')); - }); + expect(fs.readFile(enTranslationPath)).toEqual(output); + expect(fs.readFile(frTranslationPath)).toEqual(output); + }); - it('should error if the file is not valid JS', () => { - const diagnostics = new Diagnostics(); - const handler = new SourceFileTranslationHandler(); - const translations = [{locale: 'en', translations: {}}]; - const contents = Buffer.from('this is not a valid $localize file.'); - expect( - () => handler.translate( - diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, - translations)) - .toThrowError(); + it('should transform each $localize template tag and write it to the source locale if provided', + () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(fs); + 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, rootPath, filePath, contents, mockOutputPathFn, translations, 'en-US'); + + expect(fs.readFile(enUSTranslationPath)).toEqual(output); + }); + + it('should transform `$localize.locale` identifiers', () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(fs); + const translations: TranslationBundle[] = [ + {locale: 'fr', translations: {}}, + ]; + const contents = Buffer.from( + 'const x = $localize.locale;\n' + + 'const y = typeof $localize !== "undefined" && $localize.locale;\n' + + 'const z = "undefined" !== typeof $localize && $localize.locale || "default";'); + const getOutput = (locale: string) => + `const x="${locale}";const y="${locale}";const z="${locale}"||"default";`; + + handler.translate( + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations, 'en-US'); + + expect(fs.readFile(frTranslationPath)).toEqual(getOutput('fr')); + expect(fs.readFile(enUSTranslationPath)).toEqual(getOutput('en-US')); + }); + + it('should error if the file is not valid JS', () => { + const diagnostics = new Diagnostics(); + const handler = new SourceFileTranslationHandler(fs); + const translations = [{locale: 'en', translations: {}}]; + const contents = Buffer.from('this is not a valid $localize file.'); + expect( + () => handler.translate( + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations)) + .toThrowError(); + }); }); }); -}); -function mockOutputPathFn(locale: string, relativePath: string) { - return `/translations/${locale}/${relativePath}`; -} + function mockOutputPathFn(locale: string, relativePath: string) { + return `/translations/${locale}/${relativePath}`; + } +}); diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts index 147a12185c..6c251260fc 100644 --- a/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts +++ b/packages/localize/src/tools/test/translate/translation_files/translation_loader_spec.ts @@ -5,216 +5,229 @@ * 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 {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import {ɵParsedTranslation, ɵparseTranslation} from '@angular/localize'; import {DiagnosticHandlingStrategy, Diagnostics} from '../../../src/diagnostics'; -import {FileUtils} from '../../../src/file_utils'; import {TranslationLoader} from '../../../src/translate/translation_files/translation_loader'; +import {SimpleJsonTranslationParser} from '../../../src/translate/translation_files/translation_parsers/simple_json_translation_parser'; import {TranslationParser} from '../../../src/translate/translation_files/translation_parsers/translation_parser'; -describe('TranslationLoader', () => { - describe('loadBundles()', () => { - const alwaysCanParse = () => true; - const neverCanParse = () => false; +runInEachFileSystem(() => { + describe('TranslationLoader', () => { + describe('loadBundles()', () => { + const alwaysCanParse = () => true; + const neverCanParse = () => false; - beforeEach(() => { - spyOn(FileUtils, 'readFile').and.returnValues('english messages', 'french messages'); - }); + let fs: FileSystem; + let enTranslationPath: AbsoluteFsPath; + const enTranslationContent = '{"locale": "en", "translations": {"a": "A"}}'; + let frTranslationPath: AbsoluteFsPath; + const frTranslationContent = '{"locale": "fr", "translations": {"a": "A"}}'; + let frExtraTranslationPath: AbsoluteFsPath; + const frExtraTranslationContent = '{"locale": "fr", "translations": {"b": "B"}}'; + let jsonParser: SimpleJsonTranslationParser; - it('should call `canParse()` and `parse()` for each file', () => { - const diagnostics = new Diagnostics(); - 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)', - 'canParse(/src/locale/messages.fr.xlf, french messages)', - 'parse(/src/locale/messages.fr.xlf, french messages)', - ]); - }); + beforeEach(() => { + fs = getFileSystem(); + enTranslationPath = absoluteFrom('/src/locale/messages.en.json'); + frTranslationPath = absoluteFrom('/src/locale/messages.fr.json'); + frExtraTranslationPath = absoluteFrom('/src/locale/extra.fr.json'); + fs.ensureDir(absoluteFrom('/src/locale')); + fs.writeFile(enTranslationPath, enTranslationContent); + fs.writeFile(frTranslationPath, frTranslationContent); + fs.writeFile(frExtraTranslationPath, frExtraTranslationContent); + jsonParser = new SimpleJsonTranslationParser(); + }); - it('should stop at the first parser that can parse each file', () => { - const diagnostics = new Diagnostics(); - 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)', - ]); - expect(parser2.log).toEqual([ - 'canParse(/src/locale/messages.en.xlf, english messages)', - 'parse(/src/locale/messages.en.xlf, english messages)', - 'canParse(/src/locale/messages.fr.xlf, french messages)', - 'parse(/src/locale/messages.fr.xlf, french messages)', - ]); - }); + it('should call `canParse()` and `parse()` for each file', () => { + const diagnostics = new Diagnostics(); + const parser = new MockTranslationParser(alwaysCanParse, 'fr'); + const loader = new TranslationLoader(fs, [parser], 'error', diagnostics); + loader.loadBundles([[enTranslationPath], [frTranslationPath]], []); + expect(parser.log).toEqual([ + `canParse(${enTranslationPath}, ${enTranslationContent})`, + `parse(${enTranslationPath}, ${enTranslationContent})`, + `canParse(${frTranslationPath}, ${frTranslationContent})`, + `parse(${frTranslationPath}, ${frTranslationContent})`, + ]); + }); - it('should return locale and translations parsed from each file', () => { - const translations = {}; - const diagnostics = new Diagnostics(); - 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()}, - ]); - }); + it('should stop at the first parser that can parse each file', () => { + const diagnostics = new Diagnostics(); + const parser1 = new MockTranslationParser(neverCanParse); + const parser2 = new MockTranslationParser(alwaysCanParse, 'fr'); + const parser3 = new MockTranslationParser(alwaysCanParse, 'en'); + const loader = new TranslationLoader(fs, [parser1, parser2, parser3], 'error', diagnostics); + loader.loadBundles([[enTranslationPath], [frTranslationPath]], []); + expect(parser1.log).toEqual([ + `canParse(${enTranslationPath}, ${enTranslationContent})`, + `canParse(${frTranslationPath}, ${frTranslationContent})`, + ]); + expect(parser2.log).toEqual([ + `canParse(${enTranslationPath}, ${enTranslationContent})`, + `parse(${enTranslationPath}, ${enTranslationContent})`, + `canParse(${frTranslationPath}, ${frTranslationContent})`, + `parse(${frTranslationPath}, ${frTranslationContent})`, + ]); + }); - it('should return the provided locale if there is no parsed locale', () => { - const translations = {}; - const diagnostics = new 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']); - expect(result).toEqual([ - {locale: 'en', translations, diagnostics: new Diagnostics()}, - {locale: 'fr', translations, diagnostics: new Diagnostics()}, - ]); - }); + it('should return locale and translations parsed from each file', () => { + const translations = {}; + const diagnostics = new Diagnostics(); + const parser = new MockTranslationParser(alwaysCanParse, 'pl', translations); + const loader = new TranslationLoader(fs, [parser], 'error', diagnostics); + const result = loader.loadBundles([[enTranslationPath], [frTranslationPath]], []); + expect(result).toEqual([ + {locale: 'pl', translations, diagnostics: new Diagnostics()}, + {locale: 'pl', 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(), - }, - ]); - }); + it('should return the provided locale if there is no parsed locale', () => { + const translations = {}; + const diagnostics = new Diagnostics(); + const parser = new MockTranslationParser(alwaysCanParse, undefined, translations); + const loader = new TranslationLoader(fs, [parser], 'error', diagnostics); + const result = loader.loadBundles([[enTranslationPath], [frTranslationPath]], ['en', 'fr']); + expect(result).toEqual([ + {locale: 'en', translations, diagnostics: new Diagnostics()}, + {locale: 'fr', translations, 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(alwaysCanParse, 'pl', translations); - const loader = new TranslationLoader([parser], 'error', diagnostics); - loader.loadBundles( - [['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], [undefined, 'FR']); - expect(diagnostics.messages.length).toEqual(1); - expect(diagnostics.messages) - .toContain( - { - type: 'warning', - message: - `The provided locale "FR" does not match the target locale "pl" found in the translation file "/src/locale/messages.fr.xlf".`, - }, - ); - }); - - 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') + it('should merge multiple translation files, if given, for a each locale', () => { + const diagnostics = new Diagnostics(); + const loader = new TranslationLoader(fs, [jsonParser], 'error', diagnostics); + const result = loader.loadBundles([[frTranslationPath, frExtraTranslationPath]], []); + expect(result).toEqual([ + { + locale: 'fr', + translations: {'a': ɵparseTranslation('A'), 'b': ɵparseTranslation('B')}, + diagnostics: new Diagnostics(), }, - 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"].` - }]); - }); + 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 loader = new TranslationLoader(fs, [jsonParser], mode, diagnostics); + // Change the fs-extra file to have the same translations as fr. + fs.writeFile(frExtraTranslationPath, frTranslationContent); + const result = + loader.loadBundles([[frTranslationPath, frExtraTranslationPath]], []); + expect(result).toEqual([ + { + locale: 'fr', + translations: {'a': ɵparseTranslation('A')}, + diagnostics: jasmine.any(Diagnostics), + }, + ]); - it('should throw an error if there is no provided nor parsed target locale', () => { - const translations = {}; - const diagnostics = new Diagnostics(); - 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.'); - }); + if (mode === 'error' || mode === 'warning') { + expect(diagnostics.messages).toEqual([{ + type: mode, + message: `Duplicate translations for message "a" when merging "${ + frExtraTranslationPath}".` + }]); + } + })); - it('should error if none of the parsers can parse the file', () => { - const diagnostics = new 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']], [])) - .toThrowError( - 'There is no "TranslationParser" that can parse this translation file: /src/locale/messages.en.xlf.'); + it('should warn if the provided locales do not match the parsed locales', () => { + const diagnostics = new Diagnostics(); + const loader = new TranslationLoader(fs, [jsonParser], 'error', diagnostics); + loader.loadBundles([[enTranslationPath], [frTranslationPath]], [undefined, 'es']); + expect(diagnostics.messages.length).toEqual(1); + expect(diagnostics.messages) + .toContain( + { + type: 'warning', + message: + `The provided locale "es" does not match the target locale "fr" found in the translation file "${ + frTranslationPath}".`, + }, + ); + }); + + it('should warn on differing target locales when merging multiple translation files', () => { + const diagnostics = new Diagnostics(); + + const fr1 = absoluteFrom('/src/locale/messages-1.fr.json'); + fs.writeFile(fr1, '{"locale":"fr", "translations": {"a": "A"}}'); + + const fr2 = absoluteFrom('/src/locale/messages-2.fr.json'); + fs.writeFile(fr2, '{"locale":"fr", "translations": {"b": "B"}}'); + + const de = absoluteFrom('/src/locale/messages.de.json'); + fs.writeFile(de, '{"locale":"de", "translations": {"c": "C"}}'); + + const loader = new TranslationLoader(fs, [jsonParser], 'error', diagnostics); + + const result = loader.loadBundles([[fr1, fr2, de]], []); + 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 "${de}" ` + + `does not match the target locale "fr" found in earlier files ["${fr1}", "${fr2}"].` + }]); + }); + + it('should throw an error if there is no provided nor parsed target locale', () => { + const translations = {}; + const diagnostics = new Diagnostics(); + const parser = new MockTranslationParser(alwaysCanParse, undefined, translations); + const loader = new TranslationLoader(fs, [parser], 'error', diagnostics); + expect(() => loader.loadBundles([[enTranslationPath]], [])) + .toThrowError(`The translation file "${ + enTranslationPath}" 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(neverCanParse); + const loader = new TranslationLoader(fs, [parser], 'error', diagnostics); + expect(() => loader.loadBundles([[enTranslationPath], [frTranslationPath]], [])) + .toThrowError(`There is no "TranslationParser" that can parse this translation file: ${ + enTranslationPath}.`); + }); }); }); + + class MockTranslationParser implements TranslationParser { + log: string[] = []; + constructor( + private _canParse: (filePath: string) => boolean, private _locale?: string, + private _translations: Record = {}) {} + + canParse(filePath: string, fileContents: string) { + this.log.push(`canParse(${filePath}, ${fileContents})`); + return this._canParse(filePath); + } + + parse(filePath: string, fileContents: string) { + this.log.push(`parse(${filePath}, ${fileContents})`); + return { + locale: this._locale, + translations: this._translations, + diagnostics: new Diagnostics() + }; + } + } }); - -class MockTranslationParser implements TranslationParser { - log: string[] = []; - constructor( - private _canParse: (filePath: string) => boolean, private _locale?: string, - private _translations: Record = {}) {} - - canParse(filePath: string, fileContents: string) { - this.log.push(`canParse(${filePath}, ${fileContents})`); - return this._canParse(filePath); - } - - parse(filePath: string, fileContents: string) { - this.log.push(`parse(${filePath}, ${fileContents})`); - return {locale: this._locale, translations: this._translations, diagnostics: new Diagnostics()}; - } -} \ No newline at end of file diff --git a/packages/localize/src/tools/test/translate/translator_spec.ts b/packages/localize/src/tools/test/translate/translator_spec.ts index 17acdeba76..62763ddc24 100644 --- a/packages/localize/src/tools/test/translate/translator_spec.ts +++ b/packages/localize/src/tools/test/translate/translator_spec.ts @@ -5,111 +5,125 @@ * 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 {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, PathSegment, relativeFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + import {Diagnostics as Diagnostics} from '../../src/diagnostics'; -import {FileUtils} from '../../src/file_utils'; import {OutputPathFn} from '../../src/translate/output_path'; import {TranslationBundle, TranslationHandler, Translator} from '../../src/translate/translator'; -describe('Translator', () => { - describe('translateFiles()', () => { +runInEachFileSystem(() => { + describe('Translator', () => { + let fs: FileSystem; + let distDirectory: AbsoluteFsPath; + let imgDirectory: AbsoluteFsPath; + let file1Path: PathSegment; + let imgPath: PathSegment; + beforeEach(() => { - spyOn(FileUtils, 'readFileBuffer') - .and.returnValues(Buffer.from('resource file 1'), Buffer.from('resource file 2')); + fs = getFileSystem(); + distDirectory = absoluteFrom('/dist'); + imgDirectory = absoluteFrom('/dist/images'); + file1Path = relativeFrom('file1.js'); + imgPath = relativeFrom('images/img.gif'); + + fs.ensureDir(imgDirectory); + fs.writeFile(fs.resolve(distDirectory, file1Path), 'resource file 1'); + fs.writeFile(fs.resolve(distDirectory, imgPath), Buffer.from('resource file 2')); }); - it('should call FileUtils.readFileBuffer to load the resource file contents', () => { - const translator = new Translator([new MockTranslationHandler()], new Diagnostics()); - translator.translateFiles( - ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); - expect(FileUtils.readFileBuffer).toHaveBeenCalledWith('/dist/file1.js'); - expect(FileUtils.readFileBuffer).toHaveBeenCalledWith('/dist/images/img.gif'); - }); + describe('translateFiles()', () => { + it('should call FileSystem.readFileBuffer load the resource file contents', () => { + const translator = new Translator(fs, [new MockTranslationHandler()], new Diagnostics()); + spyOn(fs, 'readFileBuffer').and.callThrough(); + translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []); + expect(fs.readFileBuffer).toHaveBeenCalledWith(fs.resolve(distDirectory, file1Path)); + expect(fs.readFileBuffer).toHaveBeenCalledWith(fs.resolve(distDirectory, imgPath)); + }); - it('should call `canTranslate()` and `translate()` for each file', () => { - 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, []); + it('should call `canTranslate()` and `translate()` for each file', () => { + const diagnostics = new Diagnostics(); + const handler = new MockTranslationHandler(true); + const translator = new Translator(fs, [handler], diagnostics); + translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []); - expect(handler.log).toEqual([ - 'canTranslate(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, ...)', - ]); - }); + expect(handler.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + `translate(${distDirectory}, file1.js, resource file 1, ...)`, + 'canTranslate(images/img.gif, resource file 2)', + `translate(${distDirectory}, 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'); + it('should pass the sourceLocale through to `translate()` if provided', () => { + const diagnostics = new Diagnostics(); + const handler = new MockTranslationHandler(true); + const translator = new Translator(fs, [handler], diagnostics); + translator.translateFiles( + [file1Path, imgPath], distDirectory, 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)', - ]); - }); + expect(handler.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + `translate(${distDirectory}, file1.js, resource file 1, ..., en-US)`, + 'canTranslate(images/img.gif, resource file 2)', + `translate(${distDirectory}, images/img.gif, resource file 2, ..., en-US)`, + ]); + }); - it('should stop at the first handler that can handle each file', () => { - const diagnostics = new Diagnostics(); - const handler1 = new MockTranslationHandler(false); - const handler2 = new MockTranslationHandler(true); - const handler3 = new MockTranslationHandler(true); - const translator = new Translator([handler1, handler2, handler3], diagnostics); - translator.translateFiles( - ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); + it('should stop at the first handler that can handle each file', () => { + const diagnostics = new Diagnostics(); + const handler1 = new MockTranslationHandler(false); + const handler2 = new MockTranslationHandler(true); + const handler3 = new MockTranslationHandler(true); + const translator = new Translator(fs, [handler1, handler2, handler3], diagnostics); + translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []); - expect(handler1.log).toEqual([ - 'canTranslate(file1.js, resource file 1)', - 'canTranslate(images/img.gif, resource file 2)', - ]); - expect(handler2.log).toEqual([ - 'canTranslate(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, ...)', - ]); - }); + expect(handler1.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + 'canTranslate(images/img.gif, resource file 2)', + ]); + expect(handler2.log).toEqual([ + 'canTranslate(file1.js, resource file 1)', + `translate(${distDirectory}, file1.js, resource file 1, ...)`, + 'canTranslate(images/img.gif, resource file 2)', + `translate(${distDirectory}, images/img.gif, resource file 2, ...)`, + ]); + }); - it('should error if none of the handlers can handle the file', () => { - const diagnostics = new Diagnostics(); - const handler = new MockTranslationHandler(false); - const translator = new Translator([handler], diagnostics); + it('should error if none of the handlers can handle the file', () => { + const diagnostics = new Diagnostics(); + const handler = new MockTranslationHandler(false); + const translator = new Translator(fs, [handler], diagnostics); - translator.translateFiles( - ['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); + translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []); - expect(diagnostics.messages).toEqual([ - {type: 'error', message: 'Unable to handle resource file: /dist/file1.js'}, - {type: 'error', message: 'Unable to handle resource file: /dist/images/img.gif'}, - ]); + expect(diagnostics.messages).toEqual([ + {type: 'error', message: `Unable to handle resource file: ${file1Path}`}, + {type: 'error', message: `Unable to handle resource file: ${imgPath}`}, + ]); + }); }); }); + + class MockTranslationHandler implements TranslationHandler { + log: string[] = []; + constructor(private _canTranslate: boolean = true) {} + + canTranslate(relativePath: string, contents: Buffer) { + this.log.push(`canTranslate(${relativePath}, ${contents.toString('utf8')})`); + return this._canTranslate; + } + + 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})` : ')')); + } + } + + function mockOutputPathFn(locale: string, relativePath: string) { + return `translations/${locale}/${relativePath}`; + } }); - -class MockTranslationHandler implements TranslationHandler { - log: string[] = []; - constructor(private _canTranslate: boolean = true) {} - - canTranslate(relativePath: string, contents: Buffer) { - this.log.push(`canTranslate(${relativePath}, ${contents.toString('utf8')})`); - return this._canTranslate; - } - - 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})` : ')')); - } -} - -function mockOutputPathFn(locale: string, relativePath: string) { - return `translations/${locale}/${relativePath}`; -}