refactor(localize): use the `FileSystem` from ngtsc (#36843)

This commit makes the leap from its own custom baked `FileUtils`
solution to the fully formed `FileSystem` that is used in the compiler-cli.

This makes testing more straightforward and helps to ensure that the tool
will work across operatings systems.

Also, going forward, it will allow the localize project access to other useful
code from the compiler-cli, such as source-map handling.

PR Close #36843
This commit is contained in:
Pete Bacon Darwin 2020-04-28 21:03:27 +01:00 committed by Misko Hevery
parent dbf1f6b233
commit 141fcb95a4
17 changed files with 816 additions and 754 deletions

View File

@ -28,7 +28,11 @@
"glob": "7.1.2", "glob": "7.1.2",
"yargs": "15.3.0" "yargs": "15.3.0"
}, },
"publishConfig":{ "peerDependencies": {
"registry":"https://wombat-dressing-room.appspot.com" "@angular/compiler": "0.0.0-PLACEHOLDER",
"@angular/compiler-cli": "0.0.0-PLACEHOLDER"
},
"publishConfig": {
"registry": "https://wombat-dressing-room.appspot.com"
} }
} }

View File

@ -19,6 +19,7 @@ ts_library(
tsconfig = ":tsconfig", tsconfig = ":tsconfig",
deps = [ deps = [
"//packages/compiler", "//packages/compiler",
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/localize", "//packages/localize",
"@npm//@babel/core", "@npm//@babel/core",
"@npm//@babel/types", "@npm//@babel/types",

View File

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

View File

@ -5,8 +5,9 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {Diagnostics} from '../../diagnostics';
import {FileUtils} from '../../file_utils';
import {OutputPathFn} from '../output_path'; import {OutputPathFn} from '../output_path';
import {TranslationBundle, TranslationHandler} from '../translator'; 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. * Translate an asset file by simply copying it to the appropriate translation output paths.
*/ */
export class AssetTranslationHandler implements TranslationHandler { export class AssetTranslationHandler implements TranslationHandler {
canTranslate(_relativeFilePath: string, _contents: Buffer): boolean { constructor(private fs: FileSystem) {}
canTranslate(_relativeFilePath: PathSegment, _contents: Buffer): boolean {
return true; return true;
} }
translate( translate(
diagnostics: Diagnostics, _sourceRoot: string, relativeFilePath: string, contents: Buffer, diagnostics: Diagnostics, _sourceRoot: AbsoluteFsPath, relativeFilePath: PathSegment,
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void { contents: Buffer, outputPathFn: OutputPathFn, translations: TranslationBundle[],
sourceLocale?: string): void {
for (const translation of translations) { for (const translation of translations) {
try { this.writeAssetFile(
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents); diagnostics, outputPathFn, translation.locale, relativeFilePath, contents);
} catch (e) {
diagnostics.error(e.message);
}
} }
if (sourceLocale !== undefined) { if (sourceLocale !== undefined) {
try { this.writeAssetFile(diagnostics, outputPathFn, sourceLocale, relativeFilePath, contents);
FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents); }
} catch (e) { }
diagnostics.error(e.message);
} 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);
} }
} }
} }

View File

