Skip to content

Commit

Permalink
fix(ivy): i18n - support setting locales for each translation file (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
petebacondarwin authored and AndrewKushnir committed Oct 28, 2019
1 parent e23bc49 commit 62b2840
Show file tree
Hide file tree
Showing 14 changed files with 258 additions and 102 deletions.
Expand Up @@ -10,8 +10,6 @@ import {FileUtils} from '../../file_utils';
import {OutputPathFn} from '../output_path';
import {TranslationBundle, TranslationHandler} from '../translator';



/**
* Translate an asset file by simply copying it to the appropriate translation output paths.
*/
Expand Down
62 changes: 50 additions & 12 deletions packages/localize/src/tools/src/translate/main.ts
Expand Up @@ -14,7 +14,7 @@ import {AssetTranslationHandler} from './asset_files/asset_translation_handler';
import {getOutputPathFn, OutputPathFn} from './output_path';
import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler';
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 {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1/xliff1_translation_parser';
import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2/xliff2_translation_parser';
Expand Down Expand Up @@ -50,18 +50,21 @@ if (require.main === module) {
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.',
})

.option('o', {
alias: 'outputPath',
required: true,
describe:
'A output path pattern to where the translated files will be written. The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.'
})

.option('m', {
alias: 'missingTranslation',
describe: 'How to handle missing translations.',
choices: ['error', 'warning', 'ignore'],
default: 'warning',
})

.help()
.parse(args);

Expand All @@ -73,32 +76,67 @@ if (require.main === module) {
const diagnostics = new Diagnostics();
const missingTranslation: MissingTranslationStrategy = options['m'];
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,
missingTranslation, sourceLocale});
translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, translationFileLocales,
outputPathFn, diagnostics, missingTranslation, sourceLocale});

diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`));
process.exit(diagnostics.hasErrors ? 1 : 0);
}

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;
/**
* The files to translate, relative to the `root` path.
*/
sourceFilePaths: string[];
/**
* An array of paths to the translation files to load, either absolute or relative to the current
* working directory.
*/
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;
/**
* An object that will receive any diagnostics messages due to the processing.
*/
diagnostics: Diagnostics;
/**
* How to handle missing translations.
*/
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;
}

export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn,
diagnostics, missingTranslation,
sourceLocale}: TranslateFilesOptions) {
const translationLoader = new TranslationLoader([
new Xliff2TranslationParser(),
new Xliff1TranslationParser(),
new SimpleJsonTranslationParser(),
]);
export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths,
translationFileLocales, outputPathFn, diagnostics,
missingTranslation, sourceLocale}: TranslateFilesOptions) {
const translationLoader = new TranslationLoader(
[
new Xliff2TranslationParser(),
new Xliff1TranslationParser(),
new SimpleJsonTranslationParser(),
],
diagnostics);

const resourceProcessor = new Translator(
[
Expand All @@ -107,7 +145,7 @@ export function translateFiles({sourceRootPath, sourceFilePaths, translationFile
],
diagnostics);

const translations = translationLoader.loadBundles(translationFilePaths);
const translations = translationLoader.loadBundles(translationFilePaths, translationFileLocales);
sourceRootPath = resolve(sourceRootPath);
resourceProcessor.translateFiles(
sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale);
Expand Down
Expand Up @@ -5,23 +5,18 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize/private';
import {parseSync, transformFromAstSync} from '@babel/core';
import {File, Program} from '@babel/types';
import {extname, join} from 'path';

import {Diagnostics} from '../../diagnostics';
import {FileUtils} from '../../file_utils';
import {OutputPathFn} from '../output_path';
import {TranslationBundle, TranslationHandler} from '../translator';

import {makeEs2015TranslatePlugin} from './es2015_translate_plugin';
import {makeEs5TranslatePlugin} from './es5_translate_plugin';
import {makeLocalePlugin} from './locale_plugin';
import {TranslatePluginOptions} from './source_file_utils';



/**
* Translate a file by inlining all messages tagged by `$localize` with the appropriate translated
* message.
Expand Down

This file was deleted.

@@ -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}.`);
});
}
}
Expand Up @@ -7,8 +7,7 @@
*/
import {ɵMessageId, ɵParsedTranslation, ɵparseTranslation} from '@angular/localize';
import {extname} from 'path';
import {TranslationBundle} from '../../../translator';
import {TranslationParser} from '../translation_parser';
import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';

/**
* A translation parser that can parse JSON that has the form:
Expand All @@ -26,13 +25,13 @@ import {TranslationParser} from '../translation_parser';
export class SimpleJsonTranslationParser implements TranslationParser {
canParse(filePath: string, _contents: string): boolean { return (extname(filePath) === '.json'); }

parse(_filePath: string, contents: string): TranslationBundle {
const {locale, translations} = JSON.parse(contents);
parse(_filePath: string, contents: string): ParsedTranslationBundle {
const {locale: parsedLocale, translations} = JSON.parse(contents);
const parsedTranslations: Record<ɵMessageId, ɵParsedTranslation> = {};
for (const messageId in translations) {
const targetMessage = translations[messageId];
parsedTranslations[messageId] = ɵparseTranslation(targetMessage);
}
return {locale, translations: parsedTranslations};
return {locale: parsedLocale, translations: parsedTranslations};
}
}
Expand Up @@ -5,7 +5,15 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {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.
Expand All @@ -25,5 +33,5 @@ export interface TranslationParser {
* @param filePath The absolute path to the translation file.
* @param contents The contents of the translation file.
*/
parse(filePath: string, contents: string): TranslationBundle;
parse(filePath: string, contents: string): ParsedTranslationBundle;
}
Expand Up @@ -9,11 +9,10 @@ import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path';
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';
import {TranslationBundle} from '../../../translator';
import {BaseVisitor} from '../base_visitor';
import {TranslationParseError} from '../translation_parse_error';
import {TranslationParser} from '../translation_parser';
import {getAttrOrThrow, parseInnerRange} from '../translation_utils';
import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {getAttrOrThrow, getAttribute, parseInnerRange} from '../translation_utils';
import {Xliff1MessageSerializer} from './xliff1_message_serializer';

const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/;
Expand All @@ -30,7 +29,7 @@ export class Xliff1TranslationParser implements TranslationParser {
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 xml = xmlParser.parse(contents, filePath);
const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes);
Expand All @@ -42,9 +41,9 @@ export class Xliff1TranslationParser implements TranslationParser {
}

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();
visitAll(visitor, xliff);
return visitor.bundle;
Expand All @@ -53,7 +52,7 @@ class XliffFileElementVisitor extends BaseVisitor {
visitElement(element: Element): any {
if (element.name === 'file') {
this.bundle = {
locale: getAttrOrThrow(element, 'target-language'),
locale: getAttribute(element, 'target-language'),
translations: XliffTranslationVisitor.extractTranslations(element)
};
} else {
Expand Down

0 comments on commit 62b2840

Please sign in to comment.