diff --git a/packages/webpack/package.json b/packages/webpack/package.json new file mode 100644 index 000000000000..e77421fa4c01 --- /dev/null +++ b/packages/webpack/package.json @@ -0,0 +1,21 @@ +{ + "name": "@angular-cli/webpack", + "version": "1.0.0", + "description": "", + "main": "lib/index.js", + "scripts": { + "build:main": "tsc", + "test": "echo \"Error: no test specified\" && exit 0" + }, + "author": "Rob Wormald ", + "license": "MIT", + "devDependencies": { + "@types/node": "^6.0.39", + "node-sass": "^3.8.0", + "sass-loader": "^4.0.1", + "typescript": "2.0.2", + "webpack": "^2.1.0-beta.21", + "@angular/compiler-cli": "^0.6.0", + "@angular/core": "^2.0.0" + } +} diff --git a/packages/webpack/src/codegen.ts b/packages/webpack/src/codegen.ts new file mode 100644 index 000000000000..a5434965444e --- /dev/null +++ b/packages/webpack/src/codegen.ts @@ -0,0 +1,52 @@ +import {Observable} from 'rxjs/Rx' +import * as ts from 'typescript' +import * as ngCompiler from '@angular/compiler-cli' +import * as tscWrapped from '@angular/tsc-wrapped' +import * as tsc from '@angular/tsc-wrapped/src/tsc' +import * as path from 'path' +import * as fs from 'fs' + +export interface CodeGenOptions { + program: ts.Program; + ngcOptions: any; + i18nOptions: any; + resourceLoader?:any; //impl of ResourceLoader + compilerHost: any; +} + +function _readConfig(tsConfigPath){ + let {config, error} = ts.readConfigFile(tsConfigPath, ts.sys.readFile); + if(error){ + throw error; + } + return config; + } + +export function createCodeGenerator({ngcOptions, i18nOptions, resourceLoader, compilerHost}){ + + return program => new Observable<{fileName:string, sourceText: string}>(codegenOutput => { + //emit files from observable monkeypatch + const writeFile = compilerHost.writeFile; + + compilerHost.writeFile = (fileName, sourceText) => { + writeFile(fileName, sourceText); + codegenOutput.next({fileName, sourceText}); + }; + const codeGenerator = ngCompiler.CodeGenerator.create( + ngcOptions, + i18nOptions, + program, + compilerHost, + undefined, //TODO: hook in reflector host context + resourceLoader + ); + + codeGenerator + .codegen().then( + () => { + codegenOutput.complete(); + }, + err => codegenOutput.error(err) + ); + }); +} diff --git a/packages/webpack/src/compiler.ts b/packages/webpack/src/compiler.ts new file mode 100644 index 000000000000..59faca8a533c --- /dev/null +++ b/packages/webpack/src/compiler.ts @@ -0,0 +1,17 @@ +import * as ngCompiler from '@angular/compiler-cli' +import * as tscWrapped from '@angular/tsc-wrapped/src/compiler_host' +import * as ts from 'typescript' + + +export class NgcWebpackCompilerHost extends tscWrapped.DelegatingHost { + fileCache:Map = new Map () + constructor(delegate: ts.CompilerHost){ + super(delegate); + + } +} + +export function createCompilerHost(tsConfig){ + const delegateHost = ts.createCompilerHost(tsConfig.compilerOptions); + return new NgcWebpackCompilerHost(delegateHost); +} diff --git a/packages/webpack/src/index.ts b/packages/webpack/src/index.ts new file mode 100644 index 000000000000..19b711d39489 --- /dev/null +++ b/packages/webpack/src/index.ts @@ -0,0 +1,3 @@ +export * from './codegen' +export * from './plugin' +export {ngcLoader as default} from './loader' diff --git a/packages/webpack/src/loader.ts b/packages/webpack/src/loader.ts new file mode 100644 index 000000000000..6aac1a33d3ba --- /dev/null +++ b/packages/webpack/src/loader.ts @@ -0,0 +1,9 @@ +//super simple TS transpiler loader for testing / isolated usage. does not type check! +import * as path from 'path' +import * as fs from 'fs' +import * as ts from 'typescript' + +export function ngcLoader(sourceFile){ + + return ts.transpileModule(sourceFile, {compilerOptions: {target: ts.ScriptTarget.ES5, module: ts.ModuleKind.ES2015}}).outputText; +} diff --git a/packages/webpack/src/plugin.ts b/packages/webpack/src/plugin.ts new file mode 100644 index 000000000000..70018d96502f --- /dev/null +++ b/packages/webpack/src/plugin.ts @@ -0,0 +1,262 @@ +//@angular/webpack plugin main +import 'reflect-metadata'; +import { ReflectiveInjector, OpaqueToken, NgModule } from '@angular/core' +import * as ts from 'typescript' +import * as ngCompiler from '@angular/compiler-cli' +import * as tscWrapped from '@angular/tsc-wrapped' +import * as tsc from '@angular/tsc-wrapped/src/tsc' +import * as path from 'path' +import * as fs from 'fs' + +import { WebpackResourceLoader } from './resource_loader' +import { createCodeGenerator } from './codegen' +import { createCompilerHost } from './compiler' +import { createResolveDependenciesFromContextMap } from './utils' + +function debug(...args) { + console.log.apply(console, ['ngc:', ...args]); +} + +/** + * Option Constants + */ +export type NGC_COMPILER_MODE = 'aot' | 'jit' + +export interface AngularWebpackPluginOptions { + tsconfigPath?: string; + compilerMode?: NGC_COMPILER_MODE; + providers?: any[]; + entryModule: string; +} + +const noTransformExtensions = ['.html', '.css'] + +export class NgcWebpackPlugin { + projectPath: string; + rootModule: string; + rootModuleName: string; + fileCache: any; + codeGeneratorFactory: any; + reflector: ngCompiler.StaticReflector; + reflectorHost: ngCompiler.ReflectorHost; + program: ts.Program; + private injector: ReflectiveInjector; + compilerHost: ts.CompilerHost; + compilerOptions: ts.CompilerOptions; + angularCompilerOptions: any; + files: any[]; + contextRegex = /.*/; + lazyRoutes: any; + + constructor(public options: any = {}) { + const tsConfig = tsc.tsc.readConfiguration(options.project, options.baseDir); + this.compilerOptions = tsConfig.parsed.options; + this.files = tsConfig.parsed.fileNames; + this.angularCompilerOptions = tsConfig.ngOptions; + this.angularCompilerOptions.basePath = options.baseDir || process.cwd(); + + if (!this.angularCompilerOptions) { + //TODO:robwormald more validation here + throw new Error(`"angularCompilerOptions" is not set in your tsconfig file!`); + } + const [rootModule, rootNgModule] = this.angularCompilerOptions.entryModule.split('#'); + + this.projectPath = options.project; + this.rootModule = rootModule; + this.rootModuleName = rootNgModule; + + this.compilerHost = ts.createCompilerHost(this.compilerOptions, true); + this.program = ts.createProgram(this.files, this.compilerOptions, this.compilerHost); + + //TODO: pick this up from ngOptions + const i18nOptions = { + i18nFile: undefined, + i18nFormat: undefined, + locale: undefined, + basePath: options.baseDir + } + + this.reflectorHost = new ngCompiler.ReflectorHost(this.program, this.compilerHost, tsConfig.ngOptions); + this.reflector = new ngCompiler.StaticReflector(this.reflectorHost); + this.codeGeneratorFactory = createCodeGenerator({ + ngcOptions: tsConfig.ngOptions, + i18nOptions, + compilerHost: this.compilerHost, + resourceLoader: undefined //TODO: handle styles/templatess here. + }); + } + + //registration hook for webpack plugin + apply(compiler) { + compiler.plugin('context-module-factory', (cmf) => this._resolveImports(cmf)); + compiler.plugin('make', (compiler, cb) => this._make(compiler, cb)); + + } + + private _resolveImports(contextModuleFactory){ + const plugin = this; + contextModuleFactory.plugin('before-resolve',(request, callback) => plugin._beforeResolveImports(request, callback)); + contextModuleFactory.plugin('after-resolve', (request, callback) => plugin._afterResolveImports(request, callback)); + return contextModuleFactory; +} + + private _beforeResolveImports(result, callback){ + if(!result) return callback(); + if(this.contextRegex.test(result.request)){ + result.request = path.resolve(process.cwd(), 'app/ngfactory'); + result.recursive = true; + result.dependencies.forEach(d => d.critical = false); + + } + return callback(null, result); + } + + private _afterResolveImports(result, callback){ + if(!result) return callback(); + if(this.contextRegex.test(result.resource)) { + result.resource = path.resolve(process.cwd(), this.angularCompilerOptions.genDir + '/app'); + result.recursive = true; + result.dependencies.forEach(d => d.critical = false); + result.resolveDependencies = createResolveDependenciesFromContextMap((fs, cb) => cb(null, this.lazyRoutes)); + } + return callback(null, result); + } + + private _make(compilation, cb) { + + const rootModulePath = this.rootModule + '.ts'; + const rootModuleName = this.rootModuleName; + + //process the lazy routes + const lazyModules = this._processNgModule("./" + rootModulePath, rootModuleName, "./" + rootModulePath).map(moduleKey => { + return moduleKey.split('#')[0]; + }) + const program = ts.createProgram(this.files, this.compilerOptions, this.compilerHost) + + this.codeGeneratorFactory(program) + .reduce((files, generatedFile) => files.concat(generatedFile), []) + .do(files => debug(`generated ${files.length} files`)) + .map(allGeneratedCode => { + return lazyModules.reduce((lazyRoutes, lazyModule) => { + const lazyPath = lazyModule + '.ngfactory'; + lazyRoutes[lazyPath] = path.join(path.resolve(process.cwd(), this.angularCompilerOptions.genDir + '/app'), lazyModule + '.ngfactory.ts'); + return lazyRoutes; + }, {}); + }) + .do(lazyRouteConfig => this.lazyRoutes = lazyRouteConfig) + .forEach(v => console.log('codegen complete')) + .then( + _ => cb(), + err => cb(err) + ); + } + + private _processNgModule(mod: string, ngModuleName: string, containingFile: string): string[] { + const staticSymbol = this.reflectorHost.findDeclaration(mod, ngModuleName, containingFile); + const entryNgModuleMetadata = this.getNgModuleMetadata(staticSymbol); + const loadChildren = this.extractLoadChildren(entryNgModuleMetadata); + + const moduleChildren = loadChildren.reduce((res, lc) => { + const [childMoudle, childNgModule] = lc.split('#'); + + //TODO calculate a different containingFile for relative paths + + const children = this._processNgModule(childMoudle, childNgModule, containingFile); + return res.concat(children); + }, loadChildren); + + return moduleChildren; + } + + private _convertToModule(s: string): string { + // TODO. Currently we assume that the string is the same as the import + return s; + } + + private _resolve(compiler, resolver, requestObject, cb) { + cb() + } + + + private _run(compiler, cb) { + cb() + } + + private _watch(watcher, cb) { + this._make(watcher.compiler, cb); + } + + private _readConfig(tsConfigPath): any { + let {config, error} = ts.readConfigFile(tsConfigPath, ts.sys.readFile); + if (error) { + throw error; + } + return ts.parseJsonConfigFileContent(config, new ParseConfigHost(), ""); + } + + private getNgModuleMetadata(staticSymbol: ngCompiler.StaticSymbol) { + const ngModules = this.reflector.annotations(staticSymbol).filter(s => s instanceof NgModule); + if (ngModules.length === 0) { + throw new Error(`${staticSymbol.name} is not an NgModule`); + } + return ngModules[0]; + } + + private extractLoadChildren(ngModuleDecorator: any): any[] { + const routes = ngModuleDecorator.imports.reduce((mem, m) => { + return mem.concat(this.collectRoutes(m.providers)); + }, this.collectRoutes(ngModuleDecorator.providers)); + return this.collectLoadChildren(routes); + } + + private collectRoutes(providers: any[]): any[] { + if (!providers) return []; + const ROUTES = this.reflectorHost.findDeclaration("@angular/router/src/router_config_loader", "ROUTES", undefined); + return providers.reduce((m, p) => { + if (p.provide === ROUTES) { + return m.concat(p.useValue); + + } else if (Array.isArray(p)) { + return m.concat(this.collectRoutes(p)); + + } else { + return m; + } + }, []); + } + + private collectLoadChildren(routes: any[]): any[] { + if (!routes) return []; + return routes.reduce((m, r) => { + if (r.loadChildren) { + return m.concat([r.loadChildren]); + + } else if (Array.isArray(r)) { + return m.concat(this.collectLoadChildren(r)); + + } else if (r.children) { + return m.concat(this.collectLoadChildren(r.children)); + + } else { + return m; + } + }, []); + } +} + +class ParseConfigHost implements ts.ParseConfigHost { + useCaseSensitiveFileNames: boolean = true; + + readDirectory(rootDir: string, extensions: string[], excludes: string[], includes: string[]): string[] { + return ts.sys.readDirectory(rootDir, extensions, excludes, includes); + } + /** + * Gets a value indicating whether the specified path exists and is a file. + * @param path The path to test. + */ + fileExists(path: string): boolean { + return ts.sys.fileExists(path); + } +} + + diff --git a/packages/webpack/src/resource_loader.ts b/packages/webpack/src/resource_loader.ts new file mode 100644 index 000000000000..7ca5af1e8ea8 --- /dev/null +++ b/packages/webpack/src/resource_loader.ts @@ -0,0 +1,17 @@ +//stub for ng2 compiler's ResourceLoader, responsible for fetching HTML and CSS files into the AoT compiler +//TODO: integrate this with webpack loaders for less/sass + +import { ResourceLoader } from '@angular/compiler' +import * as fs from 'fs' + +export class WebpackResourceLoader implements ResourceLoader { + constructor(private compiler){} + //called by AOT compiler to retrieve files from disk + get(filePath){ + return Promise.resolve(fs.readFileSync(filePath, 'utf-8')) + .then(resource => this.transform(resource)); + } + transform(resource:string):string { + return resource; + } +} diff --git a/packages/webpack/src/utils.ts b/packages/webpack/src/utils.ts new file mode 100644 index 000000000000..5f829dec27e0 --- /dev/null +++ b/packages/webpack/src/utils.ts @@ -0,0 +1,14 @@ +var ContextElementDependency = require('../test/node_modules/webpack/lib/dependencies/ContextElementDependency'); + +export function createResolveDependenciesFromContextMap(createContextMap) { + return function resolveDependenciesFromContextMap(fs, resource, recursive, regExp, callback) { + createContextMap(fs, function(err, map) { + if(err) return callback(err); + var dependencies = Object.keys(map).map(function(key) { + let dep = new ContextElementDependency(map[key], key); + return dep; + }); + callback(null, dependencies); + }); + } +}; diff --git a/packages/webpack/test/app/app.component.css b/packages/webpack/test/app/app.component.css new file mode 100644 index 000000000000..5cde7b922336 --- /dev/null +++ b/packages/webpack/test/app/app.component.css @@ -0,0 +1,3 @@ +:host { + background-color: blue; +} diff --git a/packages/webpack/test/app/app.component.html b/packages/webpack/test/app/app.component.html new file mode 100644 index 000000000000..5a532db9308f --- /dev/null +++ b/packages/webpack/test/app/app.component.html @@ -0,0 +1,5 @@ +
+

hello world

+ lazy + +
diff --git a/packages/webpack/test/app/app.component.ts b/packages/webpack/test/app/app.component.ts new file mode 100644 index 000000000000..7c9e448c63b5 --- /dev/null +++ b/packages/webpack/test/app/app.component.ts @@ -0,0 +1,10 @@ +// Code generated by angular2-stress-test + +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] +}) +export class AppComponent { } diff --git a/packages/webpack/test/app/app.module.ts b/packages/webpack/test/app/app.module.ts new file mode 100644 index 000000000000..3ea663271500 --- /dev/null +++ b/packages/webpack/test/app/app.module.ts @@ -0,0 +1,29 @@ +// Code generated by angular2-stress-test + +import { NgModule, Component } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router' +import { AppComponent } from './app.component'; + +@Component({ + selector: 'home-view', + template: 'home!' +}) +export class HomeView {} + + +@NgModule({ + declarations: [ + AppComponent, + HomeView + ], + imports: [ + BrowserModule, + RouterModule.forRoot([ + {path: "lazy", loadChildren: "./lazy.module#LazyModule"}, + {path: '', component: HomeView} + ]) + ], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/packages/webpack/test/app/feature/feature.module.ts b/packages/webpack/test/app/feature/feature.module.ts new file mode 100644 index 000000000000..401c88e1851a --- /dev/null +++ b/packages/webpack/test/app/feature/feature.module.ts @@ -0,0 +1,20 @@ +import {NgModule, Component} from '@angular/core' +import {RouterModule} from '@angular/router' + +@Component({ + selector: 'feature-component', + template: 'foo.html' +}) +export class FeatureComponent {} + +@NgModule({ + declarations: [ + FeatureComponent + ], + imports: [ + RouterModule.forChild([ + { path: '', component: FeatureComponent} + ]) + ] +}) +export class FeatureModule {} diff --git a/packages/webpack/test/app/lazy.module.ts b/packages/webpack/test/app/lazy.module.ts new file mode 100644 index 000000000000..3d7d948a53db --- /dev/null +++ b/packages/webpack/test/app/lazy.module.ts @@ -0,0 +1,27 @@ +import {NgModule, Component} from '@angular/core' +import {RouterModule} from '@angular/router' +import {HttpModule, Http} from '@angular/http' + +@Component({ + selector: 'lazy-comp', + template: 'lazy!' +}) +export class LazyComponent {} + +@NgModule({ + imports: [ + RouterModule.forChild([ + {path: '', component: LazyComponent, pathMatch: 'full'}, + {path: 'feature', loadChildren: './feature/feature.module#FeatureModule'} + ]), + HttpModule + ], + declarations: [LazyComponent] +}) +export class LazyModule { + constructor(http:Http){ + + } +} + +export class SecondModule {} diff --git a/packages/webpack/test/app/main.aot.ts b/packages/webpack/test/app/main.aot.ts new file mode 100644 index 000000000000..eda497438185 --- /dev/null +++ b/packages/webpack/test/app/main.aot.ts @@ -0,0 +1,5 @@ +import 'core-js/es7/reflect' +import {platformBrowser} from '@angular/platform-browser' +import {AppModuleNgFactory} from './ngfactory/app/app.module.ngfactory' + +platformBrowser().bootstrapModuleFactory(AppModuleNgFactory); diff --git a/packages/webpack/test/app/main.jit.ts b/packages/webpack/test/app/main.jit.ts new file mode 100644 index 000000000000..8d037d7547e5 --- /dev/null +++ b/packages/webpack/test/app/main.jit.ts @@ -0,0 +1,5 @@ +import 'reflect-metadata' +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic' +import {AppModule} from './app.module' + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/packages/webpack/test/index.html b/packages/webpack/test/index.html new file mode 100644 index 000000000000..89fb0893c35d --- /dev/null +++ b/packages/webpack/test/index.html @@ -0,0 +1,12 @@ + + + + Document + + + + + + + + diff --git a/packages/webpack/test/package.json b/packages/webpack/test/package.json new file mode 100644 index 000000000000..144118852123 --- /dev/null +++ b/packages/webpack/test/package.json @@ -0,0 +1,31 @@ +{ + "name": "test", + "version": "1.0.0", + "description": "", + "main": "webpack.config.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "webpack-dev-server" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@angular/common": "^2.0.0", + "@angular/compiler": "^2.0.0", + "@angular/compiler-cli": "0.6.2", + "@angular/core": "^2.0.0", + "@angular/platform-browser": "^2.0.0", + "@angular/platform-browser-dynamic": "^2.0.0", + "@angular/platform-server": "^2.0.0", + "@angular/router": "^3.0.0", + "core-js": "^2.4.1", + "rxjs": "^5.0.0-beta.12", + "zone.js": "^0.6.21" + }, + "devDependencies": { + "performance-now": "^0.2.0", + "typescript": "2.0.2", + "webpack": "2.1.0-beta.22", + "webpack-dev-server": "^2.1.0-beta.5" + } +} diff --git a/packages/webpack/test/tsconfig.json b/packages/webpack/test/tsconfig.json new file mode 100644 index 000000000000..d85ce0dd9e43 --- /dev/null +++ b/packages/webpack/test/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "es2015", + "moduleResolution": "node", + "target": "es5", + "noImplicitAny": false, + "sourceMap": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": [ + "es2015", + "dom" + ], + "outDir": "lib", + "skipLibCheck": true, + "rootDir": "." + }, + + "include": [ + "app/**/*.ts" + ], + "angularCompilerOptions": { + "genDir": "./app/ngfactory", + "entryModule": "app/app.module#AppModule" + } +} diff --git a/packages/webpack/test/webpack.config.js b/packages/webpack/test/webpack.config.js new file mode 100644 index 000000000000..ca9f81e0ce41 --- /dev/null +++ b/packages/webpack/test/webpack.config.js @@ -0,0 +1,33 @@ +var NgcWebpackPlugin = require('../lib/plugin').NgcWebpackPlugin; +var path = require('path'); +module.exports = { + resolve: { + extensions: ['.scss', '.ts', '.js'] + // mainFields: [ 'browser', 'module', 'main'] + }, + entry: './app/main.aot.ts', + output: { + path: "./dist", + publicPath: 'dist/', + filename: "app.main.js" + }, + plugins: [ + new NgcWebpackPlugin({ + project: './tsconfig.json', + baseDir: path.resolve(__dirname, '') + }), + + ], + module: { + loaders: [ + { + test: /\.ts$/, + loader: '@angular-cli/webpack' + } + ] + + }, + devServer: { + historyApiFallback: true + } +} diff --git a/packages/webpack/tsconfig.json b/packages/webpack/tsconfig.json new file mode 100644 index 000000000000..f432e848a0f0 --- /dev/null +++ b/packages/webpack/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": [ + "es2015", + "dom" + ], + "module": "commonjs", + "target": "es5", + "outDir": "lib", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "declaration": true + }, + "files": [ + "src/index.ts" + ] +}