Skip to content

Commit 8602536

Browse files
claderahansl
authored andcommitted
feature(i18n): implement xi18n command (#3340)
Implement i18n messages extractor. Contrary to @angular/complier-cli's command it will not throw an error if a resource is not found.
1 parent 8edb612 commit 8602536

File tree

11 files changed

+385
-0
lines changed

11 files changed

+385
-0
lines changed

packages/@angular/cli/addon/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ module.exports = {
3232
'version': require('../commands/version').default,
3333
'completion': require('../commands/completion').default,
3434
'doc': require('../commands/doc').default,
35+
'xi18n': require('../commands/xi18n').default,
3536

3637
// Easter eggs.
3738
'make-this-awesome': require('../commands/easter-egg').default,
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const Command = require('../ember-cli/lib/models/command');
2+
3+
import {Extracti18nTask} from '../tasks/extract-i18n';
4+
5+
export interface Xi18nOptions {
6+
outputPath?: string;
7+
verbose?: boolean;
8+
i18nFormat?: string;
9+
}
10+
11+
const Xi18nCommand = Command.extend({
12+
name: 'xi18n',
13+
description: 'Extracts i18n messages from source code.',
14+
works: 'insideProject',
15+
availableOptions: [
16+
{
17+
name: 'i18n-format',
18+
type: String,
19+
default: 'xlf',
20+
aliases: ['f', {'xmb': 'xmb'}, {'xlf': 'xlf'}, {'xliff': 'xlf'}]
21+
},
22+
{ name: 'output-path', type: 'Path', default: null, aliases: ['op']},
23+
{ name: 'verbose', type: Boolean, default: false},
24+
{ name: 'progress', type: Boolean, default: true }
25+
26+
],
27+
run: function (commandOptions: any) {
28+
29+
const xi18nTask = new Extracti18nTask({
30+
ui: this.ui,
31+
project: this.project
32+
});
33+
34+
return xi18nTask.run(commandOptions);
35+
}
36+
});
37+
38+
39+
export default Xi18nCommand;
40+

packages/@angular/cli/models/webpack-configs/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './production';
44
export * from './styles';
55
export * from './typescript';
66
export * from './utils';
7+
export * from './xi18n';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as path from 'path';
2+
import {ExtractI18nPlugin} from '@ngtools/webpack';
3+
4+
export const getWebpackExtractI18nConfig = function(
5+
projectRoot: string,
6+
appConfig: any,
7+
genDir: string,
8+
i18nFormat: string): any {
9+
10+
let exclude: string[] = [];
11+
if (appConfig.test) {
12+
exclude.push(path.join(projectRoot, appConfig.root, appConfig.test));
13+
}
14+
15+
return {
16+
plugins: [
17+
new ExtractI18nPlugin({
18+
tsConfigPath: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig),
19+
exclude: exclude,
20+
genDir: genDir,
21+
i18nFormat: i18nFormat
22+
})
23+
]
24+
};
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as path from 'path';
2+
3+
import {CliConfig} from './config';
4+
import {NgCliWebpackConfig} from './webpack-config';
5+
const webpackMerge = require('webpack-merge');
6+
import {getWebpackExtractI18nConfig} from './webpack-configs';
7+
8+
export interface XI18WebpackOptions {
9+
genDir?: string;
10+
buildDir?: string;
11+
i18nFormat?: string;
12+
verbose?: boolean;
13+
progress?: boolean;
14+
}
15+
export class XI18nWebpackConfig extends NgCliWebpackConfig {
16+
17+
public config: any;
18+
19+
constructor(extractOptions: XI18WebpackOptions) {
20+
21+
super({
22+
target: 'development',
23+
verbose: extractOptions.verbose,
24+
progress: extractOptions.progress
25+
});
26+
27+
const configPath = CliConfig.configFilePath();
28+
const projectRoot = path.dirname(configPath);
29+
const appConfig = CliConfig.fromProject().config.apps[0];
30+
31+
const extractI18nConfig =
32+
getWebpackExtractI18nConfig(projectRoot,
33+
appConfig,
34+
extractOptions.genDir,
35+
extractOptions.i18nFormat);
36+
37+
this.config = webpackMerge([this.config, extractI18nConfig]);
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import * as webpack from 'webpack';
2+
import * as path from 'path';
3+
import * as rimraf from 'rimraf';
4+
5+
const Task = require('../ember-cli/lib/models/task');
6+
7+
import {XI18nWebpackConfig} from '../models/webpack-xi18n-config';
8+
import {CliConfig} from '../models/config';
9+
10+
11+
export const Extracti18nTask = Task.extend({
12+
run: function (runTaskOptions: any) {
13+
14+
const project = this.project;
15+
16+
const appConfig = CliConfig.fromProject().config.apps[0];
17+
18+
const buildDir = '.tmp';
19+
const genDir = runTaskOptions.outputPath || appConfig.root;
20+
21+
const config = new XI18nWebpackConfig({
22+
genDir,
23+
buildDir,
24+
i18nFormat: runTaskOptions.i18nFormat,
25+
verbose: runTaskOptions.verbose,
26+
progress: runTaskOptions.progress
27+
}).config;
28+
29+
const webpackCompiler = webpack(config);
30+
31+
return new Promise((resolve, reject) => {
32+
const callback: webpack.compiler.CompilerCallback = (err, stats) => {
33+
if (err) {
34+
return reject(err);
35+
}
36+
37+
if (stats.hasErrors()) {
38+
reject();
39+
} else {
40+
resolve();
41+
}
42+
};
43+
44+
webpackCompiler.run(callback);
45+
})
46+
.then(() => {
47+
// Deletes temporary build folder
48+
rimraf.sync(path.resolve(project.root, buildDir));
49+
})
50+
.catch((err: Error) => {
51+
if (err) {
52+
this.ui.writeError('\nAn error occured during the i18n extraction:\n'
53+
+ ((err && err.stack) || err));
54+
}
55+
throw err;
56+
});
57+
}
58+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import * as ts from 'typescript';
2+
import * as path from 'path';
3+
import * as fs from 'fs';
4+
5+
import {__NGTOOLS_PRIVATE_API_2} from '@angular/compiler-cli';
6+
7+
import {Tapable} from './webpack';
8+
import {WebpackResourceLoader} from './resource_loader';
9+
10+
export interface ExtractI18nPluginOptions {
11+
tsConfigPath: string;
12+
basePath?: string;
13+
genDir?: string;
14+
i18nFormat?: string;
15+
exclude?: string[];
16+
}
17+
18+
export class ExtractI18nPlugin implements Tapable {
19+
private _resourceLoader: WebpackResourceLoader;
20+
21+
private _donePromise: Promise<void>;
22+
private _compiler: any = null;
23+
private _compilation: any = null;
24+
25+
private _tsConfigPath: string;
26+
private _basePath: string;
27+
private _genDir: string;
28+
private _rootFilePath: string[];
29+
private _compilerOptions: any = null;
30+
private _angularCompilerOptions: any = null;
31+
// private _compilerHost: WebpackCompilerHost;
32+
private _compilerHost: ts.CompilerHost;
33+
private _program: ts.Program;
34+
35+
private _i18nFormat: string;
36+
37+
constructor(options: ExtractI18nPluginOptions) {
38+
this._setupOptions(options);
39+
}
40+
41+
private _setupOptions(options: ExtractI18nPluginOptions) {
42+
if (!options.hasOwnProperty('tsConfigPath')) {
43+
throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.');
44+
}
45+
this._tsConfigPath = options.tsConfigPath;
46+
47+
// Check the base path.
48+
const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath);
49+
let basePath = maybeBasePath;
50+
if (fs.statSync(maybeBasePath).isFile()) {
51+
basePath = path.dirname(basePath);
52+
}
53+
if (options.hasOwnProperty('basePath')) {
54+
basePath = path.resolve(process.cwd(), options.basePath);
55+
}
56+
57+
let tsConfigJson: any = null;
58+
try {
59+
tsConfigJson = JSON.parse(fs.readFileSync(this._tsConfigPath, 'utf8'));
60+
} catch (err) {
61+
throw new Error(`An error happened while parsing ${this._tsConfigPath} JSON: ${err}.`);
62+
}
63+
const tsConfig = ts.parseJsonConfigFileContent(
64+
tsConfigJson, ts.sys, basePath, null, this._tsConfigPath);
65+
66+
let fileNames = tsConfig.fileNames;
67+
if (options.hasOwnProperty('exclude')) {
68+
let exclude: string[] = typeof options.exclude == 'string'
69+
? [options.exclude as string] : (options.exclude as string[]);
70+
71+
exclude.forEach((pattern: string) => {
72+
const basePathPattern = '(' + basePath.replace(/\\/g, '/')
73+
.replace(/[\-\[\]\/{}()+?.\\^$|*]/g, '\\$&') + ')?';
74+
pattern = pattern
75+
// Replace windows path separators with forward slashes.
76+
.replace(/\\/g, '/')
77+
// Escape characters that are used normally in regexes, except stars.
78+
.replace(/[\-\[\]{}()+?.\\^$|]/g, '\\$&')
79+
// Two stars replacement.
80+
.replace(/\*\*/g, '(?:.*)')
81+
// One star replacement.
82+
.replace(/\*/g, '(?:[^/]*)')
83+
// Escape characters from the basePath and make sure it's forward slashes.
84+
.replace(/^/, basePathPattern);
85+
86+
const re = new RegExp('^' + pattern + '$');
87+
fileNames = fileNames.filter(x => !x.replace(/\\/g, '/').match(re));
88+
});
89+
} else {
90+
fileNames = fileNames.filter(fileName => !/\.spec\.ts$/.test(fileName));
91+
}
92+
this._rootFilePath = fileNames;
93+
94+
// By default messages will be generated in basePath
95+
let genDir = basePath;
96+
97+
if (options.hasOwnProperty('genDir')) {
98+
genDir = path.resolve(process.cwd(), options.genDir);
99+
}
100+
101+
this._compilerOptions = tsConfig.options;
102+
this._angularCompilerOptions = Object.assign(
103+
{ genDir },
104+
this._compilerOptions,
105+
tsConfig.raw['angularCompilerOptions'],
106+
{ basePath }
107+
);
108+
109+
this._basePath = basePath;
110+
this._genDir = genDir;
111+
112+
// this._compilerHost = new WebpackCompilerHost(this._compilerOptions, this._basePath);
113+
this._compilerHost = ts.createCompilerHost(this._compilerOptions, true);
114+
this._program = ts.createProgram(
115+
this._rootFilePath, this._compilerOptions, this._compilerHost);
116+
117+
if (options.hasOwnProperty('i18nFormat')) {
118+
this._i18nFormat = options.i18nFormat;
119+
}
120+
}
121+
122+
apply(compiler: any) {
123+
this._compiler = compiler;
124+
125+
compiler.plugin('make', (compilation: any, cb: any) => this._make(compilation, cb));
126+
127+
compiler.plugin('after-emit', (compilation: any, cb: any) => {
128+
this._donePromise = null;
129+
this._compilation = null;
130+
compilation._ngToolsWebpackXi18nPluginInstance = null;
131+
cb();
132+
});
133+
}
134+
135+
private _make(compilation: any, cb: (err?: any, request?: any) => void) {
136+
this._compilation = compilation;
137+
if (this._compilation._ngToolsWebpackXi18nPluginInstance) {
138+
return cb(new Error('An @ngtools/webpack xi18n plugin already exist for ' +
139+
'this compilation.'));
140+
}
141+
if (!this._compilation._ngToolsWebpackPluginInstance) {
142+
return cb(new Error('An @ngtools/webpack aot plugin does not exists ' +
143+
'for this compilation'));
144+
}
145+
146+
this._compilation._ngToolsWebpackXi18nPluginInstance = this;
147+
148+
this._resourceLoader = new WebpackResourceLoader(compilation);
149+
150+
this._donePromise = Promise.resolve()
151+
.then(() => {
152+
return __NGTOOLS_PRIVATE_API_2.extractI18n({
153+
basePath: this._basePath,
154+
compilerOptions: this._compilerOptions,
155+
program: this._program,
156+
host: this._compilerHost,
157+
angularCompilerOptions: this._angularCompilerOptions,
158+
i18nFormat: this._i18nFormat,
159+
160+
readResource: (path: string) => this._resourceLoader.get(path)
161+
});
162+
})
163+
.then(() => cb(), (err: any) => {
164+
compilation.errors.push(err);
165+
cb();
166+
});
167+
168+
}
169+
}
+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './plugin';
2+
export * from './extract_i18n_plugin';
23
export {ngcLoader as default} from './loader';
34
export {PathsPlugin} from './paths-plugin';
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {join} from 'path';
2+
import {ng} from '../../utils/process';
3+
import {
4+
expectFileToExist, writeFile,
5+
expectFileToMatch
6+
} from '../../utils/fs';
7+
8+
9+
export default function() {
10+
return ng('generate', 'component', 'i18n-test')
11+
.then(() => writeFile(
12+
join('src/app/i18n-test', 'i18n-test.component.html'),
13+
'<p i18n>Hello world</p>'))
14+
.then(() => ng('xi18n', '--no-progress'))
15+
.then(() => expectFileToExist(join('src', 'messages.xlf')))
16+
.then(() => expectFileToMatch(join('src', 'messages.xlf'), /Hello world/));
17+
}
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {join} from 'path';
2+
import {ng} from '../../utils/process';
3+
import {
4+
expectFileToExist, writeFile,
5+
expectFileToMatch
6+
} from '../../utils/fs';
7+
8+
9+
export default function() {
10+
return ng('generate', 'component', 'i18n-test')
11+
.then(() => writeFile(
12+
join('src/app/i18n-test', 'i18n-test.component.html'),
13+
'<p i18n>Hello world</p>'))
14+
.then(() => ng('xi18n', '--no-progress', '--output-path', 'src/locale'))
15+
.then(() => expectFileToExist(join('src', 'locale', 'messages.xlf')))
16+
.then(() => expectFileToMatch(join('src', 'locale', 'messages.xlf'), /Hello world/));
17+
}

0 commit comments

Comments
 (0)