Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion integration/cli-hello-world-ivy-i18n/package.json
Expand Up @@ -12,7 +12,7 @@
"start": "ng serve",
"pretest": "ng version",
"test": "ng test && yarn e2e --configuration=ci && yarn e2e --configuration=ci-production && yarn translated:test && yarn translated:legacy:test",
"translate": "localize-translate -r \"dist/\" -s \"**/*\" -l \"en-US\" -t \"src/locales/messages.*\" -o \"../tmp/translations/{{LOCALE}}\"",
"translate": "localize-translate -r \"dist/\" -s \"**/*\" -l \"en-US\" -t \"src/locales/messages.de.json\" \"src/locales/messages.fr.json\" -o \"../tmp/translations/{{LOCALE}}\"",
"translated:test": "yarn build && yarn translate && yarn translated:fr:e2e && yarn translated:de:e2e && yarn translated:en:e2e",
"translated:fr:serve": "serve ../tmp/translations/fr --listen 4200",
"translated:fr:e2e": "npm-run-all -p -r translated:fr:serve \"ng e2e --configuration=translated-fr\"",
Expand Down
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
73 changes: 59 additions & 14 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 @@ -47,58 +47,103 @@ if (require.main === module) {
.option('t', {
alias: 'translations',
required: true,
array: true,
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 list of paths to the translation files to load, either absolute or relative to the current working directory.\n' +
'E.g. "-t src/locale/messages.en.xlf src/locale/messages.fr.xlf src/locale/messages.de.xlf".',
})

.option('target-locales', {
array: true,
describe:
'A list of target locales for the translation files, which will override any target locale parsed from the translation file.\n' +
'E.g. "-t en fr de".',
})

.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);

const sourceRootPath = options['r'];
const sourceFilePaths =
glob.sync(options['s'], {absolute: true, cwd: sourceRootPath, nodir: true});
const translationFilePaths = glob.sync(options['t'], {absolute: true, nodir: true});
const translationFilePaths: string[] = options['t'];
const outputPathFn = getOutputPathFn(options['o']);
const diagnostics = new Diagnostics();
const missingTranslation: MissingTranslationStrategy = options['m'];
const sourceLocale: string|undefined = options['l'];
const translationFileLocales: string[] = options['target-locales'] || [];

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 {
/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding docs 👍

* 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 +152,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