diff --git a/packages/@angular/cli/addon/index.js b/packages/@angular/cli/addon/index.js index 5bc9aceb51e6..00cd26fe6672 100644 --- a/packages/@angular/cli/addon/index.js +++ b/packages/@angular/cli/addon/index.js @@ -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, diff --git a/packages/@angular/cli/commands/xi18n.ts b/packages/@angular/cli/commands/xi18n.ts new file mode 100644 index 000000000000..066319bf9c2c --- /dev/null +++ b/packages/@angular/cli/commands/xi18n.ts @@ -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; + diff --git a/packages/@angular/cli/models/webpack-configs/index.ts b/packages/@angular/cli/models/webpack-configs/index.ts index dc3c96bac237..64d0358482cf 100644 --- a/packages/@angular/cli/models/webpack-configs/index.ts +++ b/packages/@angular/cli/models/webpack-configs/index.ts @@ -4,3 +4,4 @@ export * from './production'; export * from './styles'; export * from './typescript'; export * from './utils'; +export * from './xi18n'; diff --git a/packages/@angular/cli/models/webpack-configs/xi18n.ts b/packages/@angular/cli/models/webpack-configs/xi18n.ts new file mode 100644 index 000000000000..8130fcbaeb2b --- /dev/null +++ b/packages/@angular/cli/models/webpack-configs/xi18n.ts @@ -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 + }) + ] + }; +}; diff --git a/packages/@angular/cli/models/webpack-xi18n-config.ts b/packages/@angular/cli/models/webpack-xi18n-config.ts new file mode 100644 index 000000000000..8338d5bf1052 --- /dev/null +++ b/packages/@angular/cli/models/webpack-xi18n-config.ts @@ -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]); + } +} diff --git a/packages/@angular/cli/tasks/extract-i18n.ts b/packages/@angular/cli/tasks/extract-i18n.ts new file mode 100644 index 000000000000..4fdea3d210a5 --- /dev/null +++ b/packages/@angular/cli/tasks/extract-i18n.ts @@ -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; + }); + } +}); diff --git a/packages/@ngtools/webpack/src/extract_i18n_plugin.ts b/packages/@ngtools/webpack/src/extract_i18n_plugin.ts new file mode 100644 index 000000000000..7b4d9ed0a778 --- /dev/null +++ b/packages/@ngtools/webpack/src/extract_i18n_plugin.ts @@ -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; + 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 ' + + '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(); + }); + + } +} diff --git a/packages/@ngtools/webpack/src/index.ts b/packages/@ngtools/webpack/src/index.ts index 067d1e724697..ae975e53884f 100644 --- a/packages/@ngtools/webpack/src/index.ts +++ b/packages/@ngtools/webpack/src/index.ts @@ -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'; diff --git a/tests/e2e/tests/i18n/extract-default.ts b/tests/e2e/tests/i18n/extract-default.ts new file mode 100644 index 000000000000..066711600685 --- /dev/null +++ b/tests/e2e/tests/i18n/extract-default.ts @@ -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'), + '

Hello world

')) + .then(() => ng('xi18n', '--no-progress')) + .then(() => expectFileToExist(join('src', 'messages.xlf'))) + .then(() => expectFileToMatch(join('src', 'messages.xlf'), /Hello world/)); +} diff --git a/tests/e2e/tests/i18n/extract-output.ts b/tests/e2e/tests/i18n/extract-output.ts new file mode 100644 index 000000000000..87677b5a6864 --- /dev/null +++ b/tests/e2e/tests/i18n/extract-output.ts @@ -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'), + '

Hello world

')) + .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/)); +} diff --git a/tests/e2e/tests/i18n/extract-xmb.ts b/tests/e2e/tests/i18n/extract-xmb.ts new file mode 100644 index 000000000000..04adaab6549a --- /dev/null +++ b/tests/e2e/tests/i18n/extract-xmb.ts @@ -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'), + '

Hello world

')) + .then(() => ng('xi18n', '--no-progress', '--i18n-format', 'xmb')) + .then(() => expectFileToExist(join('src', 'messages.xmb'))) + .then(() => expectFileToMatch(join('src', 'messages.xmb'), /Hello world/)); +}