feat(ivy): i18n - support source locale inlining (#33101)
Add a new flag to `localize-translate` that allows the source locale to be specified. When this locale is provided an extra copy of the files is made for this locale where the is no translation but all the calls to `$localize` are stripped out. Resolves FW-1623 PR Close #33101
This commit is contained in:
parent
e409ed0eab
commit
f433d6604b
|
@ -156,6 +156,10 @@
|
|||
"devServerTarget": "",
|
||||
"protractorConfig": "e2e/legacy/protractor.conf.js"
|
||||
},
|
||||
"translated-en": {
|
||||
"devServerTarget": "",
|
||||
"protractorConfig": "e2e/en/protractor.conf.js"
|
||||
},
|
||||
"translated-fr": {
|
||||
"devServerTarget": "",
|
||||
"protractorConfig": "e2e/fr/protractor.conf.js"
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import {AppPage} from '../app.po';
|
||||
|
||||
describe('cli-hello-world-ivy App', () => {
|
||||
let page: AppPage;
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
page.navigateTo();
|
||||
});
|
||||
|
||||
it('should display title',
|
||||
() => { expect(page.getHeading()).toEqual('Hello cli-hello-world-ivy-compat!'); });
|
||||
|
||||
it('should display welcome message',
|
||||
() => { expect(page.getParagraph('message')).toEqual('Welcome to the i18n app.'); });
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
const {config} = require('../protractor.conf');
|
||||
exports.config = {
|
||||
...config,
|
||||
specs: ['./app.e2e-spec.ts'],
|
||||
};
|
|
@ -11,9 +11,9 @@
|
|||
"start": "ng serve",
|
||||
"pretest": "ng version",
|
||||
"test": "ng test --progress=false --watch=false && yarn e2e --configuration=ci && yarn e2e --configuration=ci-production && yarn translated:test && yarn translated:legacy:test",
|
||||
"translate": "localize-translate -r \"dist/\" -s \"**/*\" -t \"src/locales/messages.*\" -o \"tmp/translations/{{LOCALE}}\"",
|
||||
"translate": "localize-translate -r \"dist/\" -s \"**/*\" -l \"en-US\" -t \"src/locales/messages.*\" -o \"tmp/translations/{{LOCALE}}\"",
|
||||
|
||||
"translated:test": "yarn build && yarn translate && yarn translated:fr:e2e && yarn translated:de:e2e",
|
||||
"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 --webdriver-update=false\"",
|
||||
|
@ -21,6 +21,9 @@
|
|||
"translated:de:serve": "serve tmp/translations/de --listen 4200",
|
||||
"translated:de:e2e": "npm-run-all -p -r translated:de:serve \"ng e2e --configuration=translated-de --webdriver-update=false\"",
|
||||
|
||||
"translated:en:serve": "serve tmp/translations/en-US --listen 4200",
|
||||
"translated:en:e2e": "npm-run-all -p -r translated:en:serve \"ng e2e --configuration=translated-en --webdriver-update=false\"",
|
||||
|
||||
"translated:legacy:test": "yarn translated:legacy:extract-and-update && ng build --configuration=translated-legacy && yarn translated:legacy:translate && yarn translated:legacy:e2e",
|
||||
"translated:legacy:extract-and-update": "ng xi18n && sed -i.bak -e 's/source>/target>'/ -e 's/Hello/Bonjour/' -e 's/source-language=\"en-US\"/source-language=\"en-US\" target-language=\"legacy\"/' tmp/legacy-locales/messages.legacy.xlf",
|
||||
"translated:legacy:translate": "localize-translate -r \"dist/\" -s \"**/*\" -t \"tmp/legacy-locales/messages.legacy.xlf\" -o \"tmp/translations/{{LOCALE}}\"",
|
||||
|
|
|
@ -19,7 +19,7 @@ export class AssetTranslationHandler implements TranslationHandler {
|
|||
canTranslate(_relativeFilePath: string, _contents: Buffer): boolean { return true; }
|
||||
translate(
|
||||
diagnostics: Diagnostics, _sourceRoot: string, relativeFilePath: string, contents: Buffer,
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void {
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void {
|
||||
for (const translation of translations) {
|
||||
try {
|
||||
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents);
|
||||
|
@ -27,5 +27,12 @@ export class AssetTranslationHandler implements TranslationHandler {
|
|||
diagnostics.error(e.message);
|
||||
}
|
||||
}
|
||||
if (sourceLocale !== undefined) {
|
||||
try {
|
||||
FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents);
|
||||
} catch (e) {
|
||||
diagnostics.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,12 @@ if (require.main === module) {
|
|||
'A glob pattern indicating what files to translate, relative to the `root` path. E.g. `bundles/**/*`.',
|
||||
})
|
||||
|
||||
.option('l', {
|
||||
alias: 'source-locale',
|
||||
describe:
|
||||
'The source locale of the application. If this is provided then a copy of the application will be created with no translation but just the `$localize` calls stripped out.',
|
||||
})
|
||||
|
||||
.option('t', {
|
||||
alias: 'translations',
|
||||
required: true,
|
||||
|
@ -66,9 +72,10 @@ if (require.main === module) {
|
|||
const outputPathFn = getOutputPathFn(options['o']);
|
||||
const diagnostics = new Diagnostics();
|
||||
const missingTranslation: MissingTranslationStrategy = options['m'];
|
||||
const sourceLocale: string|undefined = options['l'];
|
||||
|
||||
translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, diagnostics,
|
||||
missingTranslation});
|
||||
missingTranslation, sourceLocale});
|
||||
|
||||
diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`));
|
||||
process.exit(diagnostics.hasErrors ? 1 : 0);
|
||||
|
@ -81,10 +88,12 @@ export interface TranslateFilesOptions {
|
|||
outputPathFn: OutputPathFn;
|
||||
diagnostics: Diagnostics;
|
||||
missingTranslation: MissingTranslationStrategy;
|
||||
sourceLocale?: string;
|
||||
}
|
||||
|
||||
export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn,
|
||||
diagnostics, missingTranslation}: TranslateFilesOptions) {
|
||||
diagnostics, missingTranslation,
|
||||
sourceLocale}: TranslateFilesOptions) {
|
||||
const translationLoader = new TranslationLoader([
|
||||
new Xliff2TranslationParser(),
|
||||
new Xliff1TranslationParser(),
|
||||
|
@ -100,5 +109,6 @@ export function translateFiles({sourceRootPath, sourceFilePaths, translationFile
|
|||
|
||||
const translations = translationLoader.loadBundles(translationFilePaths);
|
||||
sourceRootPath = resolve(sourceRootPath);
|
||||
resourceProcessor.translateFiles(sourceFilePaths, sourceRootPath, outputPathFn, translations);
|
||||
resourceProcessor.translateFiles(
|
||||
sourceFilePaths, sourceRootPath, outputPathFn, translations, sourceLocale);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
* 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';
|
||||
|
@ -17,20 +19,23 @@ import {makeEs2015TranslatePlugin} from './es2015_translate_plugin';
|
|||
import {makeEs5TranslatePlugin} from './es5_translate_plugin';
|
||||
import {TranslatePluginOptions} from './source_file_utils';
|
||||
|
||||
|
||||
/**
|
||||
* Translate a file by inlining all messages tagged by `$localize` with the appropriate translated
|
||||
* message.
|
||||
*/
|
||||
export class SourceFileTranslationHandler implements TranslationHandler {
|
||||
private sourceLocaleOptions:
|
||||
TranslatePluginOptions = {...this.translationOptions, missingTranslation: 'ignore'};
|
||||
constructor(private translationOptions: TranslatePluginOptions = {}) {}
|
||||
|
||||
canTranslate(relativeFilePath: string, contents: Buffer): boolean {
|
||||
canTranslate(relativeFilePath: string, _contents: Buffer): boolean {
|
||||
return extname(relativeFilePath) === '.js';
|
||||
}
|
||||
|
||||
translate(
|
||||
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer,
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void {
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void {
|
||||
const sourceCode = contents.toString('utf8');
|
||||
// A short-circuit check to avoid parsing the file into an AST if it does not contain any
|
||||
// `$localize` identifiers.
|
||||
|
@ -38,33 +43,49 @@ export class SourceFileTranslationHandler implements TranslationHandler {
|
|||
for (const translation of translations) {
|
||||
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents);
|
||||
}
|
||||
if (sourceLocale !== undefined) {
|
||||
FileUtils.writeFile(outputPathFn(sourceLocale, relativeFilePath), contents);
|
||||
}
|
||||
} else {
|
||||
const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath});
|
||||
if (!ast) {
|
||||
diagnostics.error(`Unable to parse source file: ${join(sourceRoot, relativeFilePath)}`);
|
||||
return;
|
||||
}
|
||||
// Output a translated copy of the file for each locale.
|
||||
for (const translationBundle of translations) {
|
||||
const translated = transformFromAstSync(ast, sourceCode, {
|
||||
compact: true,
|
||||
generatorOpts: {minified: true},
|
||||
plugins: [
|
||||
makeEs2015TranslatePlugin(
|
||||
diagnostics, translationBundle.translations, this.translationOptions),
|
||||
makeEs5TranslatePlugin(
|
||||
diagnostics, translationBundle.translations, this.translationOptions),
|
||||
],
|
||||
filename: relativeFilePath,
|
||||
});
|
||||
if (translated && translated.code) {
|
||||
FileUtils.writeFile(
|
||||
outputPathFn(translationBundle.locale, relativeFilePath), translated.code);
|
||||
} else {
|
||||
diagnostics.error(
|
||||
`Unable to translate source file: ${join(sourceRoot, relativeFilePath)}`);
|
||||
return;
|
||||
}
|
||||
this.translateFile(
|
||||
diagnostics, ast, translationBundle, sourceRoot, relativeFilePath, outputPathFn,
|
||||
this.translationOptions);
|
||||
}
|
||||
if (sourceLocale !== undefined) {
|
||||
// Also output a copy of the file for the source locale.
|
||||
// There will be no translations - by definition - so we "ignore" `missingTranslations`.
|
||||
this.translateFile(
|
||||
diagnostics, ast, {locale: sourceLocale, translations: {}}, sourceRoot,
|
||||
relativeFilePath, outputPathFn, this.sourceLocaleOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private translateFile(
|
||||
diagnostics: Diagnostics, ast: File|Program, translationBundle: TranslationBundle,
|
||||
sourceRoot: string, filename: string, outputPathFn: OutputPathFn,
|
||||
options: TranslatePluginOptions) {
|
||||
const translated = transformFromAstSync(ast, undefined, {
|
||||
compact: true,
|
||||
generatorOpts: {minified: true},
|
||||
plugins: [
|
||||
makeEs2015TranslatePlugin(diagnostics, translationBundle.translations, options),
|
||||
makeEs5TranslatePlugin(diagnostics, translationBundle.translations, options),
|
||||
],
|
||||
filename,
|
||||
});
|
||||
if (translated && translated.code) {
|
||||
FileUtils.writeFile(outputPathFn(translationBundle.locale, filename), translated.code);
|
||||
} else {
|
||||
diagnostics.error(`Unable to translate source file: ${join(sourceRoot, filename)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,10 +51,13 @@ export interface TranslationHandler {
|
|||
* @param outputPathFn A function that returns an absolute path where the output file should be
|
||||
* written.
|
||||
* @param translations A collection of translations to apply to this file.
|
||||
* @param sourceLocale The locale of the original application source. If provided then an
|
||||
* additional copy of the application is created under this locale just with the `$localize` calls
|
||||
* stripped out.
|
||||
*/
|
||||
translate(
|
||||
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer,
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void;
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[], sourceLocale?: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,14 +69,15 @@ export class Translator {
|
|||
|
||||
translateFiles(
|
||||
inputPaths: string[], rootPath: string, outputPathFn: OutputPathFn,
|
||||
translations: TranslationBundle[]): void {
|
||||
translations: TranslationBundle[], sourceLocale?: string): void {
|
||||
inputPaths.forEach(inputPath => {
|
||||
const contents = FileUtils.readFileBuffer(inputPath);
|
||||
const relativePath = relative(rootPath, inputPath);
|
||||
for (const resourceHandler of this.resourceHandlers) {
|
||||
if (resourceHandler.canTranslate(relativePath, contents)) {
|
||||
return resourceHandler.translate(
|
||||
this.diagnostics, rootPath, relativePath, contents, outputPathFn, translations);
|
||||
this.diagnostics, rootPath, relativePath, contents, outputPathFn, translations,
|
||||
sourceLocale);
|
||||
}
|
||||
}
|
||||
this.diagnostics.error(`Unable to handle resource file: ${inputPath}`);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import {Diagnostics} from '../../../src/diagnostics';
|
||||
import {FileUtils} from '../../../src/file_utils';
|
||||
import {AssetTranslationHandler} from '../../../src/translate/asset_files/asset_translation_handler';
|
||||
import {TranslationBundle} from '../../../src/translate/translator';
|
||||
|
||||
describe('AssetTranslationHandler', () => {
|
||||
describe('canTranslate()', () => {
|
||||
|
@ -37,6 +38,20 @@ describe('AssetTranslationHandler', () => {
|
|||
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', () => {
|
||||
const diagnostics = new Diagnostics();
|
||||
const handler = new AssetTranslationHandler();
|
||||
const translations: TranslationBundle[] = [];
|
||||
const contents = Buffer.from('contents');
|
||||
const sourceLocale = 'en-US';
|
||||
handler.translate(
|
||||
diagnostics, '/root/path', 'relative/path', contents, mockOutputPathFn, translations,
|
||||
sourceLocale);
|
||||
|
||||
expect(FileUtils.writeFile)
|
||||
.toHaveBeenCalledWith('/translations/en-US/relative/path', contents);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import {Diagnostics} from '../../../src/diagnostics';
|
||||
import {FileUtils} from '../../../src/file_utils';
|
||||
import {SourceFileTranslationHandler} from '../../../src/translate/source_files/source_file_translation_handler';
|
||||
import {TranslationBundle} from '../../../src/translate/translator';
|
||||
|
||||
describe('SourceFileTranslationHandler', () => {
|
||||
describe('canTranslate()', () => {
|
||||
|
@ -39,6 +40,19 @@ describe('SourceFileTranslationHandler', () => {
|
|||
.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();
|
||||
|
@ -57,6 +71,23 @@ describe('SourceFileTranslationHandler', () => {
|
|||
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',
|
||||
() => {
|
||||
const diagnostics = new Diagnostics();
|
||||
const handler = new SourceFileTranslationHandler();
|
||||
const translations: TranslationBundle[] = [];
|
||||
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, 'en-US');
|
||||
|
||||
expect(FileUtils.writeFile)
|
||||
.toHaveBeenCalledWith('/translations/en-US/relative/path.js', output);
|
||||
});
|
||||
|
||||
it('should error if the file is not valid JS', () => {
|
||||
const diagnostics = new Diagnostics();
|
||||
const handler = new SourceFileTranslationHandler();
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
*/
|
||||
import {Diagnostics as Diagnostics} from '../../src/diagnostics';
|
||||
import {FileUtils} from '../../src/file_utils';
|
||||
import {TranslationHandler, Translator} from '../../src/translate/translator';
|
||||
import {OutputPathFn} from '../../src/translate/output_path';
|
||||
import {TranslationBundle, TranslationHandler, Translator} from '../../src/translate/translator';
|
||||
|
||||
describe('Translator', () => {
|
||||
describe('translateFiles()', () => {
|
||||
|
@ -34,9 +35,24 @@ describe('Translator', () => {
|
|||
|
||||
expect(handler.log).toEqual([
|
||||
'canTranslate(file1.js, resource file 1)',
|
||||
'translate(/dist, file1.js, resource file 1)',
|
||||
'translate(/dist, file1.js, resource file 1, ...)',
|
||||
'canTranslate(images/img.gif, resource file 2)',
|
||||
'translate(/dist, images/img.gif, resource file 2)',
|
||||
'translate(/dist, images/img.gif, resource file 2, ...)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass the sourceLocale through to `translate()` if provided', () => {
|
||||
const diagnostics = new Diagnostics();
|
||||
const handler = new MockTranslationHandler(true);
|
||||
const translator = new Translator([handler], diagnostics);
|
||||
translator.translateFiles(
|
||||
['/dist/file1.js', '/dist/images/img.gif'], '/dist', mockOutputPathFn, [], 'en-US');
|
||||
|
||||
expect(handler.log).toEqual([
|
||||
'canTranslate(file1.js, resource file 1)',
|
||||
'translate(/dist, file1.js, resource file 1, ..., en-US)',
|
||||
'canTranslate(images/img.gif, resource file 2)',
|
||||
'translate(/dist, images/img.gif, resource file 2, ..., en-US)',
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -55,9 +71,9 @@ describe('Translator', () => {
|
|||
]);
|
||||
expect(handler2.log).toEqual([
|
||||
'canTranslate(file1.js, resource file 1)',
|
||||
'translate(/dist, file1.js, resource file 1)',
|
||||
'translate(/dist, file1.js, resource file 1, ...)',
|
||||
'canTranslate(images/img.gif, resource file 2)',
|
||||
'translate(/dist, images/img.gif, resource file 2)',
|
||||
'translate(/dist, images/img.gif, resource file 2, ...)',
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -86,8 +102,12 @@ class MockTranslationHandler implements TranslationHandler {
|
|||
return this._canTranslate;
|
||||
}
|
||||
|
||||
translate(_diagnostics: Diagnostics, rootPath: string, relativePath: string, contents: Buffer) {
|
||||
this.log.push(`translate(${rootPath}, ${relativePath}, ${contents})`);
|
||||
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})` : ')'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue