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
Previously the target locale of a translation file had to be extracted
from the contents of the translation file.

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 angular#33323
  • Loading branch information
petebacondarwin committed Oct 24, 2019
1 parent a1d7b6b commit 9af849e
Show file tree
Hide file tree
Showing 11 changed files with 234 additions and 72 deletions.
46 changes: 40 additions & 6 deletions packages/localize/src/tools/src/translate/main.ts
Expand Up @@ -50,6 +50,7 @@ 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,
Expand All @@ -73,27 +74,60 @@ 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) {
export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths,
translationFileLocales, outputPathFn, diagnostics,
missingTranslation, sourceLocale}: TranslateFilesOptions) {
const translationLoader = new TranslationLoader([
new Xliff2TranslationParser(),
new Xliff1TranslationParser(),
Expand All @@ -107,7 +141,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 @@ -20,12 +20,13 @@ export class TranslationLoader {
*
* @param translationFilePaths A collection of absolute paths to the translation files.
*/
loadBundles(translationFilePaths: string[]): TranslationBundle[] {
return translationFilePaths.map(filePath => {
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)) {
return translationParser.parse(filePath, fileContents);
return translationParser.parse(filePath, fileContents, translationFileLocales[index]);
}
}
throw new Error(`Unable to parse translation file: ${filePath}`);
Expand Down
Expand Up @@ -26,8 +26,12 @@ 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, locale: string|undefined): TranslationBundle {
const {locale: loadedLocale, translations} = JSON.parse(contents);
locale = locale || loadedLocale;
if (locale === undefined) {
throw new Error(`No locale provided for or read from ${_filePath}.`);
}
const parsedTranslations: Record<ɵMessageId, ɵParsedTranslation> = {};
for (const messageId in translations) {
const targetMessage = translations[messageId];
Expand Down
Expand Up @@ -25,5 +25,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, locale: string|undefined): TranslationBundle;
}
Expand Up @@ -30,30 +30,34 @@ 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, locale: string|undefined): TranslationBundle {
const xmlParser = new XmlParser();
const xml = xmlParser.parse(contents, filePath);
const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes);
const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes, locale);
if (bundle === undefined) {
throw new Error(`Unable to parse "${filePath}" as XLIFF 1.2 format.`);
}
if (locale !== undefined) {
bundle.locale = locale;
}
return bundle;
}
}

