fix(ivy): i18n - support setting locales for each translation file (#33381)

Previously the target locale of a translation file had to be extracted
from the contents of the translation file. Therefore it was an error if
the translation file did not provide a target locale.

Now an array of locales can be provided via the `translationFileLocales`
option that overrides any target locale extracted from the file.
This allows us to support translation files that do not have a target
locale specified in their contents.

// FW-1644
Fixes #33323

PR Close #33381
This commit is contained in:
Pete Bacon Darwin 2019-10-24 19:57:13 +01:00 committed by Andrew Kushnir
parent e23bc4991d
commit 62b2840822
14 changed files with 259 additions and 103 deletions

View File

@ -10,8 +10,6 @@ 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';
/** /**
* 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.
*/ */

View File

@ -14,7 +14,7 @@ import {AssetTranslationHandler} from './asset_files/asset_translation_handler';
import {getOutputPathFn, OutputPathFn} from './output_path'; import {getOutputPathFn, OutputPathFn} from './output_path';
import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler'; import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler';
import {MissingTranslationStrategy} from './source_files/source_file_utils'; import {MissingTranslationStrategy} from './source_files/source_file_utils';
import {TranslationLoader} from './translation_files/translation_file_loader'; import {TranslationLoader} from './translation_files/translation_loader';
import {SimpleJsonTranslationParser} from './translation_files/translation_parsers/simple_json/simple_json_translation_parser'; import {SimpleJsonTranslationParser} from './translation_files/translation_parsers/simple_json/simple_json_translation_parser';
import {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1/xliff1_translation_parser'; import {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1/xliff1_translation_parser';
import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2/xliff2_translation_parser'; import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2/xliff2_translation_parser';
@ -50,18 +50,21 @@ if (require.main === module) {
describe: describe:
'A glob pattern indicating what translation files to load, either absolute or relative to the current working directory. E.g. `my_proj/src/locale/messages.*.xlf.', 'A glob pattern indicating what translation files to load, either absolute or relative to the current working directory. E.g. `my_proj/src/locale/messages.*.xlf.',
}) })
.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. The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.' '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}}`.'
}) })
.option('m', { .option('m', {
alias: 'missingTranslation', alias: 'missingTranslation',
describe: 'How to handle missing translations.', describe: 'How to handle missing translations.',
choices: ['error', 'warning', 'ignore'], choices: ['error', 'warning', 'ignore'],
default: 'warning', default: 'warning',
}) })
.help() .help()
.parse(args); .parse(args);
@ -73,32 +76,67 @@ if (require.main === module) {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const missingTranslation: MissingTranslationStrategy = options['m']; const missingTranslation: MissingTranslationStrategy = options['m'];
const sourceLocale: string|undefined = options['l']; const sourceLocale: string|undefined = options['l'];
// For CLI we do not have a way to specify the locale of the translation files
// It must be extracted from the file itself.
const translationFileLocales: string[] = [];
translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, diagnostics, translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, translationFileLocales,
missingTranslation, sourceLocale}); outputPathFn, diagnostics, missingTranslation, sourceLocale});
diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`)); diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`));
process.exit(diagnostics.hasErrors ? 1 : 0); process.exit(diagnostics.hasErrors ? 1 : 0);
} }
export interface TranslateFilesOptions { export interface TranslateFilesOptions {
/**
* The root path of the files to translate, either absolute or relative to the current working
* directory. E.g. `dist/en`
*/
sourceRootPath: string; sourceRootPath: string;
/**
* The files to translate, relative to the `root` path.
*/
sourceFilePaths: string[]; sourceFilePaths: string[];
/**
* An array of paths to the translation files to load, either absolute or relative to the current
* working directory.
*/
translationFilePaths: string[]; translationFilePaths: string[];
/**
* A collection of the target locales for the translation files.
*/
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}}`.
*/
outputPathFn: OutputPathFn; outputPathFn: OutputPathFn;
/**
* An object that will receive any diagnostics messages due to the processing.
*/
diagnostics: Diagnostics; diagnostics: Diagnostics;
/**
* How to handle missing translations.
*/
missingTranslation: MissingTranslationStrategy; missingTranslation: MissingTranslationStrategy;
/**
* The locale of the source files.
* If this is provided then a copy of the application will be created with no translation but just
* the `$localize` calls stripped out.
*/
sourceLocale?: string; sourceLocale?: string;
} }
export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths,
diagnostics, missingTranslation, translationFileLocales, outputPathFn, diagnostics,
sourceLocale}: TranslateFilesOptions) { missingTranslation, sourceLocale}: TranslateFilesOptions) {
const translationLoader = new TranslationLoader([ const translationLoader = new TranslationLoader(
new Xliff2TranslationParser(), [
new Xliff1TranslationParser(), new Xliff2TranslationParser(),
new SimpleJsonTranslationParser(), new Xliff1TranslationParser(),
]); new SimpleJsonTranslationParser(),
],
diagnostics);
const resourceProcessor = new Translator( const resourceProcessor = new Translator(
[ [
@ -107,7 +145,7 @@ export function translateFiles({sourceRootPath, sourceFilePaths, translationFile
], ],
diagnostics); diagnostics);
const translations = translationLoader.loadBundles(translationFilePaths); const translations = translationLoader.loadBundles(translationFilePaths, translationFileLocales);
sourceRootPath = resolve(sourceRootPath); sourceRootPath = resolve(sourceRootPath);
resourceProcessor.translateFiles( resourceProcessor.translateFiles(
sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale); sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale);

View File

@ -5,23 +5,18 @@
* 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 {ɵMessageId, ɵParsedTranslation} from '@angular/localize/private';
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 {extname, join} from 'path';
import {Diagnostics} from '../../diagnostics'; import {Diagnostics} from '../../diagnostics';
import {FileUtils} from '../../file_utils'; 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';
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';
import {TranslatePluginOptions} from './source_file_utils'; import {TranslatePluginOptions} from './source_file_utils';
/** /**
* Translate a file by inlining all messages tagged by `$localize` with the appropriate translated * Translate a file by inlining all messages tagged by `$localize` with the appropriate translated
* message. * message.

View File

@ -1,34 +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 {FileUtils} from '../../file_utils';
import {TranslationBundle} from '../translator';
import {TranslationParser} from './translation_parsers/translation_parser';
/**
* Use this class to load a collection of translation files from disk.
*/
export class TranslationLoader {
constructor(private translationParsers: TranslationParser[]) {}
/**
* Load and parse the translation files into a collection of `TranslationBundles`.
*
* @param translationFilePaths A collection of absolute paths to the translation files.
*/
loadBundles(translationFilePaths: string[]): TranslationBundle[] {
return translationFilePaths.map(filePath => {
const fileContents = FileUtils.readFile(filePath);
for (const translationParser of this.translationParsers) {
if (translationParser.canParse(filePath, fileContents)) {
return translationParser.parse(filePath, fileContents);
}
}
throw new Error(`Unable to parse translation file: ${filePath}`);
});
}
}

View File

@ -0,0 +1,58 @@
/**
* @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 {Diagnostics} from '../../diagnostics';
import {FileUtils} from '../../file_utils';
import {TranslationBundle} from '../translator';
import {TranslationParser} from './translation_parsers/translation_parser';
/**
* Use this class to load a collection of translation files from disk.
*/
export class TranslationLoader {
constructor(private translationParsers: TranslationParser[], private diagnostics: Diagnostics) {}
/**
* Load and parse the translation files into a collection of `TranslationBundles`.
*
* If there is a locale provided in `translationFileLocales` then this is used rather than the
* locale extracted from the file itself.
* If there is neither a provided locale nor a locale parsed from the file, then an error is
* thrown.
* 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 .
*
* @param translationFilePaths An array of absolute paths to the translation files.
* @param translationFileLocales An array of locales for each of the translation files.
*/
loadBundles(translationFilePaths: string[], translationFileLocales: (string|undefined)[]):
TranslationBundle[] {
return translationFilePaths.map((filePath, index) => {
const fileContents = FileUtils.readFile(filePath);
for (const translationParser of this.translationParsers) {
if (translationParser.canParse(filePath, fileContents)) {
const providedLocale = translationFileLocales[index];
const {locale: parsedLocale, translations} =
translationParser.parse(filePath, fileContents);
const locale = providedLocale || parsedLocale;
if (locale === undefined) {
throw new Error(
`The translation file "${filePath}" does not contain a target locale and no explicit locale was provided for this file.`);
}
if (parsedLocale !== undefined && providedLocale !== undefined &&
parsedLocale !== providedLocale) {
this.diagnostics.warn(
`The provided locale "${providedLocale}" does not match the target locale "${parsedLocale}" found in the translation file "${filePath}".`);
}
return {locale, translations};
}
}
throw new Error(
`There is no "TranslationParser" that can parse this translation file: ${filePath}.`);
});
}
}

View File

@ -7,8 +7,7 @@
*/ */
import {ɵMessageId, ɵParsedTranslation, ɵparseTranslation} from '@angular/localize'; import {ɵMessageId, ɵParsedTranslation, ɵparseTranslation} from '@angular/localize';
import {extname} from 'path'; import {extname} from 'path';
import {TranslationBundle} from '../../../translator'; import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {TranslationParser} from '../translation_parser';
/** /**
* A translation parser that can parse JSON that has the form: * A translation parser that can parse JSON that has the form:
@ -26,13 +25,13 @@ import {TranslationParser} from '../translation_parser';
export class SimpleJsonTranslationParser implements TranslationParser { export class SimpleJsonTranslationParser implements TranslationParser {
canParse(filePath: string, _contents: string): boolean { return (extname(filePath) === '.json'); } canParse(filePath: string, _contents: string): boolean { return (extname(filePath) === '.json'); }
parse(_filePath: string, contents: string): TranslationBundle { parse(_filePath: string, contents: string): ParsedTranslationBundle {
const {locale, translations} = JSON.parse(contents); const {locale: parsedLocale, translations} = JSON.parse(contents);
const parsedTranslations: Record<ɵMessageId, ɵParsedTranslation> = {}; const parsedTranslations: Record<ɵMessageId, ɵParsedTranslation> = {};
for (const messageId in translations) { for (const messageId in translations) {
const targetMessage = translations[messageId]; const targetMessage = translations[messageId];
parsedTranslations[messageId] = ɵparseTranslation(targetMessage); parsedTranslations[messageId] = ɵparseTranslation(targetMessage);
} }
return {locale, translations: parsedTranslations}; return {locale: parsedLocale, translations: parsedTranslations};
} }
} }

View File

@ -5,7 +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 {TranslationBundle} from '../../translator'; import {ɵMessageId, ɵParsedTranslation} from '@angular/localize/private';
/**
* An object that holds translations that have been parsed from a translation file.
*/
export interface ParsedTranslationBundle {
locale: string|undefined;
translations: Record<ɵMessageId, ɵParsedTranslation>;
}
/** /**
* Implement this interface to provide a class that can parse the contents of a translation file. * Implement this interface to provide a class that can parse the contents of a translation file.
@ -25,5 +33,5 @@ export interface TranslationParser {
* @param filePath The absolute path to the translation file. * @param filePath The absolute path to the translation file.
* @param contents The contents of the translation file. * @param contents The contents of the translation file.
*/ */
parse(filePath: string, contents: string): TranslationBundle; parse(filePath: string, contents: string): ParsedTranslationBundle;
} }

View File

@ -9,11 +9,10 @@ import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path'; import {extname} from 'path';
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer'; import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';
import {TranslationBundle} from '../../../translator';
import {BaseVisitor} from '../base_visitor'; import {BaseVisitor} from '../base_visitor';
import {TranslationParseError} from '../translation_parse_error'; import {TranslationParseError} from '../translation_parse_error';
import {TranslationParser} from '../translation_parser'; import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {getAttrOrThrow, parseInnerRange} from '../translation_utils'; import {getAttrOrThrow, getAttribute, parseInnerRange} from '../translation_utils';
import {Xliff1MessageSerializer} from './xliff1_message_serializer'; import {Xliff1MessageSerializer} from './xliff1_message_serializer';
const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/; const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/;
@ -30,7 +29,7 @@ export class Xliff1TranslationParser implements TranslationParser {
return (extname(filePath) === '.xlf') && XLIFF_1_2_NS_REGEX.test(contents); return (extname(filePath) === '.xlf') && XLIFF_1_2_NS_REGEX.test(contents);
} }
parse(filePath: string, contents: string): TranslationBundle { parse(filePath: string, contents: string): ParsedTranslationBundle {
const xmlParser = new XmlParser(); const xmlParser = new XmlParser();
const xml = xmlParser.parse(contents, filePath); const xml = xmlParser.parse(contents, filePath);
const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes); const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes);
@ -42,9 +41,9 @@ export class Xliff1TranslationParser implements TranslationParser {
} }
class XliffFileElementVisitor extends BaseVisitor { class XliffFileElementVisitor extends BaseVisitor {
private bundle: TranslationBundle|undefined; private bundle: ParsedTranslationBundle|undefined;
static extractBundle(xliff: Node[]): TranslationBundle|undefined { static extractBundle(xliff: Node[]): ParsedTranslationBundle|undefined {
const visitor = new this(); const visitor = new this();
visitAll(visitor, xliff); visitAll(visitor, xliff);
return visitor.bundle; return visitor.bundle;
@ -53,7 +52,7 @@ class XliffFileElementVisitor extends BaseVisitor {
visitElement(element: Element): any { visitElement(element: Element): any {
if (element.name === 'file') { if (element.name === 'file') {
this.bundle = { this.bundle = {
locale: getAttrOrThrow(element, 'target-language'), locale: getAttribute(element, 'target-language'),
translations: XliffTranslationVisitor.extractTranslations(element) translations: XliffTranslationVisitor.extractTranslations(element)
}; };
} else { } else {

View File

@ -9,11 +9,10 @@ import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path'; import {extname} from 'path';
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer'; import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';
import {TranslationBundle} from '../../../translator';
import {BaseVisitor} from '../base_visitor'; import {BaseVisitor} from '../base_visitor';
import {TranslationParseError} from '../translation_parse_error'; import {TranslationParseError} from '../translation_parse_error';
import {TranslationParser} from '../translation_parser'; import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {getAttrOrThrow, parseInnerRange} from '../translation_utils'; import {getAttrOrThrow, getAttribute, parseInnerRange} from '../translation_utils';
import {Xliff2MessageSerializer} from './xliff2_message_serializer'; import {Xliff2MessageSerializer} from './xliff2_message_serializer';
const XLIFF_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/; const XLIFF_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/;
@ -29,7 +28,7 @@ export class Xliff2TranslationParser implements TranslationParser {
return (extname(filePath) === '.xlf') && XLIFF_2_0_NS_REGEX.test(contents); return (extname(filePath) === '.xlf') && XLIFF_2_0_NS_REGEX.test(contents);
} }
parse(filePath: string, contents: string): TranslationBundle { parse(filePath: string, contents: string): ParsedTranslationBundle {
const xmlParser = new XmlParser(); const xmlParser = new XmlParser();
const xml = xmlParser.parse(contents, filePath); const xml = xmlParser.parse(contents, filePath);
const bundle = Xliff2TranslationBundleVisitor.extractBundle(xml.rootNodes); const bundle = Xliff2TranslationBundleVisitor.extractBundle(xml.rootNodes);
@ -40,27 +39,30 @@ export class Xliff2TranslationParser implements TranslationParser {
} }
} }
class Xliff2TranslationBundleVisitor extends BaseVisitor { interface BundleVisitorContext {
private locale: string|undefined; parsedLocale?: string;
private bundle: TranslationBundle|undefined; }
static extractBundle(xliff: Node[]): TranslationBundle|undefined { class Xliff2TranslationBundleVisitor extends BaseVisitor {
private bundle: ParsedTranslationBundle|undefined;
static extractBundle(xliff: Node[]): ParsedTranslationBundle|undefined {
const visitor = new this(); const visitor = new this();
visitAll(visitor, xliff); visitAll(visitor, xliff, {});
return visitor.bundle; return visitor.bundle;
} }
visitElement(element: Element): any { visitElement(element: Element, {parsedLocale}: BundleVisitorContext): any {
if (element.name === 'xliff') { if (element.name === 'xliff') {
this.locale = getAttrOrThrow(element, 'trgLang'); parsedLocale = getAttribute(element, 'trgLang');
return visitAll(this, element.children); return visitAll(this, element.children, {parsedLocale});
} else if (element.name === 'file') { } else if (element.name === 'file') {
this.bundle = { this.bundle = {
locale: this.locale !, locale: parsedLocale,
translations: Xliff2TranslationVisitor.extractTranslations(element) translations: Xliff2TranslationVisitor.extractTranslations(element)
}; };
} else { } else {
return visitAll(this, element.children); return visitAll(this, element.children, {parsedLocale});
} }
} }
} }

View File

@ -13,11 +13,8 @@ import {FileUtils} from '../file_utils';
import {OutputPathFn} from './output_path'; import {OutputPathFn} from './output_path';
/** /**
* An object that holds translations that have been loaded * An object that holds information to be used to translate files.
* from a translation file.
*/ */
export interface TranslationBundle { export interface TranslationBundle {
locale: string; locale: string;

View File

@ -30,7 +30,7 @@ describe('translateFiles()', () => {
outputPathFn, outputPathFn,
translationFilePaths: resolveAll( translationFilePaths: resolveAll(
__dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']), __dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']),
diagnostics, translationFileLocales: [], diagnostics,
missingTranslation: 'error' missingTranslation: 'error'
}); });
@ -58,7 +58,7 @@ describe('translateFiles()', () => {
sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), outputPathFn, sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), outputPathFn,
translationFilePaths: resolveAll( translationFilePaths: resolveAll(
__dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']), __dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']),
diagnostics, translationFileLocales: [], diagnostics,
missingTranslation: 'error', missingTranslation: 'error',
}); });
@ -72,6 +72,33 @@ describe('translateFiles()', () => {
.toEqual(`var name="World";var message="Hola, "+name+"!";`); .toEqual(`var name="World";var message="Hola, "+name+"!";`);
}); });
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']),
translationFileLocales: ['xde', undefined, 'fr'], diagnostics,
missingTranslation: '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 "${resolve(__dirname, 'locales', 'messages.de.json')}".`
});
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+"!";`);
});
it('should transform and/or copy files to the destination folders', () => { it('should transform and/or copy files to the destination folders', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}')); const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}'));
@ -82,7 +109,7 @@ describe('translateFiles()', () => {
outputPathFn, outputPathFn,
translationFilePaths: resolveAll( translationFilePaths: resolveAll(
__dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']), __dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']),
diagnostics, translationFileLocales: [], diagnostics,
missingTranslation: 'error', missingTranslation: 'error',
}); });

View File

@ -7,8 +7,9 @@
*/ */
import {ɵParsedTranslation} from '@angular/localize'; import {ɵParsedTranslation} from '@angular/localize';
import {Diagnostics} from '../../../src/diagnostics';
import {FileUtils} from '../../../src/file_utils'; import {FileUtils} from '../../../src/file_utils';
import {TranslationLoader} from '../../../src/translate/translation_files/translation_file_loader'; import {TranslationLoader} from '../../../src/translate/translation_files/translation_loader';
import {TranslationParser} from '../../../src/translate/translation_files/translation_parsers/translation_parser'; import {TranslationParser} from '../../../src/translate/translation_files/translation_parsers/translation_parser';
describe('TranslationLoader', () => { describe('TranslationLoader', () => {
@ -18,9 +19,10 @@ describe('TranslationLoader', () => {
}); });
it('should `canParse()` and `parse()` for each file', () => { it('should `canParse()` and `parse()` for each file', () => {
const parser = new MockTranslationParser(true); const diagnostics = new Diagnostics();
const loader = new TranslationLoader([parser]); const parser = new MockTranslationParser(true, 'fr');
loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf']); const loader = new TranslationLoader([parser], diagnostics);
loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []);
expect(parser.log).toEqual([ expect(parser.log).toEqual([
'canParse(/src/locale/messages.en.xlf, english messages)', 'canParse(/src/locale/messages.en.xlf, english messages)',
'parse(/src/locale/messages.en.xlf, english messages)', 'parse(/src/locale/messages.en.xlf, english messages)',
@ -30,11 +32,12 @@ describe('TranslationLoader', () => {
}); });
it('should stop at the first parser that can parse each file', () => { it('should stop at the first parser that can parse each file', () => {
const diagnostics = new Diagnostics();
const parser1 = new MockTranslationParser(false); const parser1 = new MockTranslationParser(false);
const parser2 = new MockTranslationParser(true); const parser2 = new MockTranslationParser(true, 'fr');
const parser3 = new MockTranslationParser(true); const parser3 = new MockTranslationParser(true, 'en');
const loader = new TranslationLoader([parser1, parser2, parser3]); const loader = new TranslationLoader([parser1, parser2, parser3], diagnostics);
loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf']); loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []);
expect(parser1.log).toEqual([ expect(parser1.log).toEqual([
'canParse(/src/locale/messages.en.xlf, english messages)', 'canParse(/src/locale/messages.en.xlf, english messages)',
'canParse(/src/locale/messages.fr.xlf, french messages)', 'canParse(/src/locale/messages.fr.xlf, french messages)',
@ -49,22 +52,64 @@ describe('TranslationLoader', () => {
it('should return locale and translations parsed from each file', () => { it('should return locale and translations parsed from each file', () => {
const translations = {}; const translations = {};
const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(true, 'pl', translations); const parser = new MockTranslationParser(true, 'pl', translations);
const loader = new TranslationLoader([parser]); const loader = new TranslationLoader([parser], diagnostics);
const result = const result =
loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf']); loader.loadBundles(['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []);
expect(result).toEqual([ expect(result).toEqual([
{locale: 'pl', translations}, {locale: 'pl', translations},
{locale: 'pl', translations}, {locale: 'pl', translations},
]); ]);
}); });
it('should return the provided locale if there is no parsed locale', () => {
const translations = {};
const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(true, undefined, translations);
const loader = new TranslationLoader([parser], diagnostics);
const result = loader.loadBundles(
['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], ['en', 'fr']);
expect(result).toEqual([
{locale: 'en', translations},
{locale: 'fr', translations},
]);
});
it('should warn if the provided locales do not match the parsed locales', () => {
const translations = {};
const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(true, 'pl', translations);
const loader = new TranslationLoader([parser], 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 throw an error if there is no provided nor parsed target locale', () => {
const translations = {};
const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(true, undefined, translations);
const loader = new TranslationLoader([parser], 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.');
});
it('should error if none of the parsers can parse the file', () => { it('should error if none of the parsers can parse the file', () => {
const diagnostics = new Diagnostics();
const parser = new MockTranslationParser(false); const parser = new MockTranslationParser(false);
const loader = new TranslationLoader([parser]); const loader = new TranslationLoader([parser], diagnostics);
expect(() => loader.loadBundles([ expect(
'/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf' () => loader.loadBundles(
])).toThrowError('Unable to parse translation file: /src/locale/messages.en.xlf'); ['/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.');
}); });
}); });
}); });
@ -72,7 +117,7 @@ describe('TranslationLoader', () => {
class MockTranslationParser implements TranslationParser { class MockTranslationParser implements TranslationParser {
log: string[] = []; log: string[] = [];
constructor( constructor(
private _canParse: boolean = true, private _locale: string = 'fr', private _canParse: boolean = true, private _locale?: string,
private _translations: Record<string, ɵParsedTranslation> = {}) {} private _translations: Record<string, ɵParsedTranslation> = {}) {}
canParse(filePath: string, fileContents: string) { canParse(filePath: string, fileContents: string) {

View File

@ -40,6 +40,19 @@ describe('Xliff1TranslationParser', () => {
expect(result.locale).toEqual('fr'); expect(result.locale).toEqual('fr');
}); });
it('should return an undefined locale if there is no locale in the file', () => {
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
</body>
</file>
</xliff>`;
const parser = new Xliff1TranslationParser();
const result = parser.parse('/some/file.xlf', XLIFF);
expect(result.locale).toBeUndefined();
});
it('should extract basic messages', () => { it('should extract basic messages', () => {
/** /**
* Source HTML: * Source HTML:

View File

@ -39,6 +39,17 @@ describe('Xliff2TranslationParser', () => {
expect(result.locale).toEqual('fr'); expect(result.locale).toEqual('fr');
}); });
it('should return undefined locale if there is no locale in the file', () => {
const XLIFF = `
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en">
<file original="ng.template" id="ngi18n">
</file>
</xliff>`;
const parser = new Xliff2TranslationParser();
const result = parser.parse('/some/file.xlf', XLIFF);
expect(result.locale).toBeUndefined();
});
it('should extract basic messages', () => { it('should extract basic messages', () => {
/** /**
* Source HTML: * Source HTML: