Skip to content

feature(i18n): implement xi18n command #3340

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

Merged
merged 8 commits into from
Feb 8, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@angular/cli/addon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module.exports = {
'version': require('../commands/version').default,
'completion': require('../commands/completion').default,
'doc': require('../commands/doc').default,
'xi18n': require('../commands/xi18n').default,

// Easter eggs.
'make-this-awesome': require('../commands/easter-egg').default,
Expand Down
40 changes: 40 additions & 0 deletions packages/@angular/cli/commands/xi18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const Command = require('../ember-cli/lib/models/command');

import {Extracti18nTask} from '../tasks/extract-i18n';

export interface Xi18nOptions {
outputPath?: string;
verbose?: boolean;
i18nFormat?: string;
}

const Xi18nCommand = Command.extend({
name: 'xi18n',
description: 'Extracts i18n messages from source code.',
works: 'insideProject',
availableOptions: [
{
name: 'i18n-format',
type: String,
default: 'xlf',
aliases: ['f', {'xmb': 'xmb'}, {'xlf': 'xlf'}, {'xliff': 'xlf'}]
},
{ name: 'output-path', type: 'Path', default: null, aliases: ['op']},
{ name: 'verbose', type: Boolean, default: false},
{ name: 'progress', type: Boolean, default: true }

],
run: function (commandOptions: any) {

const xi18nTask = new Extracti18nTask({
ui: this.ui,
project: this.project
});

return xi18nTask.run(commandOptions);
}
});


export default Xi18nCommand;

1 change: 1 addition & 0 deletions packages/@angular/cli/models/webpack-configs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './production';
export * from './styles';
export * from './typescript';
export * from './utils';
export * from './xi18n';
25 changes: 25 additions & 0 deletions packages/@angular/cli/models/webpack-configs/xi18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as path from 'path';
import {ExtractI18nPlugin} from '@ngtools/webpack';

export const getWebpackExtractI18nConfig = function(
projectRoot: string,
appConfig: any,
genDir: string,
i18nFormat: string): any {

let exclude: string[] = [];
if (appConfig.test) {
exclude.push(path.join(projectRoot, appConfig.root, appConfig.test));
}

return {
plugins: [
new ExtractI18nPlugin({
tsConfigPath: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig),
exclude: exclude,
genDir: genDir,
i18nFormat: i18nFormat
})
]
};
};
39 changes: 39 additions & 0 deletions packages/@angular/cli/models/webpack-xi18n-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as path from 'path';

import {CliConfig} from './config';
import {NgCliWebpackConfig} from './webpack-config';
const webpackMerge = require('webpack-merge');
import {getWebpackExtractI18nConfig} from './webpack-configs';

export interface XI18WebpackOptions {
genDir?: string;
buildDir?: string;
i18nFormat?: string;
verbose?: boolean;
progress?: boolean;
}
export class XI18nWebpackConfig extends NgCliWebpackConfig {

public config: any;

constructor(extractOptions: XI18WebpackOptions) {

super({
target: 'development',
verbose: extractOptions.verbose,
progress: extractOptions.progress
});

const configPath = CliConfig.configFilePath();
const projectRoot = path.dirname(configPath);
const appConfig = CliConfig.fromProject().config.apps[0];

const extractI18nConfig =
getWebpackExtractI18nConfig(projectRoot,
appConfig,
extractOptions.genDir,
extractOptions.i18nFormat);

this.config = webpackMerge([this.config, extractI18nConfig]);
}
}
58 changes: 58 additions & 0 deletions packages/@angular/cli/tasks/extract-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as webpack from 'webpack';
import * as path from 'path';
import * as rimraf from 'rimraf';

const Task = require('../ember-cli/lib/models/task');

import {XI18nWebpackConfig} from '../models/webpack-xi18n-config';
import {CliConfig} from '../models/config';


export const Extracti18nTask = Task.extend({
run: function (runTaskOptions: any) {

const project = this.project;

const appConfig = CliConfig.fromProject().config.apps[0];

const buildDir = '.tmp';
const genDir = runTaskOptions.outputPath || appConfig.root;

const config = new XI18nWebpackConfig({
genDir,
buildDir,
i18nFormat: runTaskOptions.i18nFormat,
verbose: runTaskOptions.verbose,
progress: runTaskOptions.progress
}).config;

const webpackCompiler = webpack(config);

return new Promise((resolve, reject) => {
const callback: webpack.compiler.CompilerCallback = (err, stats) => {
if (err) {
return reject(err);
}

if (stats.hasErrors()) {
reject();
} else {
resolve();
}
};

webpackCompiler.run(callback);
})
.then(() => {
// Deletes temporary build folder
rimraf.sync(path.resolve(project.root, buildDir));
})
.catch((err: Error) => {
if (err) {
this.ui.writeError('\nAn error occured during the i18n extraction:\n'
+ ((err && err.stack) || err));
}
throw err;
});
}
});
169 changes: 169 additions & 0 deletions packages/@ngtools/webpack/src/extract_i18n_plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as ts from 'typescript';
import * as path from 'path';
import * as fs from 'fs';

import {__NGTOOLS_PRIVATE_API_2} from '@angular/compiler-cli';

import {Tapable} from './webpack';
import {WebpackResourceLoader} from './resource_loader';

export interface ExtractI18nPluginOptions {
tsConfigPath: string;
basePath?: string;
genDir?: string;
i18nFormat?: string;
exclude?: string[];
}

export class ExtractI18nPlugin implements Tapable {
private _resourceLoader: WebpackResourceLoader;

private _donePromise: Promise<void>;
private _compiler: any = null;
private _compilation: any = null;

private _tsConfigPath: string;
private _basePath: string;
private _genDir: string;
private _rootFilePath: string[];
private _compilerOptions: any = null;
private _angularCompilerOptions: any = null;
// private _compilerHost: WebpackCompilerHost;
private _compilerHost: ts.CompilerHost;
private _program: ts.Program;

private _i18nFormat: string;

constructor(options: ExtractI18nPluginOptions) {
this._setupOptions(options);
}

private _setupOptions(options: ExtractI18nPluginOptions) {
if (!options.hasOwnProperty('tsConfigPath')) {
throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.');
}
this._tsConfigPath = options.tsConfigPath;

// Check the base path.
const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath);
let basePath = maybeBasePath;
if (fs.statSync(maybeBasePath).isFile()) {
basePath = path.dirname(basePath);
}
if (options.hasOwnProperty('basePath')) {
basePath = path.resolve(process.cwd(), options.basePath);
}

let tsConfigJson: any = null;
try {
tsConfigJson = JSON.parse(fs.readFileSync(this._tsConfigPath, 'utf8'));
} catch (err) {
throw new Error(`An error happened while parsing ${this._tsConfigPath} JSON: ${err}.`);
}
const tsConfig = ts.parseJsonConfigFileContent(
tsConfigJson, ts.sys, basePath, null, this._tsConfigPath);

let fileNames = tsConfig.fileNames;
if (options.hasOwnProperty('exclude')) {
let exclude: string[] = typeof options.exclude == 'string'
? [options.exclude as string] : (options.exclude as string[]);

exclude.forEach((pattern: string) => {
const basePathPattern = '(' + basePath.replace(/\\/g, '/')
.replace(/[\-\[\]\/{}()+?.\\^$|*]/g, '\\$&') + ')?';
pattern = pattern
// Replace windows path separators with forward slashes.
.replace(/\\/g, '/')
// Escape characters that are used normally in regexes, except stars.
.replace(/[\-\[\]{}()+?.\\^$|]/g, '\\$&')
// Two stars replacement.
.replace(/\*\*/g, '(?:.*)')
// One star replacement.
.replace(/\*/g, '(?:[^/]*)')
// Escape characters from the basePath and make sure it's forward slashes.
.replace(/^/, basePathPattern);

const re = new RegExp('^' + pattern + '$');
fileNames = fileNames.filter(x => !x.replace(/\\/g, '/').match(re));
});
} else {
fileNames = fileNames.filter(fileName => !/\.spec\.ts$/.test(fileName));
}
this._rootFilePath = fileNames;

// By default messages will be generated in basePath
let genDir = basePath;

if (options.hasOwnProperty('genDir')) {
genDir = path.resolve(process.cwd(), options.genDir);
}

this._compilerOptions = tsConfig.options;
this._angularCompilerOptions = Object.assign(
{ genDir },
this._compilerOptions,
tsConfig.raw['angularCompilerOptions'],
{ basePath }
);

this._basePath = basePath;
this._genDir = genDir;

// this._compilerHost = new WebpackCompilerHost(this._compilerOptions, this._basePath);
this._compilerHost = ts.createCompilerHost(this._compilerOptions, true);
this._program = ts.createProgram(
this._rootFilePath, this._compilerOptions, this._compilerHost);

if (options.hasOwnProperty('i18nFormat')) {
this._i18nFormat = options.i18nFormat;
}
}

apply(compiler: any) {
this._compiler = compiler;

compiler.plugin('make', (compilation: any, cb: any) => this._make(compilation, cb));

compiler.plugin('after-emit', (compilation: any, cb: any) => {
this._donePromise = null;
this._compilation = null;
compilation._ngToolsWebpackXi18nPluginInstance = null;
cb();
});
}

private _make(compilation: any, cb: (err?: any, request?: any) => void) {
this._compilation = compilation;
if (this._compilation._ngToolsWebpackXi18nPluginInstance) {
return cb(new Error('An @ngtools/webpack xi18n plugin already exist for ' +
Copy link
Contributor

Choose a reason for hiding this comment

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

You should also validate that we don't have a _ngToolsWebpackPluginInstance, since that would seem to be an error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changes pushed. Travis build failed though. I don't understand why it failed, can you take a look and let me know if I broke something.

'this compilation.'));
}
if (!this._compilation._ngToolsWebpackPluginInstance) {
return cb(new Error('An @ngtools/webpack aot plugin does not exists ' +
'for this compilation'));
}

this._compilation._ngToolsWebpackXi18nPluginInstance = this;

this._resourceLoader = new WebpackResourceLoader(compilation);

this._donePromise = Promise.resolve()
.then(() => {
return __NGTOOLS_PRIVATE_API_2.extractI18n({
basePath: this._basePath,
compilerOptions: this._compilerOptions,
program: this._program,
host: this._compilerHost,
angularCompilerOptions: this._angularCompilerOptions,
i18nFormat: this._i18nFormat,

readResource: (path: string) => this._resourceLoader.get(path)
});
})
.then(() => cb(), (err: any) => {
compilation.errors.push(err);
cb();
});

}
}
1 change: 1 addition & 0 deletions packages/@ngtools/webpack/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'reflect-metadata';

export * from './plugin';
export * from './extract_i18n_plugin';
export {ngcLoader as default} from './loader';
export {PathsPlugin} from './paths-plugin';
17 changes: 17 additions & 0 deletions tests/e2e/tests/i18n/extract-default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {join} from 'path';
import {ng} from '../../utils/process';
import {
expectFileToExist, writeFile,
expectFileToMatch
} from '../../utils/fs';


export default function() {
return ng('generate', 'component', 'i18n-test')
.then(() => writeFile(
join('src/app/i18n-test', 'i18n-test.component.html'),
'<p i18n>Hello world</p>'))
.then(() => ng('xi18n', '--no-progress'))
.then(() => expectFileToExist(join('src', 'messages.xlf')))
.then(() => expectFileToMatch(join('src', 'messages.xlf'), /Hello world/));
}
17 changes: 17 additions & 0 deletions tests/e2e/tests/i18n/extract-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {join} from 'path';
import {ng} from '../../utils/process';
import {
expectFileToExist, writeFile,
expectFileToMatch
} from '../../utils/fs';


export default function() {
return ng('generate', 'component', 'i18n-test')
.then(() => writeFile(
join('src/app/i18n-test', 'i18n-test.component.html'),
'<p i18n>Hello world</p>'))
.then(() => ng('xi18n', '--no-progress', '--output-path', 'src/locale'))
.then(() => expectFileToExist(join('src', 'locale', 'messages.xlf')))
.then(() => expectFileToMatch(join('src', 'locale', 'messages.xlf'), /Hello world/));
}
Loading