diff --git a/package.json b/package.json index 09ab340aa8ee..610c32ca1cfb 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "style-loader": "^0.13.1", "stylus": "^0.54.5", "stylus-loader": "^3.0.1", + "tree-kill": "^1.0.0", "typescript": "~2.4.2", "uglifyjs-webpack-plugin": "1.0.0-beta.1", "url-loader": "^0.5.7", @@ -138,7 +139,6 @@ "tar": "^3.1.5", "temp": "0.8.3", "through": "^2.3.6", - "tree-kill": "^1.0.0", "ts-node": "^3.2.0", "tslint": "^5.1.0", "walk-sync": "^0.3.1" diff --git a/packages/@angular/cli/models/build-options.ts b/packages/@angular/cli/models/build-options.ts index a2b0d4d32249..41682180c263 100644 --- a/packages/@angular/cli/models/build-options.ts +++ b/packages/@angular/cli/models/build-options.ts @@ -12,6 +12,8 @@ export interface BuildOptions { progress?: boolean; i18nFile?: string; i18nFormat?: string; + i18nOutFile?: string; + i18nOutFormat?: string; locale?: string; missingTranslation?: string; extractCss?: boolean; diff --git a/packages/@angular/cli/models/webpack-configs/typescript.ts b/packages/@angular/cli/models/webpack-configs/typescript.ts index f01992b17583..17f8775a1294 100644 --- a/packages/@angular/cli/models/webpack-configs/typescript.ts +++ b/packages/@angular/cli/models/webpack-configs/typescript.ts @@ -1,6 +1,12 @@ import * as path from 'path'; import { stripIndent } from 'common-tags'; -import { AotPlugin, AngularCompilerPlugin } from '@ngtools/webpack'; +import { + AotPlugin, + AotPluginOptions, + AngularCompilerPlugin, + AngularCompilerPluginOptions, + PLATFORM +} from '@ngtools/webpack'; import { WebpackConfigOptions } from '../webpack-config'; const SilentError = require('silent-error'); @@ -67,22 +73,35 @@ function _createAotPlugin(wco: WebpackConfigOptions, options: any) { }; } - const pluginOptions = Object.assign({}, { - mainPath: path.join(projectRoot, appConfig.root, appConfig.main), - i18nFile: buildOptions.i18nFile, - i18nFormat: buildOptions.i18nFormat, - locale: buildOptions.locale, - replaceExport: appConfig.platform === 'server', - missingTranslation: buildOptions.missingTranslation, - hostReplacementPaths, - sourceMap: buildOptions.sourcemaps, - // If we don't explicitely list excludes, it will default to `['**/*.spec.ts']`. - exclude: [] - }, options); - - if (wco.buildOptions.experimentalAngularCompiler && !options.skipCodeGeneration) { +if (wco.buildOptions.experimentalAngularCompiler) { + const pluginOptions: AngularCompilerPluginOptions = Object.assign({}, { + mainPath: path.join(projectRoot, appConfig.root, appConfig.main), + i18nInFile: buildOptions.i18nFile, + i18nInFormat: buildOptions.i18nFormat, + i18nOutFile: buildOptions.i18nOutFile, + i18nOutFormat: buildOptions.i18nOutFormat, + locale: buildOptions.locale, + platform: appConfig.platform === 'server' ? PLATFORM.Server : PLATFORM.Browser, + missingTranslation: buildOptions.missingTranslation, + hostReplacementPaths, + sourceMap: buildOptions.sourcemaps, + // If we don't explicitely list excludes, it will default to `['**/*.spec.ts']`. + exclude: [] + }, options); return new AngularCompilerPlugin(pluginOptions); } else { + const pluginOptions: AotPluginOptions = Object.assign({}, { + mainPath: path.join(projectRoot, appConfig.root, appConfig.main), + i18nFile: buildOptions.i18nFile, + i18nFormat: buildOptions.i18nFormat, + locale: buildOptions.locale, + replaceExport: appConfig.platform === 'server', + missingTranslation: buildOptions.missingTranslation, + hostReplacementPaths, + sourceMap: buildOptions.sourcemaps, + // If we don't explicitely list excludes, it will default to `['**/*.spec.ts']`. + exclude: [] + }, options); return new AotPlugin(pluginOptions); } } diff --git a/packages/@angular/cli/models/webpack-xi18n-config.ts b/packages/@angular/cli/models/webpack-xi18n-config.ts index 816d4091474e..8413d0bdf43a 100644 --- a/packages/@angular/cli/models/webpack-xi18n-config.ts +++ b/packages/@angular/cli/models/webpack-xi18n-config.ts @@ -14,6 +14,7 @@ export interface XI18WebpackOptions { verbose?: boolean; progress?: boolean; app?: string; + experimentalAngularCompiler?: boolean; } export class XI18nWebpackConfig extends NgCliWebpackConfig { @@ -25,24 +26,31 @@ export class XI18nWebpackConfig extends NgCliWebpackConfig { target: 'development', verbose: extractOptions.verbose, progress: extractOptions.progress, - experimentalAngularCompiler: false, + experimentalAngularCompiler: extractOptions.experimentalAngularCompiler, + locale: extractOptions.locale, + i18nOutFormat: extractOptions.i18nFormat, + i18nOutFile: extractOptions.outFile, + aot: extractOptions.experimentalAngularCompiler }, appConfig); super.buildConfig(); } public buildConfig() { - const configPath = CliConfig.configFilePath(); - const projectRoot = path.dirname(configPath); - - const extractI18nConfig = - getWebpackExtractI18nConfig(projectRoot, - this.appConfig, - this.extractOptions.genDir, - this.extractOptions.i18nFormat, - this.extractOptions.locale, - this.extractOptions.outFile); - - this.config = webpackMerge([this.config, extractI18nConfig]); + // The extra extraction config is only needed in Angular 2/4. + if (!this.extractOptions.experimentalAngularCompiler) { + const configPath = CliConfig.configFilePath(); + const projectRoot = path.dirname(configPath); + + const extractI18nConfig = + getWebpackExtractI18nConfig(projectRoot, + this.appConfig, + this.extractOptions.genDir, + this.extractOptions.i18nFormat, + this.extractOptions.locale, + this.extractOptions.outFile); + + this.config = webpackMerge([this.config, extractI18nConfig]); + } return this.config; } } diff --git a/packages/@angular/cli/tasks/eject.ts b/packages/@angular/cli/tasks/eject.ts index 510c790ec190..6fae9fe20091 100644 --- a/packages/@angular/cli/tasks/eject.ts +++ b/packages/@angular/cli/tasks/eject.ts @@ -122,7 +122,7 @@ class JsonWebpackSerializer { const basePath = path.dirname(tsConfigPath); return Object.assign({}, value.options, { tsConfigPath, - mainPath: path.relative(value.basePath, value.options.mainPath), + mainPath: path.relative(basePath, value.options.mainPath), hostReplacementPaths: Object.keys(value.options.hostReplacementPaths) .reduce((acc: any, key: string) => { const replacementPath = value.options.hostReplacementPaths[key]; @@ -132,7 +132,7 @@ class JsonWebpackSerializer { }, {}), exclude: Array.isArray(value.options.exclude) ? value.options.exclude.map((p: any) => { - return p.startsWith('/') ? path.relative(value.basePath, p) : p; + return p.startsWith('/') ? path.relative(basePath, p) : p; }) : value.options.exclude }); diff --git a/packages/@angular/cli/tasks/extract-i18n.ts b/packages/@angular/cli/tasks/extract-i18n.ts index 8d2a31dfaaba..0cb76ddc930d 100644 --- a/packages/@angular/cli/tasks/extract-i18n.ts +++ b/packages/@angular/cli/tasks/extract-i18n.ts @@ -1,4 +1,6 @@ +import { join } from 'path'; import * as webpack from 'webpack'; +import { AngularCompilerPlugin } from '@ngtools/webpack'; import { XI18nWebpackConfig } from '../models/webpack-xi18n-config'; import { getAppFromConfig } from '../utilities/app-utils'; @@ -9,16 +11,25 @@ const MemoryFS = require('memory-fs'); export const Extracti18nTask = Task.extend({ run: function (runTaskOptions: any) { const appConfig = getAppFromConfig(runTaskOptions.app); + const experimentalAngularCompiler = AngularCompilerPlugin.isSupported(); + + // We need to determine the outFile name so that AngularCompiler can retrieve it. + let outFile = runTaskOptions.outFile || getI18nOutfile(runTaskOptions.i18nFormat); + if (experimentalAngularCompiler && runTaskOptions.outputPath) { + // AngularCompilerPlugin doesn't support genDir so we have to adjust outFile instead. + outFile = join(runTaskOptions.outputPath, outFile); + } const config = new XI18nWebpackConfig({ genDir: runTaskOptions.outputPath || appConfig.root, buildDir: '.tmp', i18nFormat: runTaskOptions.i18nFormat, locale: runTaskOptions.locale, - outFile: runTaskOptions.outFile, + outFile: outFile, verbose: runTaskOptions.verbose, progress: runTaskOptions.progress, app: runTaskOptions.app, + experimentalAngularCompiler, }, appConfig).buildConfig(); const webpackCompiler = webpack(config); @@ -47,3 +58,18 @@ export const Extracti18nTask = Task.extend({ }); } }); + +function getI18nOutfile(format: string) { + switch (format) { + case 'xmb': + return 'messages.xmb'; + case 'xlf': + case 'xlif': + case 'xliff': + case 'xlf2': + case 'xliff2': + return 'messages.xlf'; + default: + throw new Error(`Unsupported format "${format}"`); + } +} diff --git a/packages/@ngtools/webpack/package.json b/packages/@ngtools/webpack/package.json index 8e2754b627ec..40517d341221 100644 --- a/packages/@ngtools/webpack/package.json +++ b/packages/@ngtools/webpack/package.json @@ -25,9 +25,12 @@ "npm": ">= 3.0.0" }, "dependencies": { + "tree-kill": "^1.0.0", + "chalk": "^2.0.1", "loader-utils": "^1.0.2", "enhanced-resolve": "^3.1.0", "magic-string": "^0.22.3", + "semver": "^5.3.0", "source-map": "^0.5.6" }, "peerDependencies": { diff --git a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts index 36cb28d48d67..b2c3c8c2e29e 100644 --- a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts @@ -1,12 +1,13 @@ // @ignoreDep @angular/compiler-cli -// @ignoreDep @angular/compiler-cli/ngtools2 import * as fs from 'fs'; +import { fork, ChildProcess } from 'child_process'; import * as path from 'path'; import * as ts from 'typescript'; const { __NGTOOLS_PRIVATE_API_2, VERSION } = require('@angular/compiler-cli'); const ContextElementDependency = require('webpack/lib/dependencies/ContextElementDependency'); const NodeWatchFileSystem = require('webpack/lib/node/NodeWatchFileSystem'); +const treeKill = require('tree-kill'); import { WebpackResourceLoader } from './resource_loader'; import { WebpackCompilerHost } from './compiler_host'; @@ -21,31 +22,27 @@ import { replaceBootstrap, exportNgFactory, exportLazyModuleMap, - registerLocaleData + registerLocaleData, + replaceResources, } from './transformers'; - -// These imports do not exist on Angular versions lower than 5. -// The commented imports are for types that we use but have to replace with `any` for now. -// Types replaced this way have a comment on them. -// @ignoreDep @angular/compiler-cli/src/transformers/api -let compilerCliNgtools: any = {}; -try { - compilerCliNgtools = require('@angular/compiler-cli/ngtools2'); -} catch (e) { - // Don't throw an error if the private API does not exist. - // Instead, the `isSupported` method should return false and indicate the plugin cannot be used. -} - -const { - // Program, - // CompilerHost, +import { time, timeEnd } from './benchmark'; +import { InitMessage, UpdateMessage } from './type_checker'; +import { gatherDiagnostics, hasErrors } from './gather_diagnostics'; +import { + DEFAULT_ERROR_CODE, + UNKNOWN_ERROR_CODE, + SOURCE, + Program, + CompilerOptions, + CompilerHost, + Diagnostics, + CustomTransformers, createProgram, createCompilerHost, - // Diagnostic, formatDiagnostics, EmitFlags, - // CustomTransformers, -} = compilerCliNgtools; +} from './ngtools_api2'; + /** * Option Constants @@ -56,14 +53,16 @@ export interface AngularCompilerPluginOptions { basePath?: string; entryModule?: string; mainPath?: string; - typeChecking?: boolean; + skipCodeGeneration?: boolean; hostOverrideFileSystem?: { [path: string]: string }; hostReplacementPaths?: { [path: string]: string }; - i18nFile?: string; - i18nFormat?: string; + i18nInFile?: string; + i18nInFormat?: string; + i18nOutFile?: string; + i18nOutFormat?: string; locale?: string; missingTranslation?: string; - replaceExport?: boolean; + platform?: PLATFORM; // Use tsconfig to include path globs. exclude?: string | string[]; @@ -71,7 +70,7 @@ export interface AngularCompilerPluginOptions { compilerOptions?: ts.CompilerOptions; } -enum PLATFORM { +export enum PLATFORM { Browser, Server } @@ -81,20 +80,19 @@ export class AngularCompilerPlugin implements Tapable { // TS compilation. private _compilerOptions: ts.CompilerOptions; - private _angularCompilerOptions: any; + private _angularCompilerOptions: CompilerOptions; private _tsFilenames: string[]; - // Should be Program from @angular/compiler-cli instead of any. - private _program: any; + private _program: ts.Program | Program; private _compilerHost: WebpackCompilerHost; - // Should be CompilerHost from @angular/compiler-cli instead of any. - private _angularCompilerHost: WebpackCompilerHost & any; - // Contains `factoryModuleImportPath#factoryExportName` => `fullFactoryModulePath`. + private _angularCompilerHost: WebpackCompilerHost & CompilerHost; + // Contains `moduleImportPath#exportName` => `fullModulePath`. private _lazyRoutes: LazyRouteMap = Object.create(null); private _tsConfigPath: string; private _entryModule: string; private _basePath: string; private _transformMap: Map = new Map(); private _platform: PLATFORM; + private _JitMode = false; // Webpack plugin. private _firstRun = true; @@ -103,6 +101,10 @@ export class AngularCompilerPlugin implements Tapable { private _compilation: any = null; private _failedCompilation = false; + // TypeChecker process. + private _forkTypeChecker = true; + private _typeCheckerProcess: ChildProcess; + constructor(options: AngularCompilerPluginOptions) { this._options = Object.assign({}, options); this._setupOptions(this._options); @@ -123,6 +125,7 @@ export class AngularCompilerPlugin implements Tapable { } private _setupOptions(options: AngularCompilerPluginOptions) { + time('AngularCompilerPlugin._setupOptions'); // Fill in the missing options. if (!options.hasOwnProperty('tsConfigPath')) { throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); @@ -223,18 +226,30 @@ export class AngularCompilerPlugin implements Tapable { { basePath } ); + // Set JIT (no code generation) or AOT mode. + if (options.skipCodeGeneration !== undefined) { + this._JitMode = options.skipCodeGeneration; + } + // Process i18n options. - if (options.hasOwnProperty('i18nFile')) { - this._angularCompilerOptions.i18nInFile = options.i18nFile; + if (options.hasOwnProperty('i18nInFile')) { + this._angularCompilerOptions.i18nInFile = options.i18nInFile; } - if (options.hasOwnProperty('i18nFormat')) { - this._angularCompilerOptions.i18nInFormat = options.i18nFormat; + if (options.hasOwnProperty('i18nInFormat')) { + this._angularCompilerOptions.i18nInFormat = options.i18nInFormat; } - if (options.hasOwnProperty('locale')) { - this._angularCompilerOptions.i18nInLocale = options.locale; + if (options.hasOwnProperty('i18nOutFile')) { + this._angularCompilerOptions.i18nOutFile = options.i18nOutFile; + } + if (options.hasOwnProperty('i18nOutFormat')) { + this._angularCompilerOptions.i18nOutFormat = options.i18nOutFormat; + } + if (options.hasOwnProperty('locale') && options.locale) { + this._angularCompilerOptions.i18nInLocale = this._validateLocale(options.locale); } if (options.hasOwnProperty('missingTranslation')) { - this._angularCompilerOptions.i18nInMissingTranslations = options.missingTranslation; + this._angularCompilerOptions.i18nInMissingTranslations = + options.missingTranslation as 'error' | 'warning' | 'ignore'; } // Use entryModule if available in options, otherwise resolve it from mainPath after program @@ -266,27 +281,69 @@ export class AngularCompilerPlugin implements Tapable { } } - // TODO: consider really using platform names in the plugin options. - this._platform = options.replaceExport ? PLATFORM.Server : PLATFORM.Browser; + this._platform = options.platform || PLATFORM.Browser; + timeEnd('AngularCompilerPlugin._setupOptions'); } - private _findLazyRoutesInAst(changedFilePaths: string[]): LazyRouteMap { - const result: LazyRouteMap = Object.create(null); - for (const filePath of changedFilePaths) { - const fileLazyRoutes = findLazyRoutes(filePath, this._compilerHost, undefined, - this._compilerOptions); - for (const routeKey of Object.keys(fileLazyRoutes)) { - const route = fileLazyRoutes[routeKey]; - result[routeKey] = route; + private _getTsProgram() { + return this._JitMode ? this._program as ts.Program : (this._program as Program).getTsProgram(); + } + + private _getChangedTsFiles() { + return this._compilerHost.getChangedFilePaths() + .filter(k => k.endsWith('.ts') && !k.endsWith('.d.ts')) + .filter(k => this._compilerHost.fileExists(k)); + } + + private _createOrUpdateProgram() { + const changedTsFiles = this._getChangedTsFiles(); + changedTsFiles.forEach((file) => { + if (!this._tsFilenames.includes(file)) { + // TODO: figure out if action is needed for files that were removed from the compilation. + this._tsFilenames.push(file); } + }); + + // Update the forked type checker. + if (this._forkTypeChecker && !this._firstRun) { + this._updateForkedTypeChecker(changedTsFiles); + } + + if (this._JitMode) { + + // Create the TypeScript program. + time('AngularCompilerPlugin._createOrUpdateProgram.ts.createProgram'); + this._program = ts.createProgram( + this._tsFilenames, + this._angularCompilerOptions, + this._angularCompilerHost, + this._program as ts.Program + ); + timeEnd('AngularCompilerPlugin._createOrUpdateProgram.ts.createProgram'); + + return Promise.resolve(); + } else { + time('AngularCompilerPlugin._createOrUpdateProgram.ng.createProgram'); + // Create the Angular program. + this._program = createProgram({ + rootNames: this._tsFilenames, + options: this._angularCompilerOptions, + host: this._angularCompilerHost, + oldProgram: this._program as Program + }); + timeEnd('AngularCompilerPlugin._createOrUpdateProgram.ng.createProgram'); + + time('AngularCompilerPlugin._createOrUpdateProgram.ng.loadNgStructureAsync'); + return this._program.loadNgStructureAsync().then(() => + timeEnd('AngularCompilerPlugin._createOrUpdateProgram.ng.loadNgStructureAsync')); } - return result; } private _getLazyRoutesFromNgtools() { try { - return __NGTOOLS_PRIVATE_API_2.listLazyRoutes({ - program: this._program.getTsProgram(), + time('AngularCompilerPlugin._getLazyRoutesFromNgtools'); + const result = __NGTOOLS_PRIVATE_API_2.listLazyRoutes({ + program: this._getTsProgram(), host: this._compilerHost, angularCompilerOptions: Object.assign({}, this._angularCompilerOptions, { // genDir seems to still be needed in @angular\compiler-cli\src\compiler_host.js:226. @@ -294,6 +351,8 @@ export class AngularCompilerPlugin implements Tapable { }), entryModule: this._entryModule }); + timeEnd('AngularCompilerPlugin._getLazyRoutesFromNgtools'); + return result; } catch (err) { // We silence the error that the @angular/router could not be found. In that case, there is // basically no route supported by the app itself. @@ -305,8 +364,25 @@ export class AngularCompilerPlugin implements Tapable { } } - // Process the lazy routes discovered, adding and removing them from _lazyRoutes. - // TODO: ensure these are correct. + private _findLazyRoutesInAst(changedFilePaths: string[]): LazyRouteMap { + time('AngularCompilerPlugin._findLazyRoutesInAst'); + const result: LazyRouteMap = Object.create(null); + for (const filePath of changedFilePaths) { + const fileLazyRoutes = findLazyRoutes(filePath, this._compilerHost, undefined, + this._compilerOptions); + for (const routeKey of Object.keys(fileLazyRoutes)) { + const route = fileLazyRoutes[routeKey]; + result[routeKey] = route; + } + } + timeEnd('AngularCompilerPlugin._findLazyRoutesInAst'); + return result; + } + + // Process the lazy routes discovered, adding then to _lazyRoutes. + // TODO: find a way to remove lazy routes that don't exist anymore. + // This will require a registry of known references to a lazy route, removing it when no + // module references it anymore. private _processLazyRoutes(discoveredLazyRoutes: { [route: string]: string; }) { Object.keys(discoveredLazyRoutes) .forEach(lazyRouteKey => { @@ -316,15 +392,19 @@ export class AngularCompilerPlugin implements Tapable { return; } - const factoryPath = discoveredLazyRoutes[lazyRouteKey] - .replace(/(\.d)?\.ts$/, '.ngfactory.js'); - const factoryKey = `${lazyRouteModule}.ngfactory#${moduleName}NgFactory`; + const lazyRouteTSFile = discoveredLazyRoutes[lazyRouteKey]; + let modulePath: string, moduleKey: string; - if (factoryKey in this._lazyRoutes) { - if (factoryPath === null) { - // This lazy route does not exist anymore, remove it from the list. - this._lazyRoutes[lazyRouteKey] = null; - } else if (this._lazyRoutes[factoryKey] !== factoryPath) { + if (this._JitMode) { + modulePath = lazyRouteTSFile; + moduleKey = lazyRouteKey; + } else { + modulePath = lazyRouteTSFile.replace(/(\.d)?\.ts$/, `.ngfactory.js`); + moduleKey = `${lazyRouteModule}.ngfactory#${moduleName}NgFactory`; + } + + if (moduleKey in this._lazyRoutes) { + if (this._lazyRoutes[moduleKey] !== modulePath) { // Found a duplicate, this is an error. this._compilation.warnings.push( new Error(`Duplicated path in loadChildren detected during a rebuild. ` @@ -334,13 +414,35 @@ export class AngularCompilerPlugin implements Tapable { } } else { // Found a new route, add it to the map and read it into the compiler host. - this._lazyRoutes[factoryKey] = factoryPath; - this._angularCompilerHost.readFile(lazyRouteModule); - this._angularCompilerHost.invalidate(lazyRouteModule); + this._lazyRoutes[moduleKey] = modulePath; + this._angularCompilerHost.readFile(lazyRouteTSFile); + this._angularCompilerHost.invalidate(lazyRouteTSFile); } }); } + private _createForkedTypeChecker() { + // Bootstrap type checker is using local CLI. + const g: any = global; + const typeCheckerFile: string = g['angularCliIsLocal'] + ? './type_checker_bootstrap.js' + : './type_checker.js'; + + this._typeCheckerProcess = fork(path.resolve(__dirname, typeCheckerFile)); + this._typeCheckerProcess.send(new InitMessage(this._compilerOptions, this._basePath, + this._JitMode, this._tsFilenames)); + + // Cleanup. + const killTypeCheckerProcess = () => treeKill(this._typeCheckerProcess.pid, 'SIGTERM'); + process.on('exit', killTypeCheckerProcess); + process.on('SIGINT', killTypeCheckerProcess); + process.on('uncaughtException', killTypeCheckerProcess); + } + + private _updateForkedTypeChecker(changedTsFiles: string[]) { + this._typeCheckerProcess.send(new UpdateMessage(changedTsFiles)); + } + // Registration hook for webpack plugin. apply(compiler: any) { @@ -450,6 +552,7 @@ export class AngularCompilerPlugin implements Tapable { } private _make(compilation: any, cb: (err?: any, request?: any) => void) { + time('AngularCompilerPlugin._make'); this._compilation = compilation; if (this._compilation._ngToolsWebpackPluginInstance) { return cb(new Error('An @ngtools/webpack plugin already exist for this compilation.')); @@ -458,49 +561,57 @@ export class AngularCompilerPlugin implements Tapable { this._compilation._ngToolsWebpackPluginInstance = this; // Create the resource loader with the webpack compilation. + time('AngularCompilerPlugin._make.setResourceLoader'); const resourceLoader = new WebpackResourceLoader(compilation); this._compilerHost.setResourceLoader(resourceLoader); + timeEnd('AngularCompilerPlugin._make.setResourceLoader'); this._donePromise = Promise.resolve() + .then(() => { + // Create a new process for the type checker. + if (!this._firstRun && !this._typeCheckerProcess) { + this._createForkedTypeChecker(); + } + }) .then(() => { if (this._firstRun) { - // Use the WebpackResourceLoaderwith a resource loader to create an AngularCompilerHost. + // Use the WebpackResourceLoader with a resource loader to create an AngularCompilerHost. this._angularCompilerHost = createCompilerHost({ options: this._angularCompilerOptions, tsHost: this._compilerHost - // Should be CompilerHost from @angular/compiler-cli instead of any. - }) as any & WebpackCompilerHost; - - // Create the Angular program. - this._program = createProgram({ - rootNames: this._tsFilenames, - options: this._compilerOptions, - host: this._angularCompilerHost - }); + }) as CompilerHost & WebpackCompilerHost; - return this._program.loadNgStructureAsync() + return this._createOrUpdateProgram() .then(() => { // If there's still no entryModule try to resolve from mainPath. if (!this._entryModule && this._options.mainPath) { + time('AngularCompilerPlugin._make.resolveEntryModuleFromMain'); const mainPath = path.resolve(this._basePath, this._options.mainPath); this._entryModule = resolveEntryModuleFromMain( - mainPath, this._compilerHost, this._program.getTsProgram()); + mainPath, this._compilerHost, this._getTsProgram()); + timeEnd('AngularCompilerPlugin._make.resolveEntryModuleFromMain'); } }); } }) .then(() => this._update()) .then(() => { + timeEnd('AngularCompilerPlugin._make'); cb(); }, (err: any) => { this._failedCompilation = true; compilation.errors.push(err.stack); + timeEnd('AngularCompilerPlugin._make'); cb(); }); } private _update() { - let changedFiles: string[] = []; + // We only want to update on TS and template changes, but all kinds of files are on this + // list, like package.json and .ngsummary.json files. + time('AngularCompilerPlugin._update'); + let changedFiles = this._compilerHost.getChangedFilePaths() + .filter(k => /(ts|html|css|scss|sass|less|styl)/.test(k)); return Promise.resolve() .then(() => { @@ -516,59 +627,58 @@ export class AngularCompilerPlugin implements Tapable { this._processLazyRoutes(this._findLazyRoutesInAst(changedTsFiles)); } }) - .then(() => { - // We only want to update on TS and template changes, but all kinds of files are on this - // list, like package.json and .ngsummary.json files. - changedFiles = this._compilerHost.getChangedFilePaths() - .filter(k => /(ts|html|css|scss|sass|less|styl)/.test(k)); - }) .then(() => { // Make a new program and load the Angular structure if there are changes. if (changedFiles.length > 0) { - this._tsFilenames = this._tsFilenames.concat(changedFiles) - .filter(k => k.endsWith('.ts')) - .filter(k => this._compilerHost.fileExists(k)); - - this._program = createProgram({ - rootNames: this._tsFilenames, - options: this._angularCompilerOptions, - host: this._angularCompilerHost, - oldProgram: this._program - }); - - return this._program.loadNgStructureAsync(); + return this._createOrUpdateProgram(); } }) .then(() => { // Build transforms, emit and report errors if there are changes or it's the first run. if (changedFiles.length > 0 || this._firstRun) { + // We now have the final list of changed TS files. // Go through each changed file and add transforms as needed. - const changedTsFiles = this._compilerHost.getChangedFilePaths() - .filter(k => k.endsWith('.ts')); + const sourceFiles = this._getChangedTsFiles().map((fileName) => { + time('AngularCompilerPlugin._update.getSourceFile'); + const sourceFile = this._getTsProgram().getSourceFile(fileName); + if (!sourceFile) { + throw new Error(`${fileName} is not part of the TypeScript compilation. ` + + `Please include it in your tsconfig via the 'files' or 'include' property.`); + } + timeEnd('AngularCompilerPlugin._update.getSourceFile'); + return sourceFile; + }); + + time('AngularCompilerPlugin._update.transformOps'); + sourceFiles.forEach((sf) => { + const fileName = this._compilerHost.resolve(sf.fileName); + let transformOps = []; + + if (this._JitMode) { + transformOps.push(...replaceResources(sf)); + } - changedTsFiles.forEach((fileName) => { - const sourceFile = this._program.getTsProgram().getSourceFile(fileName); - let transformOps; if (this._platform === PLATFORM.Browser) { - transformOps = [ - ...replaceBootstrap(sourceFile, this.entryModule) - ]; + if (!this._JitMode) { + transformOps.push(...replaceBootstrap(sf, this.entryModule)); + } - // if we have a locale, auto import the locale data file + // If we have a locale, auto import the locale data file. if (this._angularCompilerOptions.i18nInLocale) { transformOps.push(...registerLocaleData( - sourceFile, + sf, this.entryModule, this._angularCompilerOptions.i18nInLocale )); } } else if (this._platform === PLATFORM.Server) { - // export_module_map - transformOps = [ - ...exportNgFactory(sourceFile, this.entryModule), - ...exportLazyModuleMap(sourceFile, this._lazyRoutes) - ]; + if (fileName === this._compilerHost.resolve(this._options.mainPath)) { + transformOps.push(...exportLazyModuleMap(sf, this._lazyRoutes)); + if (!this._JitMode) { + transformOps.push(...exportNgFactory(sf, this.entryModule)); + } + } } // We need to keep a map of transforms for each file, to reapply on each update. @@ -579,18 +689,20 @@ export class AngularCompilerPlugin implements Tapable { for (let fileTransformOps of this._transformMap.values()) { transformOps.push(...fileTransformOps); } + timeEnd('AngularCompilerPlugin._update.transformOps'); - // Should be CustomTransformers from @angular/compiler-cli instead of any. - const transformers: any = { + time('AngularCompilerPlugin._update.makeTransform'); + const transformers: CustomTransformers = { beforeTs: transformOps.length > 0 ? [makeTransform(transformOps)] : [] }; + timeEnd('AngularCompilerPlugin._update.makeTransform'); // Emit files. - const { program, emitResult, diagnostics } = this._emit(this._program, transformers); - this._program = program; + time('AngularCompilerPlugin._update._emit'); + const { emitResult, diagnostics } = this._emit(sourceFiles, transformers); + timeEnd('AngularCompilerPlugin._update._emit'); // Report diagnostics. - // TODO: check if the old _translateSourceMap function is needed. const errors = diagnostics .filter((diag) => diag.category === ts.DiagnosticCategory.Error); const warnings = diagnostics @@ -613,9 +725,28 @@ export class AngularCompilerPlugin implements Tapable { this._failedCompilation = true; } } + timeEnd('AngularCompilerPlugin._update'); }); } + writeI18nOutFile() { + function _recursiveMkDir(p: string): Promise { + if (fs.existsSync(p)) { + return Promise.resolve(); + } else { + return _recursiveMkDir(path.dirname(p)) + .then(() => fs.mkdirSync(p)); + } + } + + // Write the extracted messages to disk. + const i18nOutFilePath = path.resolve(this._basePath, this._angularCompilerOptions.i18nOutFile); + const i18nOutFileContent = this._compilerHost.readFile(i18nOutFilePath); + if (i18nOutFileContent) { + _recursiveMkDir(path.dirname(i18nOutFilePath)) + .then(() => fs.writeFileSync(i18nOutFilePath, i18nOutFileContent)); + } + } getFile(fileName: string) { const outputFile = fileName.replace(/.ts$/, '.js'); @@ -628,63 +759,136 @@ export class AngularCompilerPlugin implements Tapable { // This code mostly comes from `performCompilation` in `@angular/compiler-cli`. // It skips the program creation because we need to use `loadNgStructureAsync()`, // and uses CustomTransformers. - // Should be Program and CustomTransformers from @angular/compiler-cli instead of any. - private _emit(program: any, customTransformers: any) { - // Should be Diagnostic from @angular/compiler-cli instead of any. - type Diagnostics = Array; + private _emit( + sourceFiles: ts.SourceFile[], + customTransformers: ts.CustomTransformers & CustomTransformers + ) { + time('AngularCompilerPlugin._emit'); + const program = this._program; const allDiagnostics: Diagnostics = []; - function checkDiagnostics(diags: Diagnostics | undefined) { - if (diags) { - allDiagnostics.push(...diags); - return diags.every(d => d.category !== ts.DiagnosticCategory.Error); - } - return true; - } - let emitResult: ts.EmitResult | undefined; try { - let shouldEmit = true; - // Check parameter diagnostics - shouldEmit = shouldEmit && checkDiagnostics([ - ...program.getTsOptionDiagnostics(), ...program.getNgOptionDiagnostics() - ]); - - // Check syntactic diagnostics - shouldEmit = shouldEmit && checkDiagnostics(program.getTsSyntacticDiagnostics()); - - // Check TypeScript semantic and Angular structure diagnostics - shouldEmit = shouldEmit && - checkDiagnostics( - [...program.getTsSemanticDiagnostics(), ...program.getNgStructuralDiagnostics()]); - - // Check Angular semantic diagnostics - shouldEmit = shouldEmit && checkDiagnostics(program.getNgSemanticDiagnostics()); - - if (shouldEmit) { - emitResult = program.emit({ emitFlags: EmitFlags.Default, customTransformers }); - allDiagnostics.push(...emitResult.diagnostics); + if (this._JitMode) { + const tsProgram = program as ts.Program; + + if (this._firstRun) { + // Check parameter diagnostics. + time('AngularCompilerPlugin._emit.ts.getOptionsDiagnostics'); + allDiagnostics.push(...tsProgram.getOptionsDiagnostics()); + timeEnd('AngularCompilerPlugin._emit.ts.getOptionsDiagnostics'); + } + + if (this._firstRun || !this._forkTypeChecker) { + allDiagnostics.push(...gatherDiagnostics(this._program, this._JitMode, + 'AngularCompilerPluginOptions')); + } + + if (!hasErrors(allDiagnostics)) { + sourceFiles.forEach((sf) => { + const timeLabel = `AngularCompilerPlugin._emit.ts+${sf.fileName}+.emit`; + time(timeLabel); + emitResult = tsProgram.emit(sf, undefined, undefined, undefined, + { before: customTransformers.beforeTs } + ); + allDiagnostics.push(...emitResult.diagnostics); + timeEnd(timeLabel); + }); + } + } else { + const angularProgram = program as Program; + + if (this._firstRun) { + // Check TypeScript parameter diagnostics. + time('AngularCompilerPlugin._emit.ng.getTsOptionDiagnostics'); + allDiagnostics.push(...angularProgram.getTsOptionDiagnostics()); + timeEnd('AngularCompilerPlugin._emit.ng.getTsOptionDiagnostics'); + + // Check Angular parameter diagnostics. + time('AngularCompilerPlugin._emit.ng.getNgOptionDiagnostics'); + allDiagnostics.push(...angularProgram.getNgOptionDiagnostics()); + timeEnd('AngularCompilerPlugin._emit.ng.getNgOptionDiagnostics'); + } + + if (this._firstRun || !this._forkTypeChecker) { + allDiagnostics.push(...gatherDiagnostics(this._program, this._JitMode, + 'AngularCompilerPluginOptions')); + } + + if (!hasErrors(allDiagnostics)) { + time('AngularCompilerPlugin._emit.ng.emit'); + const extractI18n = !!this._angularCompilerOptions.i18nOutFile; + const emitFlags = extractI18n ? EmitFlags.I18nBundle : EmitFlags.Default; + emitResult = angularProgram.emit({ emitFlags, customTransformers }); + allDiagnostics.push(...emitResult.diagnostics); + if (extractI18n) { + this.writeI18nOutFile(); + } + timeEnd('AngularCompilerPlugin._emit.ng.emit'); + } } } catch (e) { - let errMsg: string; - + time('AngularCompilerPlugin._emit.catch'); // This function is available in the import below, but this way we avoid the dependency. // import { isSyntaxError } from '@angular/compiler'; function isSyntaxError(error: Error): boolean { return (error as any)['ngSyntaxError']; } + let errMsg: string; + let code: number; if (isSyntaxError(e)) { // don't report the stack for syntax errors as they are well known errors. errMsg = e.message; + code = DEFAULT_ERROR_CODE; } else { errMsg = e.stack; + // It is not a syntax error we might have a program with unknown state, discard it. + this._program = undefined; + code = UNKNOWN_ERROR_CODE; } - allDiagnostics.push({ - category: ts.DiagnosticCategory.Error, - message: errMsg, - }); + allDiagnostics.push( + { category: ts.DiagnosticCategory.Error, messageText: errMsg, code, source: SOURCE }); + timeEnd('AngularCompilerPlugin._emit.catch'); } + timeEnd('AngularCompilerPlugin._emit'); return { program, emitResult, diagnostics: allDiagnostics }; } + + private _validateLocale(locale: string) { + // Get the path of the common module. + const commonPath = path.dirname(require.resolve('@angular/common/package.json')); + // Check if the locale file exists + if (!fs.existsSync(path.resolve(commonPath, 'locales', `${locale}.js`))) { + // Check for an alternative locale (if the locale id was badly formatted). + const locales = fs.readdirSync(path.resolve(commonPath, 'locales')) + .filter(file => file.endsWith('.js')) + .map(file => file.replace('.js', '')); + + let newLocale; + const normalizedLocale = locale.toLowerCase().replace(/_/g, '-'); + for (const l of locales) { + if (l.toLowerCase() === normalizedLocale) { + newLocale = l; + break; + } + } + + if (newLocale) { + locale = newLocale; + } else { + // Check for a parent locale + const parentLocale = normalizedLocale.split('-')[0]; + if (locales.indexOf(parentLocale) !== -1) { + locale = parentLocale; + } else { + throw new Error( + `Unable to load the locale data file "@angular/common/locales/${locale}", ` + + `please check that "${locale}" is a valid locale id.`); + } + } + } + + return locale; + } } diff --git a/packages/@ngtools/webpack/src/benchmark.ts b/packages/@ngtools/webpack/src/benchmark.ts new file mode 100644 index 000000000000..58fc7443061b --- /dev/null +++ b/packages/@ngtools/webpack/src/benchmark.ts @@ -0,0 +1,16 @@ +// Internal benchmark reporting flag. +// Use with CLI --no-progress flag for best results. +// This should be false for commited code. +const _benchmark = false; + +export function time(label: string) { + if (_benchmark) { + console.time(label); + } +} + +export function timeEnd(label: string) { + if (_benchmark) { + console.timeEnd(label); + } +} diff --git a/packages/@ngtools/webpack/src/compiler_host.ts b/packages/@ngtools/webpack/src/compiler_host.ts index cbd90aad3686..853c434f3495 100644 --- a/packages/@ngtools/webpack/src/compiler_host.ts +++ b/packages/@ngtools/webpack/src/compiler_host.ts @@ -116,7 +116,7 @@ export class WebpackCompilerHost implements ts.CompilerHost { return path.replace(/\\/g, '/'); } - private _resolve(path: string) { + resolve(path: string) { path = this._normalizePath(path); if (path[0] == '.') { return this._normalizePath(join(this.getCurrentDirectory(), path)); @@ -158,7 +158,7 @@ export class WebpackCompilerHost implements ts.CompilerHost { } invalidate(fileName: string): void { - fileName = this._resolve(fileName); + fileName = this.resolve(fileName); if (fileName in this._files) { this._files[fileName] = null; this._changedFiles[fileName] = true; @@ -166,12 +166,12 @@ export class WebpackCompilerHost implements ts.CompilerHost { } fileExists(fileName: string, delegate = true): boolean { - fileName = this._resolve(fileName); + fileName = this.resolve(fileName); return this._files[fileName] != null || (delegate && this._delegate.fileExists(fileName)); } readFile(fileName: string): string { - fileName = this._resolve(fileName); + fileName = this.resolve(fileName); const stats = this._files[fileName]; if (stats == null) { @@ -188,12 +188,12 @@ export class WebpackCompilerHost implements ts.CompilerHost { // Does not delegate, use with `fileExists/directoryExists()`. stat(path: string): VirtualStats { - path = this._resolve(path); + path = this.resolve(path); return this._files[path] || this._directories[path]; } directoryExists(directoryName: string, delegate = true): boolean { - directoryName = this._resolve(directoryName); + directoryName = this.resolve(directoryName); return (this._directories[directoryName] != null) || (delegate && this._delegate.directoryExists != undefined @@ -201,14 +201,14 @@ export class WebpackCompilerHost implements ts.CompilerHost { } getFiles(path: string): string[] { - path = this._resolve(path); + path = this.resolve(path); return Object.keys(this._files) .filter(fileName => dirname(fileName) == path) .map(path => basename(path)); } getDirectories(path: string): string[] { - path = this._resolve(path); + path = this.resolve(path); const subdirs = Object.keys(this._directories) .filter(fileName => dirname(fileName) == path) .map(path => basename(path)); @@ -223,13 +223,19 @@ export class WebpackCompilerHost implements ts.CompilerHost { } getSourceFile(fileName: string, languageVersion: ts.ScriptTarget, _onError?: OnErrorFn) { - fileName = this._resolve(fileName); + fileName = this.resolve(fileName); const stats = this._files[fileName]; if (stats == null) { const content = this.readFile(fileName); + if (!this._cache) { return ts.createSourceFile(fileName, content, languageVersion, this._setParentNodes); + } else if (!this._files[fileName]) { + // If cache is turned on and the file exists, the readFile call will have populated stats. + // Empty stats at this point mean the file doesn't exist at and so we should return + // undefined. + return undefined; } } @@ -249,7 +255,8 @@ export class WebpackCompilerHost implements ts.CompilerHost { get writeFile() { return (fileName: string, data: string, _writeByteOrderMark: boolean, _onError?: (message: string) => void, _sourceFiles?: ts.SourceFile[]): void => { - fileName = this._resolve(fileName); + + fileName = this.resolve(fileName); this._setFileContent(fileName, data); }; } @@ -259,7 +266,7 @@ export class WebpackCompilerHost implements ts.CompilerHost { } getCanonicalFileName(fileName: string): string { - fileName = this._resolve(fileName); + fileName = this.resolve(fileName); return this._delegate.getCanonicalFileName(fileName); } diff --git a/packages/@ngtools/webpack/src/gather_diagnostics.ts b/packages/@ngtools/webpack/src/gather_diagnostics.ts new file mode 100644 index 000000000000..e80c70a8cced --- /dev/null +++ b/packages/@ngtools/webpack/src/gather_diagnostics.ts @@ -0,0 +1,88 @@ +import * as ts from 'typescript'; + +import { time, timeEnd } from './benchmark'; +import { Program, Diagnostics } from './ngtools_api2'; + + +export class CancellationToken implements ts.CancellationToken { + private _isCancelled = false; + + requestCancellation() { + this._isCancelled = true; + } + + isCancellationRequested() { + return this._isCancelled; + } + + throwIfCancellationRequested() { + if (this.isCancellationRequested()) { + throw new ts.OperationCanceledException(); + } + } +} + +export function hasErrors(diags: Diagnostics) { + return diags.some(d => d.category === ts.DiagnosticCategory.Error); +} + +export function gatherDiagnostics( + program: ts.Program | Program, + jitMode: boolean, + benchmarkLabel: string, + cancellationToken?: CancellationToken, +): Diagnostics { + const allDiagnostics: Diagnostics = []; + let checkOtherDiagnostics = true; + + function checkDiagnostics(diags: Diagnostics | undefined) { + if (diags) { + allDiagnostics.push(...diags); + return !hasErrors(diags); + } + return true; + } + + if (jitMode) { + const tsProgram = program as ts.Program; + // Check syntactic diagnostics. + time(`${benchmarkLabel}.gatherDiagnostics.ts.getSyntacticDiagnostics`); + checkOtherDiagnostics = checkOtherDiagnostics && + checkDiagnostics(tsProgram.getSyntacticDiagnostics(undefined, cancellationToken)); + timeEnd(`${benchmarkLabel}.gatherDiagnostics.ts.getSyntacticDiagnostics`); + + // Check semantic diagnostics. + time(`${benchmarkLabel}.gatherDiagnostics.ts.getSemanticDiagnostics`); + checkOtherDiagnostics = checkOtherDiagnostics && + checkDiagnostics(tsProgram.getSemanticDiagnostics(undefined, cancellationToken)); + timeEnd(`${benchmarkLabel}.gatherDiagnostics.ts.getSemanticDiagnostics`); + } else { + const angularProgram = program as Program; + + // Check TypeScript syntactic diagnostics. + time(`${benchmarkLabel}.gatherDiagnostics.ng.getTsSyntacticDiagnostics`); + checkOtherDiagnostics = checkOtherDiagnostics && + checkDiagnostics(angularProgram.getTsSyntacticDiagnostics(undefined, cancellationToken)); + timeEnd(`${benchmarkLabel}.gatherDiagnostics.ng.getTsSyntacticDiagnostics`); + + // Check TypeScript semantic and Angular structure diagnostics. + time(`${benchmarkLabel}.gatherDiagnostics.ng.getTsSemanticDiagnostics`); + checkOtherDiagnostics = checkOtherDiagnostics && + checkDiagnostics(angularProgram.getTsSemanticDiagnostics(undefined, cancellationToken)); + timeEnd(`${benchmarkLabel}.gatherDiagnostics.ng.getTsSemanticDiagnostics`); + + // Check Angular structural diagnostics. + time(`${benchmarkLabel}.gatherDiagnostics.ng.getNgStructuralDiagnostics`); + checkOtherDiagnostics = checkOtherDiagnostics && + checkDiagnostics(angularProgram.getNgStructuralDiagnostics(cancellationToken)); + timeEnd(`${benchmarkLabel}.gatherDiagnostics.ng.getNgStructuralDiagnostics`); + + // Check Angular semantic diagnostics + time(`${benchmarkLabel}.gatherDiagnostics.ng.getNgSemanticDiagnostics`); + checkOtherDiagnostics = checkOtherDiagnostics && + checkDiagnostics(angularProgram.getNgSemanticDiagnostics(undefined, cancellationToken)); + timeEnd(`${benchmarkLabel}.gatherDiagnostics.ng.getNgSemanticDiagnostics`); + } + + return allDiagnostics; +} diff --git a/packages/@ngtools/webpack/src/loader.ts b/packages/@ngtools/webpack/src/loader.ts index 883753b899c0..e02a4609fea9 100644 --- a/packages/@ngtools/webpack/src/loader.ts +++ b/packages/@ngtools/webpack/src/loader.ts @@ -4,6 +4,7 @@ import {AotPlugin} from './plugin'; import {AngularCompilerPlugin} from './angular_compiler_plugin'; import {TypeScriptFileRefactor} from './refactor'; import {LoaderContext, ModuleReason} from './webpack'; +import {time, timeEnd} from './benchmark'; interface Platform { name: string; @@ -523,6 +524,8 @@ export function _exportModuleMap(plugin: AotPlugin, refactor: TypeScriptFileRefa export function ngcLoader(this: LoaderContext & { _compilation: any }, source: string | null) { const cb = this.async(); const sourceFileName: string = this.resourcePath; + const timeLabel = `ngcLoader+${sourceFileName}+`; + time(timeLabel); const plugin = this._compilation._ngToolsWebpackPluginInstance; if (plugin) { @@ -536,19 +539,27 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s } if (plugin instanceof AngularCompilerPlugin) { + time(timeLabel + '.ngcLoader.AngularCompilerPlugin'); plugin.done .then(() => { + timeEnd(timeLabel + '.ngcLoader.AngularCompilerPlugin'); const result = plugin.getFile(sourceFileName); if (plugin.failedCompilation) { // Return an empty string if there is no result to prevent extra loader errors. // Plugin errors were already pushed to the compilation errors. + timeEnd(timeLabel); cb(null, result.outputText || '', result.sourceMap); } else { - cb(null, result.outputText, result.sourceMap); - } - }) - .catch(err => cb(err)); + timeEnd(timeLabel); + cb(null, result.outputText, result.sourceMap); + } + }) + .catch(err => { + timeEnd(timeLabel + '.ngcLoader.AngularCompilerPlugin'); + cb(err); + }); } else if (plugin instanceof AotPlugin) { + time(timeLabel + '.ngcLoader.AotPlugin'); if (plugin.compilerHost.readFile(sourceFileName) == source) { // In the case where the source is the same as the one in compilerHost, we don't have // extra TS loaders and there's no need to do any trickery. @@ -557,23 +568,28 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s const refactor = new TypeScriptFileRefactor( sourceFileName, plugin.compilerHost, plugin.program, source); + Promise.resolve() .then(() => { + time(timeLabel + '.ngcLoader.AotPlugin.refactor'); + let promise: Promise; if (!plugin.skipCodeGeneration) { - return Promise.resolve() + promise = Promise.resolve() .then(() => _removeDecorators(refactor)) .then(() => _refactorBootstrap(plugin, refactor)) .then(() => _replaceExport(plugin, refactor)) .then(() => _exportModuleMap(plugin, refactor)); } else { - return Promise.resolve() + promise = Promise.resolve() .then(() => _replaceResources(refactor)) .then(() => _removeModuleId(refactor)) .then(() => _exportModuleMap(plugin, refactor)); } + return promise.then(() => timeEnd(timeLabel + '.ngcLoader.AotPlugin.refactor')); }) .then(() => { if (plugin.typeCheck) { + time(timeLabel + '.ngcLoader.AotPlugin.typeCheck'); // Check all diagnostics from this and reverse dependencies also. if (!plugin.firstRun) { _diagnoseDeps(this._module.reasons, plugin, new Set()); @@ -581,16 +597,20 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s // We do this here because it will throw on error, resulting in rebuilding this file // the next time around if it changes. plugin.diagnose(sourceFileName); + timeEnd(timeLabel + '.ngcLoader.AotPlugin.typeCheck'); } }) .then(() => { + time(timeLabel + '.ngcLoader.AotPlugin.addDependency'); // Add resources as dependencies. _getResourcesUrls(refactor).forEach((url: string) => { this.addDependency(path.resolve(path.dirname(sourceFileName), url)); }); + timeEnd(timeLabel + '.ngcLoader.AotPlugin.addDependency'); }) .then(() => { if (source) { + time(timeLabel + '.ngcLoader.AotPlugin.getDiagnostics'); // We need to validate diagnostics. We ignore type checking though, to save time. const diagnostics = refactor.getDiagnostics(false); if (diagnostics.length) { @@ -610,6 +630,7 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s }); throw new Error(message); } + timeEnd(timeLabel + '.ngcLoader.AotPlugin.getDiagnostics'); } // Force a few compiler options to make sure we get the result we want. @@ -619,13 +640,18 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s sourceRoot: plugin.basePath }); + time(timeLabel + '.ngcLoader.AotPlugin.transpile'); const result = refactor.transpile(compilerOptions); + timeEnd(timeLabel + '.ngcLoader.AotPlugin.transpile'); + timeEnd(timeLabel + '.ngcLoader.AotPlugin'); if (plugin.failedCompilation && plugin.compilerOptions.noEmitOnError) { // Return an empty string to prevent extra loader errors (missing imports etc). // Plugin errors were already pushed to the compilation errors. + timeEnd(timeLabel); cb(null, ''); } else { + timeEnd(timeLabel); cb(null, result.outputText, result.sourceMap); } }) @@ -661,6 +687,7 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s const result = refactor.transpile(compilerOptions); // Webpack is going to take care of this. result.outputText = result.outputText.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + timeEnd(timeLabel); cb(null, result.outputText, result.sourceMap); } } diff --git a/packages/@ngtools/webpack/src/ngtools_api2.ts b/packages/@ngtools/webpack/src/ngtools_api2.ts new file mode 100644 index 000000000000..e4dfb6f9e249 --- /dev/null +++ b/packages/@ngtools/webpack/src/ngtools_api2.ts @@ -0,0 +1,119 @@ +/** + * This is a copy of types in @compiler-cli/src/ngtools_api.d.ts file, + * together with a safe import to support cases where Angular versions is below 5. + */ +import * as ts from 'typescript'; + +export const DEFAULT_ERROR_CODE = 100; +export const UNKNOWN_ERROR_CODE = 500; +export const SOURCE = 'angular' as 'angular'; +export interface Diagnostic { + messageText: string; + span?: any; + category: ts.DiagnosticCategory; + code: number; + source: 'angular'; +} +export interface CompilerOptions extends ts.CompilerOptions { + basePath?: string; + skipMetadataEmit?: boolean; + strictMetadataEmit?: boolean; + skipTemplateCodegen?: boolean; + flatModuleOutFile?: string; + flatModuleId?: string; + generateCodeForLibraries?: boolean; + annotateForClosureCompiler?: boolean; + annotationsAs?: 'decorators' | 'static fields'; + trace?: boolean; + enableLegacyTemplate?: boolean; + disableExpressionLowering?: boolean; + i18nOutLocale?: string; + i18nOutFormat?: string; + i18nOutFile?: string; + i18nInFormat?: string; + i18nInLocale?: string; + i18nInFile?: string; + i18nInMissingTranslations?: 'error' | 'warning' | 'ignore'; + preserveWhitespaces?: boolean; +} +export interface CompilerHost extends ts.CompilerHost { + moduleNameToFileName(moduleName: string, containingFile?: string): string | null; + fileNameToModuleName(importedFilePath: string, containingFilePath: string): string | null; + resourceNameToFileName(resourceName: string, containingFilePath: string): string | null; + toSummaryFileName(fileName: string, referringSrcFileName: string): string; + fromSummaryFileName(fileName: string, referringLibFileName: string): string; + readResource?(fileName: string): Promise | string; +} +export interface CustomTransformers { + beforeTs?: ts.TransformerFactory[]; + afterTs?: ts.TransformerFactory[]; +} +export interface TsEmitArguments { + program: ts.Program; + host: CompilerHost; + options: CompilerOptions; + targetSourceFile?: ts.SourceFile; + writeFile?: ts.WriteFileCallback; + cancellationToken?: ts.CancellationToken; + emitOnlyDtsFiles?: boolean; + customTransformers?: ts.CustomTransformers; +} +export interface TsEmitCallback { + (args: TsEmitArguments): ts.EmitResult; +} +export interface Program { + getTsProgram(): ts.Program; + getTsOptionDiagnostics(cancellationToken?: ts.CancellationToken): ts.Diagnostic[]; + getNgOptionDiagnostics(cancellationToken?: ts.CancellationToken): Diagnostic[]; + getTsSyntacticDiagnostics(sourceFile?: ts.SourceFile, cancellationToken?: ts.CancellationToken): + ts.Diagnostic[]; + getNgStructuralDiagnostics(cancellationToken?: ts.CancellationToken): Diagnostic[]; + getTsSemanticDiagnostics(sourceFile?: ts.SourceFile, cancellationToken?: ts.CancellationToken): + ts.Diagnostic[]; + getNgSemanticDiagnostics(fileName?: string, cancellationToken?: ts.CancellationToken): + Diagnostic[]; + loadNgStructureAsync(): Promise; + emit({ emitFlags, cancellationToken, customTransformers, emitCallback }: { + emitFlags?: any; + cancellationToken?: ts.CancellationToken; + customTransformers?: CustomTransformers; + emitCallback?: TsEmitCallback; + }): ts.EmitResult; +} + +export declare type Diagnostics = Array; + +// Interfaces for the function declarations. +export interface CreateProgramInterface { + ({ rootNames, options, host, oldProgram }: { + rootNames: string[]; + options: CompilerOptions; + host: CompilerHost; + oldProgram?: Program; + }): Program; +} +export interface CreateCompilerHostInterface { + ({ options, tsHost }: { + options: CompilerOptions; + tsHost?: ts.CompilerHost; + }): CompilerHost; +} +export interface FormatDiagnosticsInterface { + (options: CompilerOptions, diags: Diagnostics): string; +} + +// These imports do not exist on Angular versions lower than 5, so we cannot use a static ES6 +// import. +let ngtools2: any = {}; +try { + ngtools2 = require('@angular/compiler-cli/ngtools2'); +} catch (e) { + // Don't throw an error if the private API does not exist. + // Instead, the `AngularCompilerPlugin.isSupported` method should return false and indicate the + // plugin cannot be used. +} + +export const createProgram: CreateProgramInterface = ngtools2.createProgram; +export const createCompilerHost: CreateCompilerHostInterface = ngtools2.createCompilerHost; +export const formatDiagnostics: FormatDiagnosticsInterface = ngtools2.formatDiagnostics; +export const EmitFlags = ngtools2.EmitFlags; diff --git a/packages/@ngtools/webpack/src/plugin.ts b/packages/@ngtools/webpack/src/plugin.ts index 2d826ae91776..4cae8b630a2d 100644 --- a/packages/@ngtools/webpack/src/plugin.ts +++ b/packages/@ngtools/webpack/src/plugin.ts @@ -15,6 +15,7 @@ import {Tapable} from './webpack'; import {PathsPlugin} from './paths-plugin'; import {findLazyRoutes, LazyRouteMap} from './lazy_routes'; import {VirtualFileSystemDecorator} from './virtual_file_system_decorator'; +import {time, timeEnd} from './benchmark'; /** @@ -110,6 +111,7 @@ export class AotPlugin implements Tapable { get discoveredLazyRoutes() { return this._discoveredLazyRoutes; } private _setupOptions(options: AotPluginOptions) { + time('AotPlugin._setupOptions'); // Fill in the missing options. if (!options.hasOwnProperty('tsConfigPath')) { throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); @@ -289,9 +291,11 @@ export class AotPlugin implements Tapable { } this._missingTranslation = options.missingTranslation; } + timeEnd('AotPlugin._setupOptions'); } private _findLazyRoutesInAst(): LazyRouteMap { + time('AotPlugin._findLazyRoutesInAst'); const result: LazyRouteMap = Object.create(null); const changedFilePaths = this._compilerHost.getChangedFilePaths(); for (const filePath of changedFilePaths) { @@ -313,17 +317,21 @@ export class AotPlugin implements Tapable { } } } + timeEnd('AotPlugin._findLazyRoutesInAst'); return result; } private _getLazyRoutesFromNgtools() { try { - return __NGTOOLS_PRIVATE_API_2.listLazyRoutes({ + time('AotPlugin._getLazyRoutesFromNgtools'); + const result = __NGTOOLS_PRIVATE_API_2.listLazyRoutes({ program: this._program, host: this._compilerHost, angularCompilerOptions: this._angularCompilerOptions, entryModule: this._entryModule }); + timeEnd('AotPlugin._getLazyRoutesFromNgtools'); + return result; } catch (err) { // We silence the error that the @angular/router could not be found. In that case, there is // basically no route supported by the app itself. @@ -517,6 +525,7 @@ export class AotPlugin implements Tapable { } private _make(compilation: any, cb: (err?: any, request?: any) => void) { + time('AotPlugin._make'); this._compilation = compilation; if (this._compilation._ngToolsWebpackPluginInstance) { return cb(new Error('An @ngtools/webpack plugin already exist for this compilation.')); @@ -532,6 +541,7 @@ export class AotPlugin implements Tapable { return; } + time('AotPlugin._make.codeGen'); // Create the Code Generator. return __NGTOOLS_PRIVATE_API_2.codeGen({ basePath: this._basePath, @@ -545,7 +555,8 @@ export class AotPlugin implements Tapable { missingTranslation: this.missingTranslation, readResource: (path: string) => this._resourceLoader.get(path) - }); + }) + .then(() => timeEnd('AotPlugin._make.codeGen')); }) .then(() => { // Get the ngfactory that were created by the previous step, and add them to the root @@ -561,16 +572,21 @@ export class AotPlugin implements Tapable { // transitive modules, which include files that might just have been generated. // This needs to happen after the code generator has been created for generated files // to be properly resolved. + time('AotPlugin._make.createProgram'); this._program = ts.createProgram( this._rootFilePath, this._compilerOptions, this._compilerHost, this._program); + timeEnd('AotPlugin._make.createProgram'); }) .then(() => { // Re-diagnose changed files. + time('AotPlugin._make.diagnose'); const changedFilePaths = this._compilerHost.getChangedFilePaths(); changedFilePaths.forEach(filePath => this.diagnose(filePath)); + timeEnd('AotPlugin._make.diagnose'); }) .then(() => { if (this._typeCheck) { + time('AotPlugin._make._typeCheck'); const diagnostics = this._program.getGlobalDiagnostics(); if (diagnostics.length > 0) { const message = diagnostics @@ -589,11 +605,13 @@ export class AotPlugin implements Tapable { throw new Error(message); } + timeEnd('AotPlugin._make._typeCheck'); } }) .then(() => { // We need to run the `listLazyRoutes` the first time because it also navigates libraries // and other things that we might miss using the findLazyRoutesInAst. + time('AotPlugin._make._discoveredLazyRoutes'); this._discoveredLazyRoutes = this.firstRun ? this._getLazyRoutesFromNgtools() : this._findLazyRoutesInAst(); @@ -615,6 +633,7 @@ export class AotPlugin implements Tapable { this._lazyRoutes[k + '.ngfactory'] = path.join(this.genDir, lr); } }); + timeEnd('AotPlugin._make._discoveredLazyRoutes'); }) .then(() => { if (this._compilation.errors == 0) { @@ -623,10 +642,12 @@ export class AotPlugin implements Tapable { this._failedCompilation = true; } + timeEnd('AotPlugin._make'); cb(); }, (err: any) => { this._failedCompilation = true; compilation.errors.push(err.stack); + timeEnd('AotPlugin._make'); cb(); }); } diff --git a/packages/@ngtools/webpack/src/transformers/ast_helpers.ts b/packages/@ngtools/webpack/src/transformers/ast_helpers.ts index ca56633aa1f3..87a33d025604 100644 --- a/packages/@ngtools/webpack/src/transformers/ast_helpers.ts +++ b/packages/@ngtools/webpack/src/transformers/ast_helpers.ts @@ -1,4 +1,6 @@ import * as ts from 'typescript'; +import { WebpackCompilerHost } from '../compiler_host'; +import { makeTransform, TransformOperation } from './make_transform'; /** @@ -70,3 +72,50 @@ export function getLastNode(sourceFile: ts.SourceFile): ts.Node | null { } return null; } + + +export function transformTypescript( + content: string, + transformOpsCb: (SourceFile: ts.SourceFile) => TransformOperation[] +) { + + // Set compiler options. + const compilerOptions: ts.CompilerOptions = { + noEmitOnError: false, + allowJs: true, + newLine: ts.NewLineKind.LineFeed, + target: ts.ScriptTarget.ESNext, + skipLibCheck: true, + sourceMap: false, + importHelpers: true + }; + + // Create compiler host. + const basePath = '/project/src/'; + const compilerHost = new WebpackCompilerHost(compilerOptions, basePath); + + // Add a dummy file to host content. + const fileName = basePath + 'test-file.ts'; + compilerHost.writeFile(fileName, content, false); + + // Create the TypeScript program. + const program = ts.createProgram([fileName], compilerOptions, compilerHost); + + // Get the transform operations. + const sourceFile = program.getSourceFile(fileName); + const transformOps = transformOpsCb(sourceFile); + + // Emit. + const { emitSkipped, diagnostics } = program.emit( + undefined, undefined, undefined, undefined, { before: [makeTransform(transformOps)] } + ); + + // Log diagnostics if emit wasn't successfull. + if (emitSkipped) { + console.log(diagnostics); + return null; + } + + // Return the transpiled js. + return compilerHost.readFile(fileName.replace(/\.ts$/, '.js')); +} diff --git a/packages/@ngtools/webpack/src/transformers/export_lazy_module_map.spec.ts b/packages/@ngtools/webpack/src/transformers/export_lazy_module_map.spec.ts new file mode 100644 index 000000000000..d751c44b3350 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/export_lazy_module_map.spec.ts @@ -0,0 +1,30 @@ +import * as ts from 'typescript'; +import { oneLine, stripIndent } from 'common-tags'; +import { transformTypescript } from './ast_helpers'; +import { exportLazyModuleMap } from './export_lazy_module_map'; + +describe('@ngtools/webpack transformers', () => { + describe('replace_resources', () => { + it('should replace resources', () => { + const input = stripIndent` + export { AppModule } from './app/app.module'; + `; + // tslint:disable:max-line-length + const output = stripIndent` + import * as __lazy_0__ from "app/lazy/lazy.module.ts"; + import * as __lazy_1__ from "app/lazy2/lazy2.module.ts"; + export { AppModule } from './app/app.module'; + export var LAZY_MODULE_MAP = { "./lazy/lazy.module#LazyModule": __lazy_0__.LazyModule, "./lazy2/lazy2.module#LazyModule2": __lazy_1__.LazyModule2 }; + `; + // tslint:enable:max-line-length + + const transformOpsCb = (sourceFile: ts.SourceFile) => exportLazyModuleMap(sourceFile, { + './lazy/lazy.module#LazyModule': '/project/src/app/lazy/lazy.module.ts', + './lazy2/lazy2.module#LazyModule2': '/project/src/app/lazy2/lazy2.module.ts', + }); + const result = transformTypescript(input, transformOpsCb); + + expect(oneLine`${result}`).toEqual(oneLine`${output}`); + }); + }); +}); diff --git a/packages/@ngtools/webpack/src/transformers/export_ngfactory.spec.ts b/packages/@ngtools/webpack/src/transformers/export_ngfactory.spec.ts new file mode 100644 index 000000000000..51f7715ba099 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/export_ngfactory.spec.ts @@ -0,0 +1,24 @@ +import * as ts from 'typescript'; +import { oneLine, stripIndent } from 'common-tags'; +import { transformTypescript } from './ast_helpers'; +import { exportNgFactory } from './export_ngfactory'; + +describe('@ngtools/webpack transformers', () => { + describe('replace_resources', () => { + it('should replace resources', () => { + const input = stripIndent` + export { AppModule } from './app/app.module'; + `; + const output = stripIndent` + export { AppModuleNgFactory } from "./app/app.module.ngfactory"; + export { AppModule } from './app/app.module'; + `; + + const transformOpsCb = (sourceFile: ts.SourceFile) => + exportNgFactory(sourceFile, { path: '/app.module', className: 'AppModule' }); + const result = transformTypescript(input, transformOpsCb); + + expect(oneLine`${result}`).toEqual(oneLine`${output}`); + }); + }); +}); diff --git a/packages/@ngtools/webpack/src/transformers/index.ts b/packages/@ngtools/webpack/src/transformers/index.ts index e458e75a1803..de09607bb67e 100644 --- a/packages/@ngtools/webpack/src/transformers/index.ts +++ b/packages/@ngtools/webpack/src/transformers/index.ts @@ -6,3 +6,4 @@ export * from './replace_bootstrap'; export * from './export_ngfactory'; export * from './export_lazy_module_map'; export * from './register_locale_data'; +export * from './replace_resources'; diff --git a/packages/@ngtools/webpack/src/transformers/insert_import.ts b/packages/@ngtools/webpack/src/transformers/insert_import.ts index fa5ae08b0c26..a8bb4966138e 100644 --- a/packages/@ngtools/webpack/src/transformers/insert_import.ts +++ b/packages/@ngtools/webpack/src/transformers/insert_import.ts @@ -40,7 +40,7 @@ export function insertImport( }); }); if (hasImportAlready) { - return; + return ops; } // Just pick the first one and insert at the end of its identifier list. diff --git a/packages/@ngtools/webpack/src/transformers/make_transform.ts b/packages/@ngtools/webpack/src/transformers/make_transform.ts index eef96fbf9d49..8886d800315d 100644 --- a/packages/@ngtools/webpack/src/transformers/make_transform.ts +++ b/packages/@ngtools/webpack/src/transformers/make_transform.ts @@ -1,6 +1,12 @@ import * as ts from 'typescript'; +import { satisfies } from 'semver'; +// Typescript below 2.5.0 needs a workaround. +const visitEachChild = satisfies(ts.version, '^2.5.0') + ? ts.visitEachChild + : visitEachChildWorkaround; + export enum OPERATION_KIND { Remove, Add, @@ -35,9 +41,6 @@ export class ReplaceNodeOperation extends TransformOperation { } } -// TODO: add symbol workaround for https://github.com/Microsoft/TypeScript/issues/17551 and -// https://github.com/Microsoft/TypeScript/issues/17384 - export function makeTransform(ops: TransformOperation[]): ts.TransformerFactory { const sourceFiles = ops.reduce((prev, curr) => @@ -83,7 +86,7 @@ export function makeTransform(ops: TransformOperation[]): ts.TransformerFactory< return modifiedNodes; } else { // Otherwise return node as is and visit children. - return ts.visitEachChild(node, visitor, context); + return visitEachChild(node, visitor, context); } }; @@ -94,3 +97,34 @@ export function makeTransform(ops: TransformOperation[]): ts.TransformerFactory< return transformer; }; } + +/** + * This is a version of `ts.visitEachChild` that works that calls our version + * of `updateSourceFileNode`, so that typescript doesn't lose type information + * for property decorators. + * See https://github.com/Microsoft/TypeScript/issues/17384 and + * https://github.com/Microsoft/TypeScript/issues/17551, fixed by + * https://github.com/Microsoft/TypeScript/pull/18051 and released on TS 2.5.0. + * + * @param sf + * @param statements + */ +function visitEachChildWorkaround(node: ts.Node, visitor: ts.Visitor, + context: ts.TransformationContext) { + + if (node.kind === ts.SyntaxKind.SourceFile) { + const sf = node as ts.SourceFile; + const statements = ts.visitLexicalEnvironment(sf.statements, visitor, context); + + if (statements === sf.statements) { + return sf; + } + // Note: Need to clone the original file (and not use `ts.updateSourceFileNode`) + // as otherwise TS fails when resolving types for decorators. + const sfClone = ts.getMutableClone(sf); + sfClone.statements = statements; + return sfClone; + } + + return ts.visitEachChild(node, visitor, context); +} diff --git a/packages/@ngtools/webpack/src/transformers/register_locale_data.spec.ts b/packages/@ngtools/webpack/src/transformers/register_locale_data.spec.ts new file mode 100644 index 000000000000..203f11aa1fc0 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/register_locale_data.spec.ts @@ -0,0 +1,47 @@ +import * as ts from 'typescript'; +import { oneLine, stripIndent } from 'common-tags'; +import { transformTypescript } from './ast_helpers'; +import { registerLocaleData } from './register_locale_data'; + +describe('@ngtools/webpack transformers', () => { + describe('register_locale_data', () => { + it('should add locale imports', () => { + const input = stripIndent` + import { enableProdMode } from '@angular/core'; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + + if (environment.production) { + enableProdMode(); + } + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = stripIndent` + import __locale_fr__ from "@angular/common/locales/fr"; + import { registerLocaleData } from "@angular/common"; + registerLocaleData(__locale_fr__); + + import { enableProdMode } from '@angular/core'; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + + if (environment.production) { + enableProdMode(); + } + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + + const transformOpsCb = (sourceFile: ts.SourceFile) => + registerLocaleData(sourceFile, { path: '/app.module', className: 'AppModule' }, 'fr'); + const result = transformTypescript(input, transformOpsCb); + + expect(oneLine`${result}`).toEqual(oneLine`${output}`); + }); + }); +}); diff --git a/packages/@ngtools/webpack/src/transformers/register_locale_data.ts b/packages/@ngtools/webpack/src/transformers/register_locale_data.ts index e352c90569a7..bd2f8fd45c7c 100644 --- a/packages/@ngtools/webpack/src/transformers/register_locale_data.ts +++ b/packages/@ngtools/webpack/src/transformers/register_locale_data.ts @@ -1,5 +1,3 @@ -import * as path from 'path'; -import * as fs from 'fs'; import * as ts from 'typescript'; import { findAstNodes, getFirstNode } from './ast_helpers'; @@ -45,39 +43,6 @@ export function registerLocaleData( return; } - // get the path of the common module - const commonPath = path.dirname(require.resolve('@angular/common/package.json')); - // check if the locale file exists - if (!fs.existsSync(path.resolve(commonPath, 'locales', `${locale}.js`))) { - // check for an alternative locale (if the locale id was badly formatted) - const locales = fs.readdirSync(path.resolve(commonPath, 'locales')) - .filter(file => file.endsWith('.js')) - .map(file => file.replace('.js', '')); - - let newLocale; - const normalizedLocale = locale.toLowerCase().replace(/_/g, '-'); - for (const l of locales) { - if (l.toLowerCase() === normalizedLocale) { - newLocale = l; - break; - } - } - - if (newLocale) { - locale = newLocale; - } else { - // check for a parent locale - const parentLocale = normalizedLocale.split('-')[0]; - if (locales.indexOf(parentLocale) !== -1) { - locale = parentLocale; - } else { - throw new Error( - `Unable to load the locale data file "@angular/common/locales/${locale}", ` + - `please check that "${locale}" is a valid locale id.`); - } - } - } - // Create the import node for the locale. const localeIdentifier = ts.createIdentifier(`__locale_${locale.replace(/-/g, '')}__`); const localeImportClause = ts.createImportClause(localeIdentifier, undefined); @@ -92,7 +57,7 @@ export function registerLocaleData( // Create the import node for the registerLocaleData function. const regIdentifier = ts.createIdentifier(`registerLocaleData`); - const regImportSpecifier = ts.createImportSpecifier(regIdentifier, regIdentifier); + const regImportSpecifier = ts.createImportSpecifier(undefined, regIdentifier); const regNamedImport = ts.createNamedImports([regImportSpecifier]); const regImportClause = ts.createImportClause(undefined, regNamedImport); const regNewImport = ts.createImportDeclaration(undefined, undefined, regImportClause, diff --git a/packages/@ngtools/webpack/src/transformers/replace_bootrap.spec.ts b/packages/@ngtools/webpack/src/transformers/replace_bootrap.spec.ts new file mode 100644 index 000000000000..65c317567e10 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/replace_bootrap.spec.ts @@ -0,0 +1,43 @@ +import * as ts from 'typescript'; +import { oneLine, stripIndent } from 'common-tags'; +import { transformTypescript } from './ast_helpers'; +import { replaceBootstrap } from './replace_bootstrap'; + +describe('@ngtools/webpack transformers', () => { + describe('replace_bootstrap', () => { + it('should replace bootstrap', () => { + const input = stripIndent` + import { enableProdMode } from '@angular/core'; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + + if (environment.production) { + enableProdMode(); + } + + platformBrowserDynamic().bootstrapModule(AppModule); + `; + const output = stripIndent` + import { enableProdMode } from '@angular/core'; + import { environment } from './environments/environment'; + + import { AppModuleNgFactory } from "./app/app.module.ngfactory"; + import { platformBrowser } from "@angular/platform-browser"; + + if (environment.production) { + enableProdMode(); + } + + platformBrowser().bootstrapModuleFactory(AppModuleNgFactory); + `; + + const transformOpsCb = (sourceFile: ts.SourceFile) => + replaceBootstrap(sourceFile, { path: '/app.module', className: 'AppModule' }); + const result = transformTypescript(input, transformOpsCb); + + expect(oneLine`${result}`).toEqual(oneLine`${output}`); + }); + }); +}); diff --git a/packages/@ngtools/webpack/src/transformers/replace_resources.spec.ts b/packages/@ngtools/webpack/src/transformers/replace_resources.spec.ts new file mode 100644 index 000000000000..5b6b4dbd5b2c --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/replace_resources.spec.ts @@ -0,0 +1,45 @@ +import * as ts from 'typescript'; +import { oneLine, stripIndent } from 'common-tags'; +import { transformTypescript } from './ast_helpers'; +import { replaceResources } from './replace_resources'; + +describe('@ngtools/webpack transformers', () => { + describe('replace_resources', () => { + it('should replace resources', () => { + const input = stripIndent` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css', './app.component.2.css'] + }) + export class AppComponent { + title = 'app'; + } + `; + const output = stripIndent` + import * as tslib_1 from "tslib"; + import { Component } from '@angular/core'; + let AppComponent = class AppComponent { + constructor() { + this.title = 'app'; + } + }; + AppComponent = tslib_1.__decorate([ + Component({ + selector: 'app-root', + template: require("./app.component.html"), + styles: [require("./app.component.css"), require("./app.component.2.css")] + }) + ], AppComponent); + export { AppComponent }; + `; + + const transformOpsCb = (sourceFile: ts.SourceFile) => replaceResources(sourceFile); + const result = transformTypescript(input, transformOpsCb); + + expect(oneLine`${result}`).toEqual(oneLine`${output}`); + }); + }); +}); diff --git a/packages/@ngtools/webpack/src/transformers/replace_resources.ts b/packages/@ngtools/webpack/src/transformers/replace_resources.ts new file mode 100644 index 000000000000..186eab841e98 --- /dev/null +++ b/packages/@ngtools/webpack/src/transformers/replace_resources.ts @@ -0,0 +1,114 @@ +import * as ts from 'typescript'; + +import { findAstNodes, getFirstNode } from './ast_helpers'; +import { + AddNodeOperation, + ReplaceNodeOperation, + TransformOperation +} from './make_transform'; + + +export function replaceResources(sourceFile: ts.SourceFile): TransformOperation[] { + const ops: TransformOperation[] = []; + + // Find all object literals. + findAstNodes(null, sourceFile, + ts.SyntaxKind.ObjectLiteralExpression, true) + // Get all their property assignments. + .map(node => findAstNodes(node, sourceFile, + ts.SyntaxKind.PropertyAssignment)) + // Flatten into a single array (from an array of array). + .reduce((prev, curr) => curr ? prev.concat(curr) : prev, []) + // We only want property assignments for the templateUrl/styleUrls keys. + .filter((node: ts.PropertyAssignment) => { + const key = _getContentOfKeyLiteral(node.name); + if (!key) { + // key is an expression, can't do anything. + return false; + } + return key == 'templateUrl' || key == 'styleUrls'; + }) + // Replace templateUrl/styleUrls key with template/styles, and and paths with require('path'). + .forEach((node: ts.PropertyAssignment) => { + const key = _getContentOfKeyLiteral(node.name); + + if (key == 'templateUrl') { + const requireCall = _createRequireCall(_getResourceRequest(node.initializer, sourceFile)); + const propAssign = ts.createPropertyAssignment('template', requireCall); + ops.push(new ReplaceNodeOperation(sourceFile, node, propAssign)); + } else if (key == 'styleUrls') { + const arr = findAstNodes(node, sourceFile, + ts.SyntaxKind.ArrayLiteralExpression, false); + if (!arr || arr.length == 0 || arr[0].elements.length == 0) { + return; + } + + const stylePaths = arr[0].elements.map((element: ts.Expression) => { + return _getResourceRequest(element, sourceFile); + }); + + const requireArray = ts.createArrayLiteral( + stylePaths.map((path) => _createRequireCall(path)) + ); + + const propAssign = ts.createPropertyAssignment('styles', requireArray); + ops.push(new ReplaceNodeOperation(sourceFile, node, propAssign)); + } + }); + + if (ops.length > 0) { + // If we added a require call, we need to also add typings for it. + // The typings need to be compatible with node typings, but also work by themselves. + + // interface NodeRequire {(id: string): any;} + const nodeRequireInterface = ts.createInterfaceDeclaration([], [], 'NodeRequire', [], [], [ + ts.createCallSignature([], [ + ts.createParameter([], [], undefined, 'id', undefined, + ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) + ) + ], ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)) + ]); + + // declare var require: NodeRequire; + const varRequire = ts.createVariableStatement( + [ts.createToken(ts.SyntaxKind.DeclareKeyword)], + [ts.createVariableDeclaration('require', ts.createTypeReferenceNode('NodeRequire', []))] + ); + + ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), nodeRequireInterface)); + ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), varRequire)); + } + + return ops; +} + +function _getContentOfKeyLiteral(node?: ts.Node): string | null { + if (!node) { + return null; + } else if (node.kind == ts.SyntaxKind.Identifier) { + return (node as ts.Identifier).text; + } else if (node.kind == ts.SyntaxKind.StringLiteral) { + return (node as ts.StringLiteral).text; + } else { + return null; + } +} + +function _getResourceRequest(element: ts.Expression, sourceFile: ts.SourceFile) { + if (element.kind == ts.SyntaxKind.StringLiteral) { + const url = (element as ts.StringLiteral).text; + // If the URL does not start with ./ or ../, prepends ./ to it. + return `${/^\.?\.\//.test(url) ? '' : './'}${url}`; + } else { + // if not string, just use expression directly + return element.getFullText(sourceFile); + } +} + +function _createRequireCall(path: string) { + return ts.createCall( + ts.createIdentifier('require'), + [], + [ts.createLiteral(path)] + ); +} diff --git a/packages/@ngtools/webpack/src/type_checker.ts b/packages/@ngtools/webpack/src/type_checker.ts new file mode 100644 index 000000000000..6244a8a708b8 --- /dev/null +++ b/packages/@ngtools/webpack/src/type_checker.ts @@ -0,0 +1,166 @@ +// @ignoreDep @angular/compiler-cli +import * as process from 'process'; +import * as ts from 'typescript'; +import * as chalk from 'chalk'; + +import { WebpackCompilerHost } from './compiler_host'; +import { time, timeEnd } from './benchmark'; +import { CancellationToken, gatherDiagnostics } from './gather_diagnostics'; +import { + Program, + CompilerOptions, + CompilerHost, + createProgram, + createCompilerHost, + formatDiagnostics, +} from './ngtools_api2'; + +// Force basic color support on terminals with no color support. +// Chalk typings don't have the correct constructor parameters. +const chalkCtx = new (chalk.constructor as any)(chalk.supportsColor ? {} : { level: 1 }); +const { bold, red, yellow } = chalkCtx; + +export enum MESSAGE_KIND { + Init, + Update +} + +export abstract class TypeCheckerMessage { + constructor(public kind: MESSAGE_KIND) { } +} + +export class InitMessage extends TypeCheckerMessage { + constructor( + public compilerOptions: ts.CompilerOptions, + public basePath: string, + public jitMode: boolean, + public tsFilenames: string[], + ) { + super(MESSAGE_KIND.Init); + } +} + +export class UpdateMessage extends TypeCheckerMessage { + constructor(public changedTsFiles: string[]) { + super(MESSAGE_KIND.Update); + } +} + +let typeChecker: TypeChecker; +let lastCancellationToken: CancellationToken; + +process.on('message', (message: TypeCheckerMessage) => { + time('TypeChecker.message'); + switch (message.kind) { + case MESSAGE_KIND.Init: + const initMessage = message as InitMessage; + typeChecker = new TypeChecker( + initMessage.compilerOptions, + initMessage.basePath, + initMessage.jitMode, + initMessage.tsFilenames, + ); + break; + case MESSAGE_KIND.Update: + if (!typeChecker) { + throw new Error('TypeChecker: update message received before initialization'); + } + if (lastCancellationToken) { + // This cancellation token doesn't seem to do much, messages don't seem to be processed + // before the diagnostics finish. + lastCancellationToken.requestCancellation(); + } + const updateMessage = message as UpdateMessage; + lastCancellationToken = new CancellationToken(); + typeChecker.update(updateMessage.changedTsFiles, lastCancellationToken); + break; + default: + throw new Error(`TypeChecker: Unexpected message received: ${message}.`); + } + timeEnd('TypeChecker.message'); +}); + + +class TypeChecker { + private _program: ts.Program | Program; + private _angularCompilerHost: WebpackCompilerHost & CompilerHost; + + constructor( + private _angularCompilerOptions: CompilerOptions, + _basePath: string, + private _JitMode: boolean, + private _tsFilenames: string[], + ) { + time('TypeChecker.constructor'); + const compilerHost = new WebpackCompilerHost(_angularCompilerOptions, _basePath); + compilerHost.enableCaching(); + this._angularCompilerHost = createCompilerHost({ + options: this._angularCompilerOptions, + tsHost: compilerHost + }) as CompilerHost & WebpackCompilerHost; + this._tsFilenames = []; + timeEnd('TypeChecker.constructor'); + } + + private _updateTsFilenames(changedTsFiles: string[]) { + time('TypeChecker._updateTsFilenames'); + changedTsFiles.forEach((fileName) => { + this._angularCompilerHost.invalidate(fileName); + if (!this._tsFilenames.includes(fileName)) { + this._tsFilenames.push(fileName); + } + }); + timeEnd('TypeChecker._updateTsFilenames'); + } + + private _createOrUpdateProgram() { + if (this._JitMode) { + // Create the TypeScript program. + time('TypeChecker._createOrUpdateProgram.ts.createProgram'); + this._program = ts.createProgram( + this._tsFilenames, + this._angularCompilerOptions, + this._angularCompilerHost, + this._program as ts.Program + ) as ts.Program; + timeEnd('TypeChecker._createOrUpdateProgram.ts.createProgram'); + } else { + time('TypeChecker._createOrUpdateProgram.ng.createProgram'); + // Create the Angular program. + this._program = createProgram({ + rootNames: this._tsFilenames, + options: this._angularCompilerOptions, + host: this._angularCompilerHost, + oldProgram: this._program as Program + }) as Program; + timeEnd('TypeChecker._createOrUpdateProgram.ng.createProgram'); + } + } + + private _diagnose(cancellationToken: CancellationToken) { + const allDiagnostics = gatherDiagnostics( + this._program, this._JitMode, 'TypeChecker', cancellationToken); + + // Report diagnostics. + if (!cancellationToken.isCancellationRequested()) { + const errors = allDiagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); + const warnings = allDiagnostics.filter((d) => d.category === ts.DiagnosticCategory.Warning); + + if (errors.length > 0) { + const message = formatDiagnostics(this._angularCompilerOptions, errors); + console.error(bold(red('ERROR in ' + message))); + } + + if (warnings.length > 0) { + const message = formatDiagnostics(this._angularCompilerOptions, warnings); + console.log(bold(yellow('WARNING in ' + message))); + } + } + } + + public update(changedTsFiles: string[], cancellationToken: CancellationToken) { + this._updateTsFilenames(changedTsFiles); + this._createOrUpdateProgram(); + this._diagnose(cancellationToken); + } +} diff --git a/packages/@ngtools/webpack/src/type_checker_bootstrap.js b/packages/@ngtools/webpack/src/type_checker_bootstrap.js new file mode 100644 index 000000000000..e5a3ab002ecc --- /dev/null +++ b/packages/@ngtools/webpack/src/type_checker_bootstrap.js @@ -0,0 +1,2 @@ +require('../../../../lib/bootstrap-local'); +require('./type_checker.ts'); diff --git a/tests/e2e/tests/build/aot/angular-compiler.ts b/tests/e2e/tests/build/aot/angular-compiler.ts index 88fc8beec592..08fc6fda9ca9 100644 --- a/tests/e2e/tests/build/aot/angular-compiler.ts +++ b/tests/e2e/tests/build/aot/angular-compiler.ts @@ -1,6 +1,7 @@ import { ng, npm } from '../../../utils/process'; import { updateJsonFile } from '../../../utils/project'; import { expectFileToMatch, rimraf, moveFile } from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; import { getGlobalVariable } from '../../../utils/env'; import { expectToFail } from '../../../utils/utils'; @@ -32,20 +33,23 @@ export default function () { devDependencies['typescript'] = '2.4.2'; })) .then(() => npm('install')) + .then(() => ng('build')) + .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', + /bootstrapModuleFactory.*\/\* AppModuleNgFactory \*\//))) .then(() => ng('build', '--aot')) .then(() => expectFileToMatch('dist/main.bundle.js', /bootstrapModuleFactory.*\/\* AppModuleNgFactory \*\//)) // tests for register_locale_data transformer .then(() => rimraf('dist')) - .then(() => ng('build', '--aot', '--locale=fr')) + .then(() => ng('build', '--locale=fr')) .then(() => expectFileToMatch('dist/main.bundle.js', /registerLocaleData/)) .then(() => expectFileToMatch('dist/main.bundle.js', /angular_common_locales_fr/)) .then(() => rimraf('dist')) .then(() => expectToFail(() => - ng('build', '--aot', '--locale=no-locale'))) + ng('build', '--locale=no-locale'))) // Cleanup .then(() => { diff --git a/tests/e2e/tests/build/aot/aot-decorators.ts b/tests/e2e/tests/build/aot/aot-decorators.ts index 77bc1fb95a49..ef031951ef0c 100644 --- a/tests/e2e/tests/build/aot/aot-decorators.ts +++ b/tests/e2e/tests/build/aot/aot-decorators.ts @@ -5,7 +5,7 @@ import {getGlobalVariable} from '../../../utils/env'; export default function() { - // TODO: re-enable this test for ng5 + // TODO: re-enable this test for ng5. // now we only remove decorators via --build-optimizer if (getGlobalVariable('argv').nightly) { return Promise.resolve(); diff --git a/tests/e2e/tests/build/rebuild-deps-type-check.ts b/tests/e2e/tests/build/rebuild-deps-type-check.ts index d9658602a7a7..990748cfaffd 100644 --- a/tests/e2e/tests/build/rebuild-deps-type-check.ts +++ b/tests/e2e/tests/build/rebuild-deps-type-check.ts @@ -9,6 +9,7 @@ import {getGlobalVariable} from '../../utils/env'; const doneRe = /webpack: bundle is now VALID|webpack: Compiled successfully.|webpack: Failed to compile./; +const errorRe = /ERROR in/; export default function() { @@ -41,7 +42,7 @@ export default function() { // Make an invalid version of the file. // Should trigger a rebuild, this time an error is expected. .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(doneRe, 20000), + waitForAnyProcessOutputToMatch(errorRe, 20000), writeFile('src/funky2.ts', ` export function funky2(value: number): number { return value + 1; @@ -57,9 +58,9 @@ export default function() { // Change an UNRELATED file and the error should still happen. // Should trigger a rebuild, this time an error is also expected. .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(doneRe, 20000), + waitForAnyProcessOutputToMatch(errorRe, 20000), appendToFile('src/app/app.module.ts', ` - function anything(): number {} + function anything(): number { return 1; } `) ])) .then((results) => { diff --git a/tests/e2e/tests/build/script-target.ts b/tests/e2e/tests/build/script-target.ts index 9f354ae5e421..e8932bf3ff16 100644 --- a/tests/e2e/tests/build/script-target.ts +++ b/tests/e2e/tests/build/script-target.ts @@ -2,14 +2,24 @@ import { appendToFile, expectFileToMatch } from '../../utils/fs'; import { ng } from '../../utils/process'; import { expectToFail } from '../../utils/utils'; import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; export default function () { + + let knownES6Module = '@angular/core/@angular/core'; + + // TODO: swap this check for ng5. + if (getGlobalVariable('argv').nightly) { + // Angular 5 has a different folder structure. + knownES6Module = '@angular/core/esm2015/core'; + } + return Promise.resolve() // Force import a known ES6 module and build with prod. // ES6 modules will cause UglifyJS to fail on a ES5 compilation target (default). .then(() => appendToFile('src/main.ts', ` - import * as es6module from '@angular/core/@angular/core'; + import * as es6module from '${knownES6Module}'; console.log(es6module); `)) .then(() => expectToFail(() => ng('build', '--prod'))) diff --git a/tests/e2e/tests/misc/typescript-warning.ts b/tests/e2e/tests/misc/typescript-warning.ts index 039b08c28424..bbab2dc6ad17 100644 --- a/tests/e2e/tests/misc/typescript-warning.ts +++ b/tests/e2e/tests/misc/typescript-warning.ts @@ -7,9 +7,11 @@ export default function () { // Update as needed. let unsupportedTsVersion = '2.5'; - // Skip this in Appveyor tests. + // TODO: re-enable for ng5, adjust as needed. This test fails on ng5 because the 2.5 is supported. + // When ng5 because the default this test will need to be adjusted to use 2.3 as the unsupported + // version, and to disable the experimental angular compiler (transforms need 2.4 minimum). if (getGlobalVariable('argv').nightly) { - unsupportedTsVersion = '2.3'; + return; } return Promise.resolve() diff --git a/tests/e2e/utils/process.ts b/tests/e2e/utils/process.ts index 857400b3df15..105a3695223b 100644 --- a/tests/e2e/utils/process.ts +++ b/tests/e2e/utils/process.ts @@ -117,6 +117,9 @@ export function waitForAnyProcessOutputToMatch(match: RegExp, }); childProcess.stderr.on('data', (data: Buffer) => { stderr += data.toString(); + if (data.toString().match(match)) { + resolve({ stdout, stderr }); + } }); })); @@ -177,8 +180,6 @@ export function ng(...args: string[]) { // Wait 1 second before running any end-to-end test. return new Promise(resolve => setTimeout(resolve, 1000)) .then(() => maybeSilentNg(...args)); - } else if (argv.nightly && args.includes('--aot')) { - args.push('--experimental-angular-compiler'); } return maybeSilentNg(...args); diff --git a/tests/e2e/utils/project.ts b/tests/e2e/utils/project.ts index 5508c68b5743..a11784cfbd12 100644 --- a/tests/e2e/utils/project.ts +++ b/tests/e2e/utils/project.ts @@ -120,6 +120,7 @@ export function useSha() { json['devDependencies'][`@angular/${pkgName}`] = `github:angular/${pkgName}-builds${label}`; }); + json['devDependencies']['typescript'] = '~2.4.0'; }); } else { return Promise.resolve();