class XliffFileElementVisitor extends BaseVisitor {
private bundle: TranslationBundle|undefined;
constructor(private locale: string|undefined) { super(); }

static extractBundle(xliff: Node[]): TranslationBundle|undefined {
const visitor = new this();
static extractBundle(xliff: Node[], locale: string|undefined): TranslationBundle|undefined {
const visitor = new this(locale);
visitAll(visitor, xliff);
return visitor.bundle;
}

visitElement(element: Element): any {
if (element.name === 'file') {
this.bundle = {
locale: getAttrOrThrow(element, 'target-language'),
locale: this.locale || getAttrOrThrow(element, 'target-language'),
translations: XliffTranslationVisitor.extractTranslations(element)
};
} else {
Expand Down
Expand Up @@ -29,30 +29,33 @@ export class Xliff2TranslationParser implements TranslationParser {
return (extname(filePath) === '.xlf') && XLIFF_2_0_NS_REGEX.test(contents);
}

parse(filePath: string, contents: string): TranslationBundle {
parse(filePath: string, contents: string, locale: string|undefined): TranslationBundle {
const xmlParser = new XmlParser();
const xml = xmlParser.parse(contents, filePath);
const bundle = Xliff2TranslationBundleVisitor.extractBundle(xml.rootNodes);
const bundle = Xliff2TranslationBundleVisitor.extractBundle(xml.rootNodes, locale);
if (bundle === undefined) {
throw new Error(`Unable to parse "${filePath}" as XLIFF 2.0 format.`);
}
if (locale !== undefined) {
bundle.locale = locale;
}
return bundle;
}
}

class Xliff2TranslationBundleVisitor extends BaseVisitor {
private locale: string|undefined;
private bundle: TranslationBundle|undefined;
constructor(private locale: string|undefined) { super(); }

static extractBundle(xliff: Node[]): TranslationBundle|undefined {
const visitor = new this();
static extractBundle(xliff: Node[], locale: string|undefined): TranslationBundle|undefined {
const visitor = new this(locale);
visitAll(visitor, xliff);
return visitor.bundle;
}

visitElement(element: Element): any {
if (element.name === 'xliff') {
this.locale = getAttrOrThrow(element, 'trgLang');
this.locale = this.locale || getAttrOrThrow(element, 'trgLang');
return visitAll(this, element.children);
} else if (element.name === 'file') {
this.bundle = {
Expand Down
Expand Up @@ -30,7 +30,7 @@ describe('translateFiles()', () => {
outputPathFn,
translationFilePaths: resolveAll(
__dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']),
diagnostics,
translationFileLocales: [], diagnostics,
missingTranslation: 'error'
});

Expand Down Expand Up @@ -58,7 +58,7 @@ describe('translateFiles()', () => {
sourceFilePaths: resolveAll(__dirname + '/test_files', ['test.js']), outputPathFn,
translationFilePaths: resolveAll(
__dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']),
diagnostics,
translationFileLocales: [], diagnostics,
missingTranslation: 'error',
});

Expand All @@ -72,6 +72,28 @@ describe('translateFiles()', () => {
.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, 'xfr'], diagnostics,
missingTranslation: 'error',
});

expect(diagnostics.messages.length).toEqual(0);

expect(FileUtils.readFile(resolve(testDir, 'xfr', 'test.js')))
.toEqual(`var name="World";var message="Bonjour, "+name+"!";`);
expect(FileUtils.readFile(resolve(testDir, 'xde', 'test.js')))
.toEqual(`var name="World";var message="Guten Tag, "+name+"!";`);
expect(FileUtils.readFile(resolve(testDir, 'es', 'test.js')))
.toEqual(`var name="World";var message="Hola, "+name+"!";`);
});

it('should transform and/or copy files to the destination folders', () => {
const diagnostics = new Diagnostics();
const outputPathFn = getOutputPathFn(resolve(testDir, '{{LOCALE}}'));
Expand All @@ -82,7 +104,7 @@ describe('translateFiles()', () => {
outputPathFn,
translationFilePaths: resolveAll(
__dirname + '/locales', ['messages.de.json', 'messages.es.xlf', 'messages.fr.xlf']),
diagnostics,
translationFileLocales: [], diagnostics,
missingTranslation: 'error',
});

Expand Down
Expand Up @@ -20,12 +20,12 @@ describe('TranslationLoader', () => {
it('should `canParse()` and `parse()` for each file', () => {
const parser = new MockTranslationParser(true);
const loader = new TranslationLoader([parser]);
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(parser.log).toEqual([
'canParse(/src/locale/messages.en.xlf, english messages)',
'parse(/src/locale/messages.en.xlf, english messages)',
'parse(/src/locale/messages.en.xlf, english messages, undefined)',
'canParse(/src/locale/messages.fr.xlf, french messages)',
'parse(/src/locale/messages.fr.xlf, french messages)',
'parse(/src/locale/messages.fr.xlf, french messages, undefined)',
]);
});

Expand All @@ -34,16 +34,16 @@ describe('TranslationLoader', () => {
const parser2 = new MockTranslationParser(true);
const parser3 = new MockTranslationParser(true);
const loader = new TranslationLoader([parser1, parser2, parser3]);
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([
'canParse(/src/locale/messages.en.xlf, english messages)',
'canParse(/src/locale/messages.fr.xlf, french messages)',
]);
expect(parser2.log).toEqual([
'canParse(/src/locale/messages.en.xlf, english messages)',
'parse(/src/locale/messages.en.xlf, english messages)',
'parse(/src/locale/messages.en.xlf, english messages, undefined)',
'canParse(/src/locale/messages.fr.xlf, french messages)',
'parse(/src/locale/messages.fr.xlf, french messages)',
'parse(/src/locale/messages.fr.xlf, french messages, undefined)',
]);
});

Expand All @@ -52,19 +52,32 @@ describe('TranslationLoader', () => {
const parser = new MockTranslationParser(true, 'pl', translations);
const loader = new TranslationLoader([parser]);
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([
{locale: 'pl', translations},
{locale: 'pl', translations},
]);
});

it('should pass the provided locales through to the parser', () => {
const translations = {};
const parser = new MockTranslationParser(true, 'pl', translations);
const loader = new TranslationLoader([parser]);
const result = loader.loadBundles(
['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], [undefined, 'FR']);
expect(result).toEqual([
{locale: 'pl', translations},
{locale: 'FR', translations},
]);
});

it('should error if none of the parsers can parse the file', () => {
const parser = new MockTranslationParser(false);
const loader = new TranslationLoader([parser]);
expect(() => loader.loadBundles([
'/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'
])).toThrowError('Unable to parse translation file: /src/locale/messages.en.xlf');
expect(
() => loader.loadBundles(
['/src/locale/messages.en.xlf', '/src/locale/messages.fr.xlf'], []))
.toThrowError('Unable to parse translation file: /src/locale/messages.en.xlf');
});
});
});
Expand All @@ -80,8 +93,8 @@ class MockTranslationParser implements TranslationParser {
return this._canParse;
}

parse(filePath: string, fileContents: string) {
this.log.push(`parse(${filePath}, ${fileContents})`);
return {locale: this._locale, translations: this._translations};
parse(filePath: string, fileContents: string, locale: string|undefined) {
this.log.push(`parse(${filePath}, ${fileContents}, ${locale})`);
return {locale: locale || this._locale, translations: this._translations};
}
}
Expand Up @@ -20,18 +20,27 @@ describe('SimpleJsonTranslationParser', () => {
describe('parse()', () => {
it('should extract the locale from the JSON contents', () => {
const parser = new SimpleJsonTranslationParser();
const result = parser.parse('/some/file.json', '{"locale": "en", "translations": {}}');
const result =
parser.parse('/some/file.json', '{"locale": "en", "translations": {}}', undefined);
expect(result.locale).toEqual('en');
});

it('should use the provided locale rather than one from the JSON contents', () => {
const parser = new SimpleJsonTranslationParser();
const result = parser.parse('/some/file.json', '{"locale": "en", "translations": {}}', 'fr');
expect(result.locale).toEqual('fr');
});

it('should extract and process the translations from the JSON contents', () => {
const parser = new SimpleJsonTranslationParser();
const result = parser.parse('/some/file.json', `{
const result = parser.parse(
'/some/file.json', `{
"locale": "fr",
"translations": {
"Hello, {$ph_1}!": "Bonjour, {$ph_1}!"
}
}`);
}`,
undefined);
expect(result.translations).toEqual({
'Hello, {$ph_1}!': {
messageParts: ɵmakeTemplateObject(['Bonjour, ', '!'], ['Bonjour, ', '!']),
Expand Down

0 comments on commit 9af849e

Please sign in to comment.