@ -6,8 +6,8 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 * as glob from 'glob';
import {resolve} from 'path';
import * as yargs from 'yargs'; import * as yargs from 'yargs';
import {DiagnosticHandlingStrategy, Diagnostics} from '../diagnostics'; import {DiagnosticHandlingStrategy, Diagnostics} from '../diagnostics';
@ -66,8 +66,9 @@ if (require.main === module) {
.option('o', { .option('o', {
alias: 'outputPath', alias: 'outputPath',
required: true, required: true,
describe: describe: 'A output path pattern to where the translated files will be written.\n' +
'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}}`.' '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', { .option('m', {
@ -88,11 +89,13 @@ if (require.main === module) {
.help() .help()
.parse(args); .parse(args);
const fs = new NodeJSFileSystem();
setFileSystem(fs);
const sourceRootPath = options['r']; const sourceRootPath = options['r'];
const sourceFilePaths = const sourceFilePaths = glob.sync(options['s'], {cwd: sourceRootPath, nodir: true});
glob.sync(options['s'], {absolute: true, cwd: sourceRootPath, nodir: true});
const translationFilePaths: (string|string[])[] = convertArraysFromArgs(options['t']); const translationFilePaths: (string|string[])[] = convertArraysFromArgs(options['t']);
const outputPathFn = getOutputPathFn(options['o']); const outputPathFn = getOutputPathFn(fs.resolve(options['o']));
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const missingTranslation: DiagnosticHandlingStrategy = options['m']; const missingTranslation: DiagnosticHandlingStrategy = options['m'];
const duplicateTranslation: DiagnosticHandlingStrategy = options['d']; const duplicateTranslation: DiagnosticHandlingStrategy = options['d'];
@ -154,8 +157,9 @@ export interface TranslateFilesOptions {
*/ */
translationFileLocales: (string|undefined)[]; translationFileLocales: (string|undefined)[];
/** /**
* A function that computes the output path of where the translated files will be written. * A function that computes the output path of where the translated files will be
* The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`. * written. The marker `{{LOCALE}}` will be replaced with the target locale. E.g.
* `dist/{{LOCALE}}`.
*/ */
outputPathFn: OutputPathFn; outputPathFn: OutputPathFn;
/** /**
@ -189,7 +193,9 @@ export function translateFiles({
duplicateTranslation, duplicateTranslation,
sourceLocale sourceLocale
}: TranslateFilesOptions) { }: TranslateFilesOptions) {
const fs = getFileSystem();
const translationLoader = new TranslationLoader( const translationLoader = new TranslationLoader(
fs,
[ [
new Xliff2TranslationParser(), new Xliff2TranslationParser(),
new Xliff1TranslationParser(), new Xliff1TranslationParser(),
@ -199,21 +205,24 @@ export function translateFiles({
duplicateTranslation, diagnostics); duplicateTranslation, diagnostics);
const resourceProcessor = new Translator( const resourceProcessor = new Translator(
fs,
[ [
new SourceFileTranslationHandler({missingTranslation}), new SourceFileTranslationHandler(fs, {missingTranslation}),
new AssetTranslationHandler(), new AssetTranslationHandler(fs),
], ],
diagnostics); diagnostics);
// Convert all the `translationFilePaths` elements to arrays. // Convert all the `translationFilePaths` elements to arrays.
const translationFilePathsArrays = const translationFilePathsArrays = translationFilePaths.map(
translationFilePaths.map(filePaths => Array.isArray(filePaths) ? filePaths : [filePaths]); filePaths =>
Array.isArray(filePaths) ? filePaths.map(p => fs.resolve(p)) : [fs.resolve(filePaths)]);
const translations = const translations =
translationLoader.loadBundles(translationFilePathsArrays, translationFileLocales); translationLoader.loadBundles(translationFilePathsArrays, translationFileLocales);
sourceRootPath = resolve(sourceRootPath); sourceRootPath = fs.resolve(sourceRootPath);
resourceProcessor.translateFiles( resourceProcessor.translateFiles(
sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale); sourceFilePaths.map(relativeFrom), fs.resolve(sourceRootPath), outputPathFn, translations,
sourceLocale);
} }
/** /**

View File

@ -5,8 +5,13 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
import {join} from 'path'; 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 { export interface OutputPathFn {
(locale: string, relativePath: string): string; (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. * 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. * @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}}'); const [pre, post] = outputFolder.split('{{LOCALE}}');
return post === undefined ? (_locale, relativePath) => join(pre, relativePath) : return post === undefined ? (_locale, relativePath) => join(pre, relativePath) :
(locale, relativePath) => join(pre + locale + post, relativePath); (locale, relativePath) => join(pre + locale + post, relativePath);

View File

@ -5,14 +5,15 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {parseSync, transformFromAstSync} from '@babel/core';
import {File, Program} from '@babel/types'; import {File, Program} from '@babel/types';
import {extname, join} from 'path';
import {Diagnostics} from '../../diagnostics'; import {Diagnostics} from '../../diagnostics';
import {FileUtils} from '../../file_utils';
import {TranslatePluginOptions} from '../../source_file_utils'; import {TranslatePluginOptions} from '../../source_file_utils';
import {OutputPathFn} from '../output_path'; import {OutputPathFn} from '../output_path';
import {TranslationBundle, TranslationHandler} from '../translator'; import {TranslationBundle, TranslationHandler} from '../translator';
import {makeEs2015TranslatePlugin} from './es2015_translate_plugin'; import {makeEs2015TranslatePlugin} from './es2015_translate_plugin';
import {makeEs5TranslatePlugin} from './es5_translate_plugin'; import {makeEs5TranslatePlugin} from './es5_translate_plugin';
import {makeLocalePlugin} from './locale_plugin'; import {makeLocalePlugin} from './locale_plugin';
@ -24,29 +25,32 @@ import {makeLocalePlugin} from './locale_plugin';
export class SourceFileTranslationHandler implements TranslationHandler { export class SourceFileTranslationHandler implements TranslationHandler {
private sourceLocaleOptions: private sourceLocaleOptions:
TranslatePluginOptions = {...this.translationOptions, missingTranslation: 'ignore'}; TranslatePluginOptions = {...this.translationOptions, missingTranslation: 'ignore'};
constructor(private translationOptions: TranslatePluginOptions = {}) {} constructor(private fs: FileSystem, private translationOptions: TranslatePluginOptions = {}) {}
canTranslate(relativeFilePath: string, _contents: Buffer): boolean { canTranslate(relativeFilePath: PathSegment, _contents: Buffer): boolean {
return extname(relativeFilePath) === '.js'; return this.fs.extname(relativeFrom(relativeFilePath)) === '.js';
} }
translate( translate(
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer, diagnostics: Diagnostics, sourceRoot: AbsoluteFsPath, relativeFilePath: PathSegment,
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void { contents: Buffer, outputPathFn: OutputPathFn, translations: TranslationBundle[],
sourceLocale?: string): void {
const sourceCode = contents.toString('utf8'); const sourceCode = contents.toString('utf8');
// A short-circuit check to avoid parsing the file into an AST if it does not contain any // A short-circuit check to avoid parsing the file into an AST if it does not contain any
// `$localize` identifiers. // `$localize` identifiers.
if (!sourceCode.includes('$localize')) { if (!sourceCode.includes('$localize')) {
for (const translation of translations) { for (const translation of translations) {
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents); this.writeSourceFile(
diagnostics, outputPathFn, translation.locale, relativeFilePath, contents);
} }
if (sourceLocale !== undefined) { if (sourceLocale !== undefined) {
FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents); this.writeSourceFile(diagnostics, outputPathFn, sourceLocale, relativeFilePath, contents);
} }
} else { } else {
const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath}); const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath});
if (!ast) { 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; return;
} }
// Output a translated copy of the file for each locale. // Output a translated copy of the file for each locale.
@ -67,7 +71,7 @@ export class SourceFileTranslationHandler implements TranslationHandler {
private translateFile( private translateFile(
diagnostics: Diagnostics, ast: File|Program, translationBundle: TranslationBundle, diagnostics: Diagnostics, ast: File|Program, translationBundle: TranslationBundle,
sourceRoot: string, filename: string, outputPathFn: OutputPathFn, sourceRoot: AbsoluteFsPath, filename: PathSegment, outputPathFn: OutputPathFn,
options: TranslatePluginOptions) { options: TranslatePluginOptions) {
const translated = transformFromAstSync(ast, undefined, { const translated = transformFromAstSync(ast, undefined, {
compact: true, compact: true,
@ -80,10 +84,26 @@ export class SourceFileTranslationHandler implements TranslationHandler {
filename, filename,
}); });
if (translated && translated.code) { 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 { } else {
diagnostics.error(`Unable to translate source file: ${join(sourceRoot, filename)}`); diagnostics.error(`Unable to translate source file: ${this.fs.join(sourceRoot, filename)}`);
return; 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);
}
}
} }

View File

@ -5,8 +5,8 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {DiagnosticHandlingStrategy, Diagnostics} from '../../diagnostics';
import {FileUtils} from '../../file_utils';
import {TranslationBundle} from '../translator'; import {TranslationBundle} from '../translator';
import {TranslationParser} from './translation_parsers/translation_parser'; import {TranslationParser} from './translation_parsers/translation_parser';
@ -16,7 +16,7 @@ import {TranslationParser} from './translation_parsers/translation_parser';
*/ */
export class TranslationLoader { export class TranslationLoader {
constructor( constructor(
private translationParsers: TranslationParser<any>[], private fs: FileSystem, private translationParsers: TranslationParser<any>[],
private duplicateTranslation: DiagnosticHandlingStrategy, private duplicateTranslation: DiagnosticHandlingStrategy,
/** @deprecated */ private diagnostics?: Diagnostics) {} /** @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 * 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. * same, then a warning is reported.
*/ */
loadBundles(translationFilePaths: string[][], translationFileLocales: (string|undefined)[]): loadBundles(
TranslationBundle[] { translationFilePaths: AbsoluteFsPath[][],
translationFileLocales: (string|undefined)[]): TranslationBundle[] {
return translationFilePaths.map((filePaths, index) => { return translationFilePaths.map((filePaths, index) => {
const providedLocale = translationFileLocales[index]; const providedLocale = translationFileLocales[index];
return this.mergeBundles(filePaths, providedLocale); return this.mergeBundles(filePaths, providedLocale);
@ -53,8 +54,9 @@ export class TranslationLoader {
/** /**
* Load all the translations from the file at the given `filePath`. * Load all the translations from the file at the given `filePath`.
*/ */
private loadBundle(filePath: string, providedLocale: string|undefined): TranslationBundle { private loadBundle(filePath: AbsoluteFsPath, providedLocale: string|undefined):
const fileContents = FileUtils.readFile(filePath); TranslationBundle {
const fileContents = this.fs.readFile(filePath);
for (const translationParser of this.translationParsers) { for (const translationParser of this.translationParsers) {
const result = translationParser.canParse(filePath, fileContents); const result = translationParser.canParse(filePath, fileContents);
if (!result) { 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 * There is more than one `filePath` for this locale, so load each as a bundle and then merge them
* all together. * 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 bundles = filePaths.map(filePath => this.loadBundle(filePath, providedLocale));
const bundle = bundles[0]; const bundle = bundles[0];
for (let i = 1; i < bundles.length; i++) { for (let i = 1; i < bundles.length; i++) {

View File

@ -5,11 +5,10 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
import {relative} from 'path';
import {Diagnostics} from '../diagnostics'; import {Diagnostics} from '../diagnostics';
import {FileUtils} from '../file_utils';
import {OutputPathFn} from './output_path'; 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 relativeFilePath A relative path from the sourceRoot to the resource file to handle.
* @param contents The contents of the 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`, * Translate the file at `relativeFilePath` containing `contents`, using the given `translations`,
@ -54,8 +53,9 @@ export interface TranslationHandler {
* stripped out. * stripped out.
*/ */
translate( translate(
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer, diagnostics: Diagnostics, sourceRoot: AbsoluteFsPath, relativeFilePath: PathSegment,
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void; 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()`. * The file will be translated by the first handler that returns true for `canTranslate()`.
*/ */
export class Translator { export class Translator {
constructor(private resourceHandlers: TranslationHandler[], private diagnostics: Diagnostics) {} constructor(
private fs: FileSystem, private resourceHandlers: TranslationHandler[],
private diagnostics: Diagnostics) {}
translateFiles( translateFiles(
inputPaths: string[], rootPath: string, outputPathFn: OutputPathFn, inputPaths: PathSegment[], rootPath: AbsoluteFsPath, outputPathFn: OutputPathFn,
translations: TranslationBundle[], sourceLocale?: string): void { translations: TranslationBundle[], sourceLocale?: string): void {
inputPaths.forEach(inputPath => { inputPaths.forEach(inputPath => {
const contents = FileUtils.readFileBuffer(inputPath); const absInputPath = this.fs.resolve(rootPath, inputPath);
const relativePath = relative(rootPath, inputPath); const contents = this.fs.readFileBuffer(absInputPath);
const relativePath = this.fs.relative(rootPath, absInputPath);
for (const resourceHandler of this.resourceHandlers) { for (const resourceHandler of this.resourceHandlers) {
if (resourceHandler.canTranslate(relativePath, contents)) { if (resourceHandler.canTranslate(relativePath, contents)) {
return resourceHandler.translate( return resourceHandler.translate(

View File

@ -9,6 +9,8 @@ ts_library(
deps = [ deps = [
"//packages:types", "//packages:types",
"//packages/compiler", "//packages/compiler",
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/compiler-cli/src/ngtsc/file_system/testing",
"//packages/localize", "//packages/localize",
"//packages/localize/src/tools", "//packages/localize/src/tools",
"@npm//@babel/core", "@npm//@babel/core",

View File

@ -5,56 +5,70 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils';
import {AssetTranslationHandler} from '../../../src/translate/asset_files/asset_translation_handler'; import {AssetTranslationHandler} from '../../../src/translate/asset_files/asset_translation_handler';
import {TranslationBundle} from '../../../src/translate/translator'; import {TranslationBundle} from '../../../src/translate/translator';
describe('AssetTranslationHandler', () => { runInEachFileSystem(() => {
describe('canTranslate()', () => { describe('AssetTranslationHandler', () => {
it('should always return true', () => { let fs: FileSystem;
const handler = new AssetTranslationHandler(); let rootPath: AbsoluteFsPath;
expect(handler.canTranslate('relative/path', Buffer.from('contents'))).toBe(true); let filePath: PathSegment;
}); let enTranslationPath: AbsoluteFsPath;
}); let enUSTranslationPath: AbsoluteFsPath;
let frTranslationPath: AbsoluteFsPath;
describe('translate()', () => {
beforeEach(() => { beforeEach(() => {
spyOn(FileUtils, 'writeFile'); fs = getFileSystem();
spyOn(FileUtils, 'ensureDir'); 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', () => { describe('canTranslate()', () => {
const diagnostics = new Diagnostics(); it('should always return true', () => {
const handler = new AssetTranslationHandler(); const handler = new AssetTranslationHandler(fs);
const translations = [ expect(handler.canTranslate(filePath, Buffer.from('contents'))).toBe(true);
{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 write the translated file to the source locale if provided', () => { describe('translate()', () => {
const diagnostics = new Diagnostics(); it('should write the translated file for each translation locale', () => {
const handler = new AssetTranslationHandler(); const diagnostics = new Diagnostics();
const translations: TranslationBundle[] = []; const handler = new AssetTranslationHandler(fs);
const contents = Buffer.from('contents'); const translations = [
const sourceLocale = 'en-US'; {locale: 'en', translations: {}},
handler.translate( {locale: 'fr', translations: {}},
diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations, ];
sourceLocale); const contents = Buffer.from('contents');
handler.translate(
diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations);
expect(FileUtils.writeFile) expect(fs.readFileBuffer(enTranslationPath)).toEqual(contents);
.toHaveBeenCalledWith('/translations/en-US/relative/path', 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) { function mockOutputPathFn(locale: string, relativePath: string) {
return `/translations/${locale}/${relativePath}`; return `/translations/${locale}/${relativePath}`;
} }
});

View File

@ -8,6 +8,9 @@ ts_library(
), ),
deps = [ deps = [
"//packages:types", "//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", "//packages/localize/src/tools",
], ],
) )

View File

@ -5,208 +5,216 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils';
import {translateFiles} from '../../../src/translate/main'; import {translateFiles} from '../../../src/translate/main';
import {getOutputPathFn} from '../../../src/translate/output_path'; import {getOutputPathFn} from '../../../src/translate/output_path';
describe('translateFiles()', () => { runInEachFileSystem(() => {
const tmpDir = process.env.TEST_TMPDIR; describe('translateFiles()', () => {
if (tmpDir === undefined) return; 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)); testFilesDir = fs.resolve(testDir, 'test_files');
afterEach(() => { loadTestDirectory(fs, realResolve(__dirname, 'test_files'), testFilesDir);
FileUtils.remove(testDir); translationFilesDir = fs.resolve(testDir, 'test_files');
}); loadTestDirectory(fs, realResolve(__dirname, 'locales'), translationFilesDir);
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',
}); });
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'))) expect(diagnostics.messages.length).toEqual(0);
.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');
});
it('should translate and copy source-code files to the destination folders', () => { expect(fs.readFile(fs.resolve(testDir, 'fr', 'test-1.txt')))
const diagnostics = new Diagnostics(); .toEqual('Contents of test-1.txt');
const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); expect(fs.readFile(fs.resolve(testDir, 'fr', 'test-2.txt')))
translateFiles({ .toEqual('Contents of test-2.txt');
sourceRootPath: resolve(__dirname, 'test_files'), expect(fs.readFile(fs.resolve(testDir, 'de', 'test-1.txt')))
sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), .toEqual('Contents of test-1.txt');
outputPathFn, expect(fs.readFile(fs.resolve(testDir, 'de', 'test-2.txt')))
translationFilePaths: resolveAll( .toEqual('Contents of test-2.txt');
__dirname + '/locales', expect(fs.readFile(fs.resolve(testDir, 'es', 'test-1.txt')))
['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf', 'messages.it.xtb']), .toEqual('Contents of test-1.txt');
translationFileLocales: [], expect(fs.readFile(fs.resolve(testDir, 'es', 'test-2.txt')))
diagnostics, .toEqual('Contents of test-2.txt');
missingTranslation: 'error', expect(fs.readFile(fs.resolve(testDir, 'it', 'test-1.txt')))
duplicateTranslation: 'error', .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'))) expect(diagnostics.messages.length).toEqual(0);
.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+"!";`);
});
it('should translate and copy source-code files overriding the locales', () => { expect(fs.readFile(fs.resolve(testDir, 'fr', 'test.js')))
const diagnostics = new Diagnostics(); .toEqual(`var name="World";var message="Bonjour, "+name+"!";`);
const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); expect(fs.readFile(fs.resolve(testDir, 'de', 'test.js')))
translateFiles({ .toEqual(`var name="World";var message="Guten Tag, "+name+"!";`);
sourceRootPath: resolve(__dirname, 'test_files'), expect(fs.readFile(fs.resolve(testDir, 'es', 'test.js')))
sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), .toEqual(`var name="World";var message="Hola, "+name+"!";`);
outputPathFn, expect(fs.readFile(fs.resolve(testDir, 'it', 'test.js')))
translationFilePaths: resolveAll( .toEqual(`var name="World";var message="Ciao, "+name+"!";`);
__dirname + '/locales',
['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); it('should translate and copy source-code files overriding the locales', () => {
expect(diagnostics.messages).toContain({ const diagnostics = new Diagnostics();
type: 'warning', const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}'));
message: translateFiles({
`The provided locale "xde" does not match the target locale "de" found in the translation file "${ sourceRootPath: testFilesDir,
resolve(__dirname, 'locales', 'messages.de.json')}".` 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'))) it('should merge translation files, if more than one provided, and translate source-code', () => {
.toEqual(`var name="World";var message="Guten Tag, "+name+"!";`); const diagnostics = new Diagnostics();
expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js'))) const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}'));
.toEqual(`var name="World";var message="Hola, "+name+"!";`); translateFiles({
expect(FileUtils.readFile(resolve(testDir, 'fr', 'test.js'))) sourceRootPath: testFilesDir,
.toEqual(`var name="World";var message="Bonjour, "+name+"!";`); sourceFilePaths: ['test-extra.js'],
expect(FileUtils.readFile(resolve(testDir, 'it', 'test.js'))) outputPathFn,
.toEqual(`var name="World";var message="Ciao, "+name+"!";`); 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', () => { expect(diagnostics.messages.length).toEqual(1);
const diagnostics = new Diagnostics(); // There is no "extra" translation in the `es` locale translation file.
const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); expect(diagnostics.messages[0]).toEqual({
translateFiles({ type: 'error',
sourceRootPath: resolve(__dirname, 'test_files'), message: 'No translation found for "customExtra" ("Goodbye, {$PH}!").'
sourceFilePaths: resolveAll(__dirname + '/test_files', ['test-extra.js']), });
outputPathFn,
translationFilePaths: resolveAllRecursive( // The `de` locale translates the `customExtra` message because it is in the
__dirname + '/locales', // `messages-extra.de.json` file that was merged.
[['messages.de.json', 'messages-extra.de.json'], 'messages.es.xlf']), expect(fs.readFile(fs.resolve(testDir, 'de', 'test-extra.js')))
translationFileLocales: [], .toEqual(
diagnostics, `var name="World";var message="Guten Tag, "+name+"!";var message="Auf wiedersehen, "+name+"!";`);
missingTranslation: 'error', // The `es` locale does not translate `customExtra` because there is no translation for it.
duplicateTranslation: 'error', 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); it('should transform and/or copy files to the destination folders', () => {
// There is no "extra" translation in the `es` locale translation file. const diagnostics = new Diagnostics();
expect(diagnostics.messages[0]).toEqual({ const outputPathFn = getOutputPathFn(fs.resolve(testDir, '{{LOCALE}}'));
type: 'error', translateFiles({
message: 'No translation found for "customExtra" ("Goodbye, {$PH}!").' 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 function resolveAll(rootPath: string, paths: string[]): string[] {
// `messages-extra.de.json` file that was merged. return paths.map(p => fs.resolve(rootPath, p));
expect(FileUtils.readFile(resolve(testDir, 'de', 'test-extra.js'))) }
.toEqual( function resolveAllRecursive(
`var name="World";var message="Guten Tag, "+name+"!";var message="Auf wiedersehen, "+name+"!";`); rootPath: string, paths: (string|string[])[]): (string|string[])[] {
// The `es` locale does not translate `customExtra` because there is no translation for it. return paths.map(
expect(FileUtils.readFile(resolve(testDir, 'es', 'test-extra.js'))) p => Array.isArray(p) ? p.map(p2 => fs.resolve(rootPath, p2)) : fs.resolve(rootPath, p));
.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 => resolve(rootPath, p));
}
function resolveAllRecursive(rootPath: string, paths: (string|string[])[]): (string|string[])[] {
return paths.map(
p => Array.isArray(p) ? p.map(p2 => resolve(rootPath, p2)) : resolve(rootPath, p));
}

View File

@ -5,40 +5,38 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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'; import {getOutputPathFn} from '../../src/translate/output_path';
describe('getOutputPathFn()', () => { runInEachFileSystem(() => {
it('should return a function that joins the `outputPath` and the `relativePath`', () => { describe('getOutputPathFn()', () => {
const fn = getOutputPathFn('/output/path'); it('should return a function that joins the `outputPath` and the `relativePath`', () => {
expect(fn('en', 'relative/path')).toEqual('/output/path/relative/path'); const fn = getOutputPathFn(absoluteFrom('/output/path'));
expect(fn('en', '../parent/path')).toEqual('/output/parent/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');
});
});

View File

@ -5,128 +5,135 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils';
import {SourceFileTranslationHandler} from '../../../src/translate/source_files/source_file_translation_handler'; import {SourceFileTranslationHandler} from '../../../src/translate/source_files/source_file_translation_handler';
import {TranslationBundle} from '../../../src/translate/translator'; import {TranslationBundle} from '../../../src/translate/translator';
describe('SourceFileTranslationHandler', () => { runInEachFileSystem(() => {
describe('canTranslate()', () => { describe('SourceFileTranslationHandler', () => {
it('should return true if the path ends in ".js"', () => { let fs: FileSystem;
const handler = new SourceFileTranslationHandler(); let rootPath: AbsoluteFsPath;
expect(handler.canTranslate('relative/path', Buffer.from('contents'))).toBe(false); let filePath: PathSegment;
expect(handler.canTranslate('relative/path.js', Buffer.from('contents'))).toBe(true); let enTranslationPath: AbsoluteFsPath;
}); let enUSTranslationPath: AbsoluteFsPath;
}); let frTranslationPath: AbsoluteFsPath;
describe('translate()', () => {
beforeEach(() => { 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`', describe('canTranslate()', () => {
() => { it('should return true if the path ends in ".js"', () => {
const diagnostics = new Diagnostics(); const handler = new SourceFileTranslationHandler(fs);
const handler = new SourceFileTranslationHandler(); expect(handler.canTranslate(relativeFrom('relative/path'), Buffer.from('contents')))
const translations = [ .toBe(false);
{locale: 'en', translations: {}}, expect(handler.canTranslate(filePath, Buffer.from('contents'))).toBe(true);
{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);
}); });
it('should transform each $localize template tag and write it to the source locale if provided', 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(); const diagnostics = new Diagnostics();
const translations: TranslationBundle[] = []; const handler = new SourceFileTranslationHandler(fs);
const contents = Buffer.from( const translations = [
'$localize`a${1}b${2}c`;\n' + {locale: 'en', translations: {}},
'$localize(__makeTemplateObject(["a", "b", "c"], ["a", "b", "c"]), 1, 2);'); {locale: 'fr', translations: {}},
const output = '"a"+1+"b"+2+"c";"a"+1+"b"+2+"c";'; ];
handler.translate( const contents = Buffer.from('contents');
diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, handler.translate(
translations, 'en-US'); diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations);
expect(FileUtils.writeFile) expect(fs.readFileBuffer(enTranslationPath)).toEqual(contents);
.toHaveBeenCalledWith('/translations/en-US/relative/path.js', output); expect(fs.readFileBuffer(frTranslationPath)).toEqual(contents);
}); });
it('should transform `$localize.locale` identifiers', () => { 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 diagnostics = new Diagnostics();
const translations: TranslationBundle[] = [ const handler = new SourceFileTranslationHandler(fs);
{locale: 'fr', translations: {}}, const translations: TranslationBundle[] = [];
]; const contents = Buffer.from('contents');
const contents = Buffer.from( handler.translate(
'const x = $localize.locale;\n' + diagnostics, rootPath, filePath, contents, mockOutputPathFn, translations, 'en-US');
'const y = typeof $localize !== "undefined" && $localize.locale;\n' + expect(fs.readFileBuffer(enUSTranslationPath)).toEqual(contents);
'const z = "undefined" !== typeof $localize && $localize.locale || "default";'); });
const getOutput = (locale: string) =>
`const x="${locale}";const y="${locale}";const z="${locale}"||"default";`;
handler.translate( it('should transform each $localize template tag', () => {
diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, translations, const diagnostics = new Diagnostics();
'en-US'); 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) expect(fs.readFile(enTranslationPath)).toEqual(output);
.toHaveBeenCalledWith('/translations/fr/relative/path.js', getOutput('fr')); expect(fs.readFile(frTranslationPath)).toEqual(output);
expect(FileUtils.writeFile) });
.toHaveBeenCalledWith('/translations/en-US/relative/path.js', getOutput('en-US'));
});
it('should error if the file is not valid JS', () => { 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 diagnostics = new Diagnostics();
const translations = [{locale: 'en', translations: {}}]; const handler = new SourceFileTranslationHandler(fs);
const contents = Buffer.from('this is not a valid $localize file.'); const translations: TranslationBundle[] = [];
expect( const contents = Buffer.from(
() => handler.translate( '$localize`a${1}b${2}c`;\n' +
diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, '$localize(__makeTemplateObject(["a", "b", "c"], ["a", "b", "c"]), 1, 2);');
translations)) const output = '"a"+1+"b"+2+"c";"a"+1+"b"+2+"c";';
.toThrowError(); 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) { function mockOutputPathFn(locale: string, relativePath: string) {
return `/translations/${locale}/${relativePath}`; return `/translations/${locale}/${relativePath}`;
} }
});

View File

@ -5,216 +5,229 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {ɵParsedTranslation, ɵparseTranslation} from '@angular/localize';
import {DiagnosticHandlingStrategy, Diagnostics} from '../../../src/diagnostics'; import {DiagnosticHandlingStrategy, Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils';
import {TranslationLoader} from '../../../src/translate/translation_files/translation_loader'; import {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'; import {TranslationParser} from '../../../src/translate/translation_files/translation_parsers/translation_parser';
describe('TranslationLoader', () => { runInEachFileSystem(() => {
describe('loadBundles()', () => { describe('TranslationLoader', () => {
const alwaysCanParse = () => true; describe('loadBundles()', () => {
const neverCanParse = () => false; const alwaysCanParse = () => true;
const neverCanParse = () => false;
beforeEach(() => { let fs: FileSystem;
spyOn(FileUtils, 'readFile').and.returnValues('english messages', 'french messages'); 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', () => { beforeEach(() => {
const diagnostics = new Diagnostics(); fs = getFileSystem();
const parser = new MockTranslationParser(alwaysCanParse, 'fr'); enTranslationPath = absoluteFrom('/src/locale/messages.en.json');
const loader = new TranslationLoader([parser], 'error', diagnostics); frTranslationPath = absoluteFrom('/src/locale/messages.fr.json');
loader.loadBundles([['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], []); frExtraTranslationPath = absoluteFrom('/src/locale/extra.fr.json');
expect(parser.log).toEqual([ fs.ensureDir(absoluteFrom('/src/locale'));
'canParse(/src/locale/messages.en.xlf, english messages)', fs.writeFile(enTranslationPath, enTranslationContent);
'parse(/src/locale/messages.en.xlf, english messages)', fs.writeFile(frTranslationPath, frTranslationContent);
'canParse(/src/locale/messages.fr.xlf, french messages)', fs.writeFile(frExtraTranslationPath, frExtraTranslationContent);
'parse(/src/locale/messages.fr.xlf, french messages)', jsonParser = new SimpleJsonTranslationParser();
]); });
});
it('should stop at the first parser that can parse each file', () => { it('should call `canParse()` and `parse()` for each file', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const parser1 = new MockTranslationParser(neverCanParse); const parser = new MockTranslationParser(alwaysCanParse, 'fr');
const parser2 = new MockTranslationParser(alwaysCanParse, 'fr'); const loader = new TranslationLoader(fs, [parser], 'error', diagnostics);
const parser3 = new MockTranslationParser(alwaysCanParse, 'en'); loader.loadBundles([[enTranslationPath], [frTranslationPath]], []);
const loader = new TranslationLoader([parser1, parser2, parser3], 'error', diagnostics); expect(parser.log).toEqual([
loader.loadBundles([['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], []); `canParse(${enTranslationPath}, ${enTranslationContent})`,
expect(parser1.log).toEqual([ `parse(${enTranslationPath}, ${enTranslationContent})`,
'canParse(/src/locale/messages.en.xlf, english messages)', `canParse(${frTranslationPath}, ${frTranslationContent})`,
'canParse(/src/locale/messages.fr.xlf, french messages)', `parse(${frTranslationPath}, ${frTranslationContent})`,
]); ]);
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 return locale and translations parsed from each file', () => { it('should stop at the first parser that can parse each file', () => {
const translations = {}; const diagnostics = new Diagnostics();
const diagnostics = new Diagnostics(); const parser1 = new MockTranslationParser(neverCanParse);
const parser = new MockTranslationParser(alwaysCanParse, 'pl', translations); const parser2 = new MockTranslationParser(alwaysCanParse, 'fr');
const loader = new TranslationLoader([parser], 'error', diagnostics); const parser3 = new MockTranslationParser(alwaysCanParse, 'en');
const result = loader.loadBundles( const loader = new TranslationLoader(fs, [parser1, parser2, parser3], 'error', diagnostics);
[['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], []); loader.loadBundles([[enTranslationPath], [frTranslationPath]], []);
expect(result).toEqual([ expect(parser1.log).toEqual([
{locale: 'pl', translations, diagnostics: new Diagnostics()}, `canParse(${enTranslationPath}, ${enTranslationContent})`,
{locale: 'pl', translations, diagnostics: new Diagnostics()}, `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', () => { it('should return locale and translations parsed from each file', () => {
const translations = {}; const translations = {};
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(alwaysCanParse, undefined, translations); const parser = new MockTranslationParser(alwaysCanParse, 'pl', translations);
const loader = new TranslationLoader([parser], 'error', diagnostics); const loader = new TranslationLoader(fs, [parser], 'error', diagnostics);
const result = loader.loadBundles( const result = loader.loadBundles([[enTranslationPath], [frTranslationPath]], []);
[['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], ['en', 'fr']); expect(result).toEqual([
expect(result).toEqual([ {locale: 'pl', translations, diagnostics: new Diagnostics()},
{locale: 'en', translations, diagnostics: new Diagnostics()}, {locale: 'pl', translations, diagnostics: new Diagnostics()},
{locale: 'fr', translations, diagnostics: new Diagnostics()}, ]);
]); });
});
it('should merge multiple translation files, if given, for a each locale', () => { it('should return the provided locale if there is no parsed locale', () => {
const diagnostics = new Diagnostics(); const translations = {};
const parser1 = new MockTranslationParser( const diagnostics = new Diagnostics();
f => f.includes('messages.fr'), 'fr', {'a': ɵparseTranslation('A')}); const parser = new MockTranslationParser(alwaysCanParse, undefined, translations);
const parser2 = new MockTranslationParser( const loader = new TranslationLoader(fs, [parser], 'error', diagnostics);
f => f.includes('extra.fr'), 'fr', {'b': ɵparseTranslation('B')}); const result = loader.loadBundles([[enTranslationPath], [frTranslationPath]], ['en', 'fr']);
const loader = new TranslationLoader([parser1, parser2], 'error', diagnostics); expect(result).toEqual([
const result = {locale: 'en', translations, diagnostics: new Diagnostics()},
loader.loadBundles([['/src/locale/messages.fr.xlf', '/src/locale/extra.fr.xlf']], []); {locale: 'fr', translations, diagnostics: new Diagnostics()},
expect(result).toEqual([ ]);
{ });
locale: 'fr',
translations: {'a': ɵparseTranslation('A'), 'b': ɵparseTranslation('B')},
diagnostics: new Diagnostics(),
},
]);
});
const allDiagnosticModes: DiagnosticHandlingStrategy[] = ['ignore', 'warning', 'error']; it('should merge multiple translation files, if given, for a each locale', () => {
allDiagnosticModes.forEach( const diagnostics = new Diagnostics();
mode => it( const loader = new TranslationLoader(fs, [jsonParser], 'error', diagnostics);
`should ${mode} on duplicate messages when merging multiple translation files`, () => { const result = loader.loadBundles([[frTranslationPath, frExtraTranslationPath]], []);
const diagnostics = new Diagnostics(); expect(result).toEqual([
const parser1 = new MockTranslationParser( {
f => f.includes('messages.fr'), 'fr', {'a': ɵparseTranslation('A')}); locale: 'fr',
const parser2 = new MockTranslationParser( translations: {'a': ɵparseTranslation('A'), 'b': ɵparseTranslation('B')},
f => f.includes('extra.fr'), 'fr', {'a': ɵparseTranslation('B')}); diagnostics: new Diagnostics(),
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')
}, },
diagnostics: jasmine.any(Diagnostics), ]);
}, });
]);
expect(diagnostics.messages).toEqual([{ const allDiagnosticModes: DiagnosticHandlingStrategy[] = ['ignore', 'warning', 'error'];
type: 'warning', allDiagnosticModes.forEach(
message: mode =>
`When merging multiple translation files, the target locale "de" found in "/src/locale/messages.de.xlf" ` + it(`should ${mode} on duplicate messages when merging multiple translation files`,
`does not match the target locale "fr" found in earlier files ["/src/locale/messages-1.fr.xlf", "/src/locale/messages-2.fr.xlf"].` () => {
}]); 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', () => { if (mode === 'error' || mode === 'warning') {
const translations = {}; expect(diagnostics.messages).toEqual([{
const diagnostics = new Diagnostics(); type: mode,
const parser = new MockTranslationParser(alwaysCanParse, undefined, translations); message: `Duplicate translations for message "a" when merging "${
const loader = new TranslationLoader([parser], 'error', diagnostics); frExtraTranslationPath}".`
expect(() => loader.loadBundles([['/src/locale/messages.en.xlf']], [])) }]);
.toThrowError( }
'The translation file "/src/locale/messages.en.xlf" does not contain a target locale and no explicit locale was provided for this file.'); }));
});
it('should error if none of the parsers can parse the file', () => { it('should warn if the provided locales do not match the parsed locales', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(neverCanParse); const loader = new TranslationLoader(fs, [jsonParser], 'error', diagnostics);
const loader = new TranslationLoader([parser], 'error', diagnostics); loader.loadBundles([[enTranslationPath], [frTranslationPath]], [undefined, 'es']);
expect( expect(diagnostics.messages.length).toEqual(1);
() => loader.loadBundles( expect(diagnostics.messages)
[['/src/locale/messages.en.xlf'], ['/src/locale/messages.fr.xlf']], [])) .toContain(
.toThrowError( {
'There is no "TranslationParser" that can parse this translation file: /src/locale/messages.en.xlf.'); 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<string, ɵParsedTranslation> = {}) {}
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<string, ɵParsedTranslation> = {}) {}
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()};
}
}

View File

@ -5,111 +5,125 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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 {Diagnostics as Diagnostics} from '../../src/diagnostics';
import {FileUtils} from '../../src/file_utils';
import {OutputPathFn} from '../../src/translate/output_path'; import {OutputPathFn} from '../../src/translate/output_path';
import {TranslationBundle, TranslationHandler, Translator} from '../../src/translate/translator'; import {TranslationBundle, TranslationHandler, Translator} from '../../src/translate/translator';
describe('Translator', () => { runInEachFileSystem(() => {
describe('translateFiles()', () => { describe('Translator', () => {
let fs: FileSystem;
let distDirectory: AbsoluteFsPath;
let imgDirectory: AbsoluteFsPath;
let file1Path: PathSegment;
let imgPath: PathSegment;
beforeEach(() => { beforeEach(() => {
spyOn(FileUtils, 'readFileBuffer') fs = getFileSystem();
.and.returnValues(Buffer.from('resource file 1'), Buffer.from('resource file 2')); 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', () => { describe('translateFiles()', () => {
const translator = new Translator([new MockTranslationHandler()], new Diagnostics()); it('should call FileSystem.readFileBuffer load the resource file contents', () => {
translator.translateFiles( const translator = new Translator(fs, [new MockTranslationHandler()], new Diagnostics());
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []); spyOn(fs, 'readFileBuffer').and.callThrough();
expect(FileUtils.readFileBuffer).toHaveBeenCalledWith('/dist/file1.js'); translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []);
expect(FileUtils.readFileBuffer).toHaveBeenCalledWith('/dist/images/img.gif'); 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', () => { it('should call `canTranslate()` and `translate()` for each file', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const handler = new MockTranslationHandler(true); const handler = new MockTranslationHandler(true);
const translator = new Translator([handler], diagnostics); const translator = new Translator(fs, [handler], diagnostics);
translator.translateFiles( translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []);
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []);
expect(handler.log).toEqual([ expect(handler.log).toEqual([
'canTranslate(file1.js, resource file 1)', 'canTranslate(file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1, ...)', `translate(${distDirectory}, file1.js, resource file 1, ...)`,
'canTranslate(images/img.gif, resource file 2)', 'canTranslate(images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2, ...)', `translate(${distDirectory}, images/img.gif, resource file 2, ...)`,
]); ]);
}); });
it('should pass the sourceLocale through to `translate()` if provided', () => { it('should pass the sourceLocale through to `translate()` if provided', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const handler = new MockTranslationHandler(true); const handler = new MockTranslationHandler(true);
const translator = new Translator([handler], diagnostics); const translator = new Translator(fs, [handler], diagnostics);
translator.translateFiles( translator.translateFiles(
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, [], 'en-US'); [file1Path, imgPath], distDirectory, mockOutputPathFn, [], 'en-US');
expect(handler.log).toEqual([ expect(handler.log).toEqual([
'canTranslate(file1.js, resource file 1)', 'canTranslate(file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1, ..., en-US)', `translate(${distDirectory}, file1.js, resource file 1, ..., en-US)`,
'canTranslate(images/img.gif, resource file 2)', 'canTranslate(images/img.gif, resource file 2)',
'translate(/dist, images/img.gif, resource file 2, ..., en-US)', `translate(${distDirectory}, images/img.gif, resource file 2, ..., en-US)`,
]); ]);
}); });
it('should stop at the first handler that can handle each file', () => { it('should stop at the first handler that can handle each file', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const handler1 = new MockTranslationHandler(false); const handler1 = new MockTranslationHandler(false);
const handler2 = new MockTranslationHandler(true); const handler2 = new MockTranslationHandler(true);
const handler3 = new MockTranslationHandler(true); const handler3 = new MockTranslationHandler(true);
const translator = new Translator([handler1, handler2, handler3], diagnostics); const translator = new Translator(fs, [handler1, handler2, handler3], diagnostics);
translator.translateFiles( translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []);
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []);
expect(handler1.log).toEqual([ expect(handler1.log).toEqual([
'canTranslate(file1.js, resource file 1)', 'canTranslate(file1.js, resource file 1)',
'canTranslate(images/img.gif, resource file 2)', 'canTranslate(images/img.gif, resource file 2)',
]); ]);
expect(handler2.log).toEqual([ expect(handler2.log).toEqual([
'canTranslate(file1.js, resource file 1)', 'canTranslate(file1.js, resource file 1)',
'translate(/dist, file1.js, resource file 1, ...)', `translate(${distDirectory}, file1.js, resource file 1, ...)`,
'canTranslate(images/img.gif, resource file 2)', 'canTranslate(images/img.gif, resource file 2)',
'translate(/dist, 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', () => { it('should error if none of the handlers can handle the file', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const handler = new MockTranslationHandler(false); const handler = new MockTranslationHandler(false);
const translator = new Translator([handler], diagnostics); const translator = new Translator(fs, [handler], diagnostics);
translator.translateFiles( translator.translateFiles([file1Path, imgPath], distDirectory, mockOutputPathFn, []);
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, []);
expect(diagnostics.messages).toEqual([ expect(diagnostics.messages).toEqual([
{type: 'error', message: 'Unable to handle resource file: /dist/file1.js'}, {type: 'error', message: `Unable to handle resource file: ${file1Path}`},
{type: 'error', message: 'Unable to handle resource file: /dist/images/img.gif'}, {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}`;
}