diff --git a/package.json b/package.json index 917dbb35b21d..ba1f7626bfdc 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "@angular/tsc-wrapped": "0.4.0", "angular2-template-loader": "^0.5.0", "autoprefixer": "^6.5.3", - "awesome-typescript-loader": "^2.2.3", "chalk": "^1.1.3", "common-tags": "^1.3.1", "compression-webpack-plugin": "^0.3.2", @@ -60,6 +59,7 @@ "ember-cli-normalize-entity-name": "^1.0.0", "ember-cli-preprocess-registry": "^2.0.0", "ember-cli-string-utils": "^1.0.0", + "enhanced-resolve": "^2.3.0", "exists-sync": "0.0.3", "extract-text-webpack-plugin": "^2.0.0-beta.4", "file-loader": "^0.8.5", @@ -81,6 +81,7 @@ "less": "^2.7.1", "less-loader": "^2.2.3", "lodash": "^4.11.1", + "magic-string": "^0.16.0", "markdown-it": "4.3.0", "markdown-it-terminal": "0.0.3", "minimatch": "^3.0.0", @@ -111,6 +112,7 @@ "script-loader": "^0.7.0", "semver": "^5.1.0", "silent-error": "^1.0.0", + "source-map": "^0.5.6", "source-map-loader": "^0.1.5", "sourcemap-istanbul-instrumenter-loader": "^0.2.0", "string-replace-loader": "^1.0.5", @@ -151,6 +153,7 @@ "@types/node": "^6.0.36", "@types/request": "0.0.30", "@types/rimraf": "0.0.25-alpha", + "@types/source-map": "^0.5.0", "@types/webpack": "^1.12.22-alpha", "chai": "^3.5.0", "conventional-changelog": "^1.1.0", diff --git a/packages/angular-cli/models/webpack-build-test.js b/packages/angular-cli/models/webpack-build-test.js index 8361c87a00b4..3c4c390341b0 100644 --- a/packages/angular-cli/models/webpack-build-test.js +++ b/packages/angular-cli/models/webpack-build-test.js @@ -2,7 +2,14 @@ const path = require('path'); const webpack = require('webpack'); -const atl = require('awesome-typescript-loader'); +const ngtools = require('@ngtools/webpack'); + + +const g = global; +const webpackLoader = g['angularCliIsLocal'] + ? g.angularCliPackages['@ngtools/webpack'].main + : '@ngtools/webpack'; + const getWebpackTestConfig = function (projectRoot, environment, appConfig, testConfig) { @@ -48,8 +55,8 @@ const getWebpackTestConfig = function (projectRoot, environment, appConfig, test resolve: { extensions: ['.ts', '.js'], plugins: [ - new atl.TsConfigPathsPlugin({ - tsconfig: path.resolve(appRoot, appConfig.tsconfig) + new ngtools.PathsPlugin({ + tsConfigPath: path.resolve(appRoot, appConfig.tsconfig) }) ] }, @@ -74,16 +81,11 @@ const getWebpackTestConfig = function (projectRoot, environment, appConfig, test test: /\.ts$/, loaders: [ { - loader: 'awesome-typescript-loader', + loader: webpackLoader, query: { - tsconfig: path.resolve(appRoot, appConfig.tsconfig), - module: 'commonjs', - target: 'es5', - forkChecker: true + tsConfigPath: path.resolve(appRoot, appConfig.tsconfig), + module: 'commonjs' } - }, - { - loader: 'angular2-template-loader' } ], exclude: [/\.e2e\.ts$/] diff --git a/packages/angular-cli/models/webpack-build-typescript.ts b/packages/angular-cli/models/webpack-build-typescript.ts index f1b511eca35a..8371e79c9364 100644 --- a/packages/angular-cli/models/webpack-build-typescript.ts +++ b/packages/angular-cli/models/webpack-build-typescript.ts @@ -1,9 +1,6 @@ import * as path from 'path'; -import * as webpack from 'webpack'; -import {findLazyModules} from './find-lazy-modules'; import {AotPlugin} from '@ngtools/webpack'; -const atl = require('awesome-typescript-loader'); const g: any = global; const webpackLoader: string = g['angularCliIsLocal'] @@ -12,37 +9,22 @@ const webpackLoader: string = g['angularCliIsLocal'] export const getWebpackNonAotConfigPartial = function(projectRoot: string, appConfig: any) { - const appRoot = path.resolve(projectRoot, appConfig.root); - const lazyModules = findLazyModules(appRoot); - return { - resolve: { - plugins: [ - new atl.TsConfigPathsPlugin({ - tsconfig: path.resolve(appRoot, appConfig.tsconfig) - }) - ] - }, module: { rules: [ { test: /\.ts$/, - loaders: [{ - loader: 'awesome-typescript-loader', - query: { - forkChecker: true, - tsconfig: path.resolve(appRoot, appConfig.tsconfig) - } - }, { - loader: 'angular2-template-loader' - }], + loader: webpackLoader, exclude: [/\.(spec|e2e)\.ts$/] } - ], + ] }, plugins: [ - new webpack.ContextReplacementPlugin(/.*/, appRoot, lazyModules), - new atl.ForkCheckerPlugin(), + new AotPlugin({ + tsConfigPath: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig), + mainPath: path.join(projectRoot, appConfig.root, appConfig.main), + skipCodeGeneration: true + }), ] }; }; diff --git a/packages/angular-cli/package.json b/packages/angular-cli/package.json index b31db9bbde02..af055c584489 100644 --- a/packages/angular-cli/package.json +++ b/packages/angular-cli/package.json @@ -32,7 +32,6 @@ "@angular/core": "2.2.1", "@ngtools/webpack": "^1.0.0", "angular2-template-loader": "^0.5.0", - "awesome-typescript-loader": "^2.2.3", "chalk": "^1.1.3", "common-tags": "^1.3.1", "compression-webpack-plugin": "^0.3.2", @@ -45,6 +44,7 @@ "ember-cli-normalize-entity-name": "^1.0.0", "ember-cli-preprocess-registry": "^2.0.0", "ember-cli-string-utils": "^1.0.0", + "enhanced-resolve": "^2.3.0", "exists-sync": "0.0.3", "extract-text-webpack-plugin": "^2.0.0-beta.4", "file-loader": "^0.8.5", diff --git a/packages/webpack/README.md b/packages/webpack/README.md index e04251f4f7bc..2bd1e5abb5a2 100644 --- a/packages/webpack/README.md +++ b/packages/webpack/README.md @@ -35,5 +35,5 @@ The loader works with the webpack plugin to compile your TypeScript. It's import * `basePath`. Optional. The root to use by the compiler to resolve file paths. By default, use the `tsConfigPath` root. * `entryModule`. Optional if specified in `angularCompilerOptions`. The path and classname of the main application module. This follows the format `path/to/file#ClassName`. * `mainPath`. Optional if `entryModule` is specified. The `main.ts` file containing the bootstrap code. The plugin will use AST to determine the `entryModule`. -* `genDir`. Optional. The output directory of the offline compiler. The files created by the offline compiler will be in a virtual file system, but the import paths might change. This can also be specified in `angularCompilerOptions`, and by default will be the same as `basePath`. -* `typeChecking`. Optional, defaults to true. Enable type checking through your application. This will slow down compilation, but show syntactic and semantic errors in webpack. \ No newline at end of file +* `skipCodeGeneration`. Optional, defaults to false. Disable code generation and do not refactor the code to bootstrap. This replaces `templateUrl: "string"` with `template: require("string")` (and similar for styles) to allow for webpack to properly link the resources. +* `typeChecking`. Optional, defaults to true. Enable type checking through your application. This will slow down compilation, but show syntactic and semantic errors in webpack. diff --git a/packages/webpack/package.json b/packages/webpack/package.json index e8221626e700..88db168a9613 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -25,7 +25,9 @@ "npm": ">= 3.0.0" }, "dependencies": { - "@angular-cli/ast-tools": "^1.0.0" + "@angular-cli/ast-tools": "^1.0.0", + "magic-string": "^0.16.0", + "source-map": "^0.5.6" }, "peerDependencies": { "typescript": "^2.0.2", diff --git a/packages/webpack/src/compiler.ts b/packages/webpack/src/compiler.ts deleted file mode 100644 index 87dd33414619..000000000000 --- a/packages/webpack/src/compiler.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as tscWrapped from '@angular/tsc-wrapped/src/compiler_host'; -import * as ts from 'typescript'; - - -export class NgcWebpackCompilerHost extends tscWrapped.DelegatingHost { - fileCache = new Map(); - - constructor(delegate: ts.CompilerHost) { - super(delegate); - } -} - -export function createCompilerHost(tsConfig: any) { - const delegateHost = ts.createCompilerHost(tsConfig['compilerOptions']); - return new NgcWebpackCompilerHost(delegateHost); -} diff --git a/packages/webpack/src/index.ts b/packages/webpack/src/index.ts index 8fd3bad21818..067d1e724697 100644 --- a/packages/webpack/src/index.ts +++ b/packages/webpack/src/index.ts @@ -1,4 +1,5 @@ import 'reflect-metadata'; -export * from './plugin' -export {ngcLoader as default} from './loader' +export * from './plugin'; +export {ngcLoader as default} from './loader'; +export {PathsPlugin} from './paths-plugin'; diff --git a/packages/webpack/src/loader.ts b/packages/webpack/src/loader.ts index 3e2c006ce6b9..8b51282cd8e0 100644 --- a/packages/webpack/src/loader.ts +++ b/packages/webpack/src/loader.ts @@ -1,48 +1,38 @@ import * as path from 'path'; import * as ts from 'typescript'; import {AotPlugin} from './plugin'; -import {MultiChange, ReplaceChange, insertImport} from '@angular-cli/ast-tools'; +import {TypeScriptFileRefactor} from './refactor'; + +const loaderUtils = require('loader-utils'); -// TODO: move all this to ast-tools. -function _findNodes(sourceFile: ts.SourceFile, node: ts.Node, kind: ts.SyntaxKind, - keepGoing = false): ts.Node[] { - if (node.kind == kind && !keepGoing) { - return [node]; - } - return node.getChildren(sourceFile).reduce((result, n) => { - return result.concat(_findNodes(sourceFile, n, kind, keepGoing)); - }, node.kind == kind ? [node] : []); +function _getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string { + 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 _removeDecorators(fileName: string, source: string): string { - const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); +function _removeDecorators(refactor: TypeScriptFileRefactor) { // Find all decorators. - const decorators = _findNodes(sourceFile, sourceFile, ts.SyntaxKind.Decorator); - decorators.sort((a, b) => b.pos - a.pos); - - decorators.forEach(d => { - source = source.slice(0, d.pos) + source.slice(d.end); - }); - - return source; + refactor.findAstNodes(refactor.sourceFile, ts.SyntaxKind.Decorator) + .forEach(d => refactor.removeNode(d)); } -function _replaceBootstrap(fileName: string, - source: string, - plugin: AotPlugin): Promise { +function _replaceBootstrap(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { // If bootstrapModule can't be found, bail out early. - if (!source.match(/\bbootstrapModule\b/)) { - return Promise.resolve(source); + if (!refactor.sourceMatch(/\bbootstrapModule\b/)) { + return; } - let changes = new MultiChange(); - // Calculate the base path. const basePath = path.normalize(plugin.basePath); const genDir = path.normalize(plugin.genDir); - const dirName = path.normalize(path.dirname(fileName)); + const dirName = path.normalize(path.dirname(refactor.fileName)); const entryModule = plugin.entryModule; const entryModuleFileName = path.normalize(entryModule.path + '.ngfactory'); const relativeEntryModulePath = path.relative(basePath, entryModuleFileName); @@ -50,10 +40,8 @@ function _replaceBootstrap(fileName: string, const relativeNgFactoryPath = path.relative(dirName, fullEntryModulePath); const ngFactoryPath = './' + relativeNgFactoryPath.replace(/\\/g, '/'); - const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); - - const allCalls = _findNodes( - sourceFile, sourceFile, ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]; + const allCalls = refactor.findAstNodes(refactor.sourceFile, + ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]; const bootstraps = allCalls .filter(call => call.expression.kind == ts.SyntaxKind.PropertyAccessExpression) @@ -63,107 +51,160 @@ function _replaceBootstrap(fileName: string, && access.name.text == 'bootstrapModule'; }); - const calls: ts.Node[] = bootstraps + const calls: ts.CallExpression[] = bootstraps .reduce((previous, access) => { - return previous.concat(_findNodes(sourceFile, access, ts.SyntaxKind.CallExpression, true)); + const expressions + = refactor.findAstNodes(access, ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]; + return previous.concat(expressions); }, []) - .filter(call => { + .filter((call: ts.CallExpression) => { return call.expression.kind == ts.SyntaxKind.Identifier - && call.expression.text == 'platformBrowserDynamic'; + && (call.expression as ts.Identifier).text == 'platformBrowserDynamic'; }); if (calls.length == 0) { // Didn't find any dynamic bootstrapping going on. - return Promise.resolve(source); + return; } // Create the changes we need. allCalls .filter(call => bootstraps.some(bs => bs == call.expression)) .forEach((call: ts.CallExpression) => { - changes.appendChange(new ReplaceChange(fileName, call.arguments[0].getStart(sourceFile), - entryModule.className, entryModule.className + 'NgFactory')); + refactor.replaceNode(call.arguments[0], entryModule.className + 'NgFactory'); }); - calls - .forEach(call => { - changes.appendChange(new ReplaceChange(fileName, call.getStart(sourceFile), - 'platformBrowserDynamic', 'platformBrowser')); - }); + calls.forEach(call => refactor.replaceNode(call.expression, 'platformBrowser')); bootstraps .forEach((bs: ts.PropertyAccessExpression) => { // This changes the call. - changes.appendChange(new ReplaceChange(fileName, bs.name.getStart(sourceFile), - 'bootstrapModule', 'bootstrapModuleFactory')); + refactor.replaceNode(bs.name, 'bootstrapModuleFactory'); }); - changes.appendChange(insertImport(fileName, 'platformBrowser', '@angular/platform-browser')); - changes.appendChange(insertImport(fileName, entryModule.className + 'NgFactory', ngFactoryPath)); - - let sourceText = source; - return changes.apply({ - read: (path: string) => Promise.resolve(sourceText), - write: (path: string, content: string) => Promise.resolve(sourceText = content) - }).then(() => sourceText); + + refactor.insertImport('platformBrowser', '@angular/platform-browser'); + refactor.insertImport(entryModule.className + 'NgFactory', ngFactoryPath); } -function _transpile(plugin: AotPlugin, fileName: string, sourceText: string) { - const program = plugin.program; - if (plugin.typeCheck) { - const sourceFile = program.getSourceFile(fileName); - const diagnostics = program.getSyntacticDiagnostics(sourceFile) - .concat(program.getSemanticDiagnostics(sourceFile)) - .concat(program.getDeclarationDiagnostics(sourceFile)); - - if (diagnostics.length > 0) { - const message = diagnostics - .map(diagnostic => { - const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message})`; - }) - .join('\n'); - throw new Error(message); - } - } +function _replaceResources(refactor: TypeScriptFileRefactor): void { + const sourceFile = refactor.sourceFile; + + // Find all object literals. + refactor.findAstNodes(sourceFile, ts.SyntaxKind.ObjectLiteralExpression, true) + // Get all their property assignments. + .map(node => refactor.findAstNodes(node, ts.SyntaxKind.PropertyAssignment)) + // Flatten into a single array (from an array of array). + .reduce((prev, curr) => curr ? prev.concat(curr) : prev, []) + // Remove every property assignment that aren't 'loadChildren'. + .filter((node: ts.PropertyAssignment) => { + const key = _getContentOfKeyLiteral(sourceFile, node.name); + if (!key) { + // key is an expression, can't do anything. + return false; + } + return key == 'templateUrl' || key == 'styleUrls'; + }) + // Get the full text of the initializer. + .forEach((node: ts.PropertyAssignment) => { + const key = _getContentOfKeyLiteral(sourceFile, node.name); + + if (key == 'templateUrl') { + refactor.replaceNode(node, + `template: require(${node.initializer.getFullText(sourceFile)})`); + } else if (key == 'styleUrls') { + const arr = ( + refactor.findAstNodes(node, ts.SyntaxKind.ArrayLiteralExpression, false)); + if (!arr || arr.length == 0 || arr[0].elements.length == 0) { + return; + } + + const initializer = arr[0].elements.map((element: ts.Expression) => { + return element.getFullText(sourceFile); + }); + refactor.replaceNode(node, `styles: [require(${initializer.join('), require(')})]`); + } + }); +} + + +function _checkDiagnostics(refactor: TypeScriptFileRefactor) { + const diagnostics = refactor.getDiagnostics(); - // Force a few compiler options to make sure we get the result we want. - const compilerOptions: ts.CompilerOptions = Object.assign({}, plugin.compilerOptions, { - inlineSources: true, - inlineSourceMap: false, - sourceRoot: plugin.basePath - }); - - const result = ts.transpileModule(sourceText, { compilerOptions, fileName }); - return { - outputText: result.outputText, - sourceMap: JSON.parse(result.sourceMapText) - }; + if (diagnostics.length > 0) { + const message = diagnostics + .map(diagnostic => { + const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message})`; + }) + .join('\n'); + throw new Error(message); + } } + // Super simple TS transpiler loader for testing / isolated usage. does not type check! export function ngcLoader(source: string) { this.cacheable(); + const cb: any = this.async(); + const sourceFileName: string = this.resourcePath; const plugin = this._compilation._ngToolsWebpackPluginInstance as AotPlugin; // We must verify that AotPlugin is an instance of the right class. if (plugin && plugin instanceof AotPlugin) { - const cb: any = this.async(); + const refactor = new TypeScriptFileRefactor( + sourceFileName, plugin.compilerHost, plugin.program); Promise.resolve() - .then(() => _removeDecorators(this.resource, source)) - .then(sourceText => _replaceBootstrap(this.resource, sourceText, plugin)) - .then(sourceText => { - const result = _transpile(plugin, this.resourcePath, sourceText); + .then(() => { + if (!plugin.skipCodeGeneration) { + return Promise.resolve() + .then(() => _removeDecorators(refactor)) + .then(() => _replaceBootstrap(plugin, refactor)); + } else { + return _replaceResources(refactor); + } + }) + .then(() => { + if (plugin.typeCheck) { + _checkDiagnostics(refactor); + } + }) + .then(() => { + // Force a few compiler options to make sure we get the result we want. + const compilerOptions: ts.CompilerOptions = Object.assign({}, plugin.compilerOptions, { + inlineSources: true, + inlineSourceMap: false, + sourceRoot: plugin.basePath + }); + + const result = refactor.transpile(compilerOptions); cb(null, result.outputText, result.sourceMap); }) .catch(err => cb(err)); } else { - return ts.transpileModule(source, { - compilerOptions: { - target: ts.ScriptTarget.ES5, - module: ts.ModuleKind.ES2015, + const options = loaderUtils.parseQuery(this.query); + const tsConfigPath = options.tsConfigPath; + const tsConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile); + + if (tsConfig.error) { + throw tsConfig.error; + } + + const compilerOptions: ts.CompilerOptions = tsConfig.config.compilerOptions; + for (const key of Object.keys(options)) { + if (key == 'tsConfigPath') { + continue; } - }).outputText; + compilerOptions[key] = options[key]; + } + const compilerHost = ts.createCompilerHost(compilerOptions); + const refactor = new TypeScriptFileRefactor(sourceFileName, compilerHost); + _replaceResources(refactor); + + const result = refactor.transpile(compilerOptions); + // Webpack is going to take care of this. + result.outputText = result.outputText.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + cb(null, result.outputText, result.sourceMap); } } diff --git a/packages/webpack/src/paths-plugin.ts b/packages/webpack/src/paths-plugin.ts new file mode 100644 index 000000000000..b4ec576d462d --- /dev/null +++ b/packages/webpack/src/paths-plugin.ts @@ -0,0 +1,174 @@ +import * as path from 'path'; +import * as ts from 'typescript'; +import {Request, ResolverPlugin, Callback, Tapable} from './webpack'; + + +const ModulesInRootPlugin: new (a: string, b: string, c: string) => ResolverPlugin + = require('enhanced-resolve/lib/ModulesInRootPlugin'); + +interface CreateInnerCallback { + (callback: Callback, + options: Callback, + message?: string, + messageOptional?: string): Callback; +} + +const createInnerCallback: CreateInnerCallback + = require('enhanced-resolve/lib/createInnerCallback'); +const getInnerRequest: (resolver: ResolverPlugin, request: Request) => string + = require('enhanced-resolve/lib/getInnerRequest'); + + +function escapeRegExp(str: string): string { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); +} + + +export interface PathsPluginOptions { + tsConfigPath: string; + compilerOptions?: ts.CompilerOptions; + compilerHost?: ts.CompilerHost; +} + +export class PathsPlugin implements Tapable { + private _tsConfigPath: string; + private _compilerOptions: ts.CompilerOptions; + private _host: ts.CompilerHost; + + source: string; + target: string; + + private mappings: any; + + private _absoluteBaseUrl: string; + + private static _loadOptionsFromTsConfig(tsConfigPath: string, host?: ts.CompilerHost): + ts.CompilerOptions { + const tsConfig = ts.readConfigFile(tsConfigPath, (path: string) => { + if (host) { + return host.readFile(path); + } else { + return ts.sys.readFile(path); + } + }); + if (tsConfig.error) { + throw tsConfig.error; + } + return tsConfig.config; + } + + constructor(options: PathsPluginOptions) { + if (!options.hasOwnProperty('tsConfigPath')) { + // This could happen in JavaScript. + throw new Error('tsConfigPath option is mandatory.'); + } + this._tsConfigPath = options.tsConfigPath; + + if (options.hasOwnProperty('compilerOptions')) { + this._compilerOptions = Object.assign({}, options.compilerOptions); + } else { + this._compilerOptions = PathsPlugin._loadOptionsFromTsConfig(this._tsConfigPath, null); + } + + if (options.hasOwnProperty('compilerHost')) { + this._host = options.compilerHost; + } else { + this._host = ts.createCompilerHost(this._compilerOptions, false); + } + + this.source = 'described-resolve'; + this.target = 'resolve'; + + this._absoluteBaseUrl = path.resolve( + path.dirname(this._tsConfigPath), + this._compilerOptions.baseUrl || '.' + ); + + this.mappings = []; + let paths = this._compilerOptions.paths || {}; + Object.keys(paths).forEach(alias => { + let onlyModule = alias.indexOf('*') === -1; + let excapedAlias = escapeRegExp(alias); + let targets = paths[alias]; + targets.forEach(target => { + let aliasPattern: RegExp; + if (onlyModule) { + aliasPattern = new RegExp(`^${excapedAlias}$`); + } else { + let withStarCapturing = excapedAlias.replace('\\*', '(.*)'); + aliasPattern = new RegExp(`^${withStarCapturing}`); + } + + this.mappings.push({ + onlyModule, + alias, + aliasPattern, + target: target + }); + }); + }); + } + + apply(resolver: ResolverPlugin): void { + let { baseUrl } = this._compilerOptions; + + if (baseUrl) { + resolver.apply(new ModulesInRootPlugin('module', this._absoluteBaseUrl, 'resolve')); + } + + this.mappings.forEach((mapping: any) => { + resolver.plugin(this.source, this.createPlugin(resolver, mapping)); + }); + } + + resolve(resolver: ResolverPlugin, mapping: any, request: any, callback: Callback): any { + let innerRequest = getInnerRequest(resolver, request); + if (!innerRequest) { + return callback(); + } + + let match = innerRequest.match(mapping.aliasPattern); + if (!match) { + return callback(); + } + + let newRequestStr = mapping.target; + if (!mapping.onlyModule) { + newRequestStr = newRequestStr.replace('*', match[1]); + } + if (newRequestStr[0] === '.') { + newRequestStr = path.resolve(this._absoluteBaseUrl, newRequestStr); + } + + let newRequest = Object.assign({}, request, { + request: newRequestStr + }) as Request; + + return resolver.doResolve( + this.target, + newRequest, + `aliased with mapping '${innerRequest}': '${mapping.alias}' to '${newRequestStr}'`, + createInnerCallback( + function(err, result) { + if (arguments.length > 0) { + return callback(err, result); + } + + // don't allow other aliasing or raw request + callback(null, null); + }, + callback + ) + ); + } + + createPlugin(resolver: ResolverPlugin, mapping: any): any { + return (request: any, callback: Callback) => { + try { + this.resolve(resolver, mapping, request, callback); + } catch (err) { + callback(err); + } + }; + } +} diff --git a/packages/webpack/src/plugin.ts b/packages/webpack/src/plugin.ts index e0ff548e834f..ea4d7fd3b316 100644 --- a/packages/webpack/src/plugin.ts +++ b/packages/webpack/src/plugin.ts @@ -12,6 +12,8 @@ import {createResolveDependenciesFromContextMap} from './utils'; import {WebpackCompilerHost} from './compiler_host'; import {resolveEntryModuleFromMain} from './entry_resolver'; import {StaticSymbol} from '@angular/compiler-cli'; +import {Tapable} from './webpack'; +import {PathsPlugin} from './paths-plugin'; /** @@ -23,6 +25,8 @@ export interface AotPluginOptions { entryModule?: string; mainPath?: string; typeChecking?: boolean; + + skipCodeGeneration?: boolean; } @@ -52,7 +56,7 @@ export class ModuleRoute { } -export class AotPlugin { +export class AotPlugin implements Tapable { private _entryModule: ModuleRoute; private _compilerOptions: ts.CompilerOptions; private _angularCompilerOptions: ngCompiler.AngularCompilerOptions; @@ -63,12 +67,14 @@ export class AotPlugin { private _compilerHost: WebpackCompilerHost; private _resourceLoader: WebpackResourceLoader; private _lazyRoutes: { [route: string]: string }; + private _tsConfigPath: string; private _donePromise: Promise; private _compiler: any = null; private _compilation: any = null; private _typeCheck: boolean = true; + private _skipCodeGeneration: boolean = false; private _basePath: string; private _genDir: string; @@ -85,6 +91,7 @@ export class AotPlugin { get entryModule() { return this._entryModule; } get genDir() { return this._genDir; } get program() { return this._program; } + get skipCodeGeneration() { return this._skipCodeGeneration; } get typeCheck() { return this._typeCheck; } private _setupOptions(options: AotPluginOptions) { @@ -92,17 +99,18 @@ export class AotPlugin { if (!options.hasOwnProperty('tsConfigPath')) { throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); } + this._tsConfigPath = options.tsConfigPath; // Check the base path. - let basePath = path.resolve(process.cwd(), path.dirname(options.tsConfigPath)); - if (fs.statSync(options.tsConfigPath).isDirectory()) { - basePath = options.tsConfigPath; + let basePath = path.resolve(process.cwd(), path.dirname(this._tsConfigPath)); + if (fs.statSync(this._tsConfigPath).isDirectory()) { + basePath = this._tsConfigPath; } if (options.hasOwnProperty('basePath')) { basePath = options.basePath; } - const tsConfig = tsc.readConfiguration(options.tsConfigPath, basePath); + const tsConfig = tsc.readConfiguration(this._tsConfigPath, basePath); this._rootFilePath = tsConfig.parsed.fileNames .filter(fileName => !/\.spec\.ts$/.test(fileName)); @@ -134,6 +142,9 @@ export class AotPlugin { if (options.hasOwnProperty('typeChecking')) { this._typeCheck = options.typeChecking; } + if (options.hasOwnProperty('skipCodeGeneration')) { + this._skipCodeGeneration = options.skipCodeGeneration; + } this._compilerHost = new WebpackCompilerHost(this._compilerOptions); this._program = ts.createProgram( @@ -148,13 +159,19 @@ export class AotPlugin { this._compiler = compiler; compiler.plugin('context-module-factory', (cmf: any) => { + cmf.resolvers.normal.apply(new PathsPlugin({ + tsConfigPath: this._tsConfigPath, + compilerOptions: this._compilerOptions, + compilerHost: this._compilerHost + })); + cmf.plugin('before-resolve', (request: any, callback: (err?: any, request?: any) => void) => { if (!request) { return callback(); } request.request = this.genDir; - request.recursive = true; + request.recursive = true; request.dependencies.forEach((d: any) => d.critical = false); return callback(null, request); }); @@ -171,7 +188,7 @@ export class AotPlugin { (_: any, cb: any) => cb(null, this._lazyRoutes)); return callback(null, result); - }); + }).catch((err) => callback(err)); }); }); @@ -210,26 +227,37 @@ export class AotPlugin { basePath: this.basePath }; - // Create the Code Generator. - const codeGenerator = ngCompiler.CodeGenerator.create( - this._angularCompilerOptions, - i18nOptions, - this._program, - this._compilerHost, - new ngCompiler.NodeReflectorHostContext(this._compilerHost), - this._resourceLoader - ); - - // We need to temporarily patch the CodeGenerator until either it's patched or allows us - // to pass in our own ReflectorHost. - patchReflectorHost(codeGenerator); - this._donePromise = codeGenerator.codegen({transitiveModules: true}) + let promise = Promise.resolve(); + if (!this._skipCodeGeneration) { + // Create the Code Generator. + const codeGenerator = ngCompiler.CodeGenerator.create( + this._angularCompilerOptions, + i18nOptions, + this._program, + this._compilerHost, + new ngCompiler.NodeReflectorHostContext(this._compilerHost), + this._resourceLoader + ); + + // We need to temporarily patch the CodeGenerator until either it's patched or allows us + // to pass in our own ReflectorHost. + // TODO: remove this. + patchReflectorHost(codeGenerator); + promise = promise.then(() => codeGenerator.codegen({ + transitiveModules: true + })); + } + + this._donePromise = promise .then(() => { // Create a new Program, based on the old one. This will trigger a resolution of all // 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. this._program = ts.createProgram( this._rootFilePath, this._compilerOptions, this._compilerHost, this._program); - + }) + .then(() => { const diagnostics = this._program.getGlobalDiagnostics(); if (diagnostics.length > 0) { const message = diagnostics @@ -255,7 +283,11 @@ export class AotPlugin { Object.keys(allLazyRoutes) .forEach(k => { const lazyRoute = allLazyRoutes[k]; - this._lazyRoutes[k + '.ngfactory'] = lazyRoute.moduleAbsolutePath + '.ngfactory.ts'; + if (this.skipCodeGeneration) { + this._lazyRoutes[k] = lazyRoute.moduleAbsolutePath; + } else { + this._lazyRoutes[k + '.ngfactory'] = lazyRoute.moduleAbsolutePath + '.ngfactory.ts'; + } }); }) .then(() => cb(), (err: any) => { cb(err); }); diff --git a/packages/webpack/src/refactor.ts b/packages/webpack/src/refactor.ts new file mode 100644 index 000000000000..9a2a174e3b33 --- /dev/null +++ b/packages/webpack/src/refactor.ts @@ -0,0 +1,203 @@ +// TODO: move this in its own package. +import * as path from 'path'; +import * as ts from 'typescript'; +import {SourceMapConsumer, SourceMapGenerator} from 'source-map'; + +const MagicString = require('magic-string'); + + +export interface TranspileOutput { + outputText: string; + sourceMap: any | null; +} + +export class TypeScriptFileRefactor { + private _sourceFile: ts.SourceFile; + private _sourceString: any; + private _sourceText: string; + private _changed: boolean = false; + + get fileName() { return this._fileName; } + get sourceFile() { return this._sourceFile; } + get sourceText() { return this._sourceString.toString(); } + + constructor(private _fileName: string, + private _host: ts.CompilerHost, + private _program?: ts.Program) { + if (_program) { + this._sourceFile = _program.getSourceFile(_fileName); + } + if (!this._sourceFile) { + this._program = null; + this._sourceFile = ts.createSourceFile(_fileName, _host.readFile(_fileName), + ts.ScriptTarget.Latest); + } + this._sourceText = this._sourceFile.getFullText(this._sourceFile); + this._sourceString = new MagicString(this._sourceText); + } + + getDiagnostics(): ts.Diagnostic[] { + if (!this._program) { + return []; + } + + return this._program.getSyntacticDiagnostics(this._sourceFile) + .concat(this._program.getSemanticDiagnostics(this._sourceFile)) + .concat(this._program.getDeclarationDiagnostics(this._sourceFile)); + } + + /** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param recursive Whether to go in matched nodes to keep matching. + * @param max The maximum number of items to return. + * @return all nodes of kind, or [] if none is found + */ + findAstNodes(node: ts.Node, + kind: ts.SyntaxKind, + recursive = false, + max: number = Infinity): ts.Node[] { + if (!node || max == 0) { + return []; + } + + let arr: ts.Node[] = []; + if (node.kind === kind) { + // If we're not recursively looking for children, stop here. + if (!recursive) { + return [node]; + } + + arr.push(node); + max--; + } + + if (max > 0) { + for (const child of node.getChildren(this._sourceFile)) { + this.findAstNodes(child, kind, recursive, max) + .forEach((node: ts.Node) => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + return arr; + } + + appendAfter(node: ts.Node, text: string): void { + this._sourceString.insertRight(node.getEnd(), text); + } + + insertImport(symbolName: string, modulePath: string): void { + // Find all imports. + const allImports = this.findAstNodes(this._sourceFile, ts.SyntaxKind.ImportDeclaration); + const maybeImports = allImports + .filter((node: ts.ImportDeclaration) => { + // Filter all imports that do not match the modulePath. + return node.moduleSpecifier.kind == ts.SyntaxKind.StringLiteral + && (node.moduleSpecifier as ts.StringLiteral).text == modulePath; + }) + .filter((node: ts.ImportDeclaration) => { + // Remove import statements that are either `import 'XYZ'` or `import * as X from 'XYZ'`. + const clause = node.importClause as ts.ImportClause; + if (!clause || clause.name || !clause.namedBindings) { + return false; + } + return clause.namedBindings.kind == ts.SyntaxKind.NamedImports; + }) + .map((node: ts.ImportDeclaration) => { + // Return the `{ ... }` list of the named import. + return (node.importClause as ts.ImportClause).namedBindings as ts.NamedImports; + }); + + if (maybeImports.length) { + // There's an `import {A, B, C} from 'modulePath'`. + // Find if it's in either imports. If so, just return; nothing to do. + const hasImportAlready = maybeImports.some((node: ts.NamedImports) => { + return node.elements.some((element: ts.ImportSpecifier) => { + return element.name.text == symbolName; + }); + }); + if (hasImportAlready) { + return; + } + // Just pick the first one and insert at the end of its identifier list. + this.appendAfter(maybeImports[0].elements[maybeImports[0].elements.length - 1], + `, ${symbolName}`); + } else { + // Find the last import and insert after. + this.appendAfter(allImports[allImports.length - 1], + `import {${symbolName}} from '${modulePath}';`); + } + } + + removeNode(node: ts.Node) { + this._sourceString.remove(node.getStart(this._sourceFile), node.getEnd()); + this._changed = true; + } + + replaceNode(node: ts.Node, replacement: string) { + let replaceSymbolName: boolean = node.kind === ts.SyntaxKind.Identifier; + this._sourceString.overwrite(node.getStart(this._sourceFile), + node.getEnd(), + replacement, + replaceSymbolName); + this._changed = true; + } + + sourceMatch(re: RegExp) { + return this._sourceText.match(re) !== null; + } + + transpile(compilerOptions: ts.CompilerOptions): TranspileOutput { + // const basePath = path.resolve(path.dirname(tsConfigPath), + // tsConfig.config.compilerOptions.baseUrl || '.'); + compilerOptions = Object.assign({}, compilerOptions, { + sourceMap: true, + inlineSources: false, + inlineSourceMap: false, + sourceRoot: '' + }); + + const source = this.sourceText; + const result = ts.transpileModule(source, { + compilerOptions, + fileName: this._fileName + }); + + if (result.sourceMapText) { + const sourceMapJson = JSON.parse(result.sourceMapText); + sourceMapJson.sources = [ this._fileName ]; + + const consumer = new SourceMapConsumer(sourceMapJson); + const map = SourceMapGenerator.fromSourceMap(consumer); + if (this._changed) { + const sourceMap = this._sourceString.generateMap({ + file: path.basename(this._fileName.replace(/\.ts$/, '.js')), + source: this._fileName, + hires: true, + }); + map.applySourceMap(new SourceMapConsumer(sourceMap), this._fileName); + } + + const sourceMap = map.toJSON(); + sourceMap.sources = [ this._fileName ]; + sourceMap.file = path.basename(this._fileName, '.ts') + '.js'; + sourceMap.sourcesContent = [ this._sourceText ]; + + return { outputText: result.outputText, sourceMap }; + } else { + return { + outputText: result.outputText, + sourceMap: null + }; + } + } +} diff --git a/packages/webpack/src/webpack.ts b/packages/webpack/src/webpack.ts new file mode 100644 index 000000000000..0074bed42e12 --- /dev/null +++ b/packages/webpack/src/webpack.ts @@ -0,0 +1,25 @@ +// Declarations for (some) Webpack types. Only what's needed. + +export interface Request { + request?: Request; + relativePath: string; +} + +export interface Callback { + (err?: Error | null, result?: T): void; +} + +export interface ResolverCallback { + (request: Request, callback: Callback): void; +} + +export interface Tapable { + apply(plugin: ResolverPlugin): void; +} + +export interface ResolverPlugin extends Tapable { + plugin(source: string, cb: ResolverCallback): void; + doResolve(target: string, req: Request, desc: string, callback: Callback): void; + join(relativePath: string, innerRequest: Request): Request; +} +