diff --git a/packages/ngtools/webpack/src/angular_compiler_plugin.ts b/packages/ngtools/webpack/src/angular_compiler_plugin.ts index cd844b3fd2e5..d94bb047e2c7 100644 --- a/packages/ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/ngtools/webpack/src/angular_compiler_plugin.ts @@ -69,7 +69,7 @@ import { MESSAGE_KIND, UpdateMessage, } from './type_checker_messages'; -import { flattenArray, workaroundResolve } from './utils'; +import { flattenArray, forwardSlashPath, workaroundResolve } from './utils'; import { VirtualFileSystemDecorator, VirtualWatchFileSystemDecorator, @@ -163,7 +163,7 @@ export class AngularCompilerPlugin { throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); } // TS represents paths internally with '/' and expects the tsconfig path to be in this format - this._tsConfigPath = options.tsConfigPath.replace(/\\/g, '/'); + this._tsConfigPath = forwardSlashPath(options.tsConfigPath); // Check the base path. const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath); @@ -482,7 +482,7 @@ export class AngularCompilerPlugin { return; } - const lazyRouteTSFile = discoveredLazyRoutes[lazyRouteKey].replace(/\\/g, '/'); + const lazyRouteTSFile = forwardSlashPath(discoveredLazyRoutes[lazyRouteKey]); let modulePath: string, moduleKey: string; if (this._useFactories) { @@ -928,7 +928,7 @@ export class AngularCompilerPlugin { // Import ngfactory in loadChildren import syntax if (this._useFactories) { // Only transform imports to use factories with View Engine. - this._transformers.push(importFactory(msg => this._warnings.push(msg))); + this._transformers.push(importFactory(msg => this._warnings.push(msg), getTypeChecker)); } } diff --git a/packages/ngtools/webpack/src/lazy_routes.ts b/packages/ngtools/webpack/src/lazy_routes.ts index dc32ebcea8c5..737b6c105d30 100644 --- a/packages/ngtools/webpack/src/lazy_routes.ts +++ b/packages/ngtools/webpack/src/lazy_routes.ts @@ -8,6 +8,7 @@ import { dirname, join } from 'path'; import * as ts from 'typescript'; import { findAstNodes, resolve } from './refactor'; +import { forwardSlashPath } from './utils'; function _getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null { @@ -38,7 +39,7 @@ export function findLazyRoutes( } compilerOptions = program.getCompilerOptions(); } - const fileName = resolve(filePath, host, compilerOptions).replace(/\\/g, '/'); + const fileName = forwardSlashPath(resolve(filePath, host, compilerOptions)); let sourceFile: ts.SourceFile | undefined; if (program) { sourceFile = program.getSourceFile(fileName); diff --git a/packages/ngtools/webpack/src/refactor.ts b/packages/ngtools/webpack/src/refactor.ts index 634117268de9..f9c984f5d884 100644 --- a/packages/ngtools/webpack/src/refactor.ts +++ b/packages/ngtools/webpack/src/refactor.ts @@ -7,6 +7,7 @@ */ import * as path from 'path'; import * as ts from 'typescript'; +import { forwardSlashPath } from './utils'; /** @@ -95,7 +96,7 @@ export class TypeScriptFileRefactor { let sourceFile: ts.SourceFile | null = null; if (_program) { - fileName = resolve(fileName, _host, _program.getCompilerOptions()).replace(/\\/g, '/'); + fileName = forwardSlashPath(resolve(fileName, _host, _program.getCompilerOptions())); this._fileName = fileName; if (source) { diff --git a/packages/ngtools/webpack/src/transformers/ast_helpers.ts b/packages/ngtools/webpack/src/transformers/ast_helpers.ts index 1d3beb7a3336..7ef8ec366a5b 100644 --- a/packages/ngtools/webpack/src/transformers/ast_helpers.ts +++ b/packages/ngtools/webpack/src/transformers/ast_helpers.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ import { virtualFs } from '@angular-devkit/core'; +import { readFileSync, readdirSync } from 'fs'; +import { dirname, join } from 'path'; import * as ts from 'typescript'; import { WebpackCompilerHost } from '../compiler_host'; @@ -48,14 +50,20 @@ export function getLastNode(sourceFile: ts.SourceFile): ts.Node | null { // Test transform helpers. const basePath = '/project/src/'; const fileName = basePath + 'test-file.ts'; +const tsLibFiles = loadTsLibFiles(); -export function createTypescriptContext(content: string, additionalFiles?: Record) { +export function createTypescriptContext( + content: string, + additionalFiles?: Record, + useLibs = false, +) { // Set compiler options. const compilerOptions: ts.CompilerOptions = { - noEmitOnError: false, + noEmitOnError: useLibs, allowJs: true, newLine: ts.NewLineKind.LineFeed, moduleResolution: ts.ModuleResolutionKind.NodeJs, + module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext, skipLibCheck: true, sourceMap: false, @@ -73,6 +81,15 @@ export function createTypescriptContext(content: string, additionalFiles?: Recor // Add a dummy file to host content. compilerHost.writeFile(fileName, content, false); + if (useLibs) { + // Write the default libs. + // These are needed for tests that use import(), because it relies on a Promise being there. + const compilerLibFolder = dirname(compilerHost.getDefaultLibFileName(compilerOptions)); + for (const [k, v] of Object.entries(tsLibFiles)) { + compilerHost.writeFile(join(compilerLibFolder, k), v, false); + } + } + if (additionalFiles) { for (const key in additionalFiles) { compilerHost.writeFile(basePath + key, additionalFiles[key], false); @@ -106,13 +123,27 @@ export function transformTypescript( undefined, undefined, undefined, undefined, { before: transformers }, ); - // Log diagnostics if emit wasn't successfull. + // Throw error with diagnostics if emit wasn't successfull. if (emitSkipped) { - console.error(diagnostics); - - return null; + throw new Error(ts.formatDiagnostics(diagnostics, compilerHost)); } // Return the transpiled js. return compilerHost.readFile(fileName.replace(/\.tsx?$/, '.js')); } + +function loadTsLibFiles() { + const libFolderPath = dirname(require.resolve('typescript/lib/lib.d.ts')); + const libFolderFiles = readdirSync(libFolderPath); + const libFileNames = libFolderFiles.filter(f => f.startsWith('lib.') && f.endsWith('.d.ts')); + + // Return a map of the lib names to their content. + return libFileNames.reduce( + (map, f) => { + map[f] = readFileSync(join(libFolderPath, f), 'utf-8'); + + return map; + }, + {} as { [k: string]: string }, + ); +} diff --git a/packages/ngtools/webpack/src/transformers/export_lazy_module_map.ts b/packages/ngtools/webpack/src/transformers/export_lazy_module_map.ts index 9cfc9be42db7..9898dc20b4a4 100644 --- a/packages/ngtools/webpack/src/transformers/export_lazy_module_map.ts +++ b/packages/ngtools/webpack/src/transformers/export_lazy_module_map.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as ts from 'typescript'; import { LazyRouteMap } from '../lazy_routes'; +import { forwardSlashPath } from '../utils'; import { getFirstNode, getLastNode } from './ast_helpers'; import { AddNodeOperation, StandardTransform, TransformOperation } from './interfaces'; import { makeTransform } from './make_transform'; @@ -46,7 +47,7 @@ export function exportLazyModuleMap( return; } - let relativePath = path.relative(dirName, modulePath).replace(/\\/g, '/'); + let relativePath = forwardSlashPath(path.relative(dirName, modulePath)); if (!(relativePath.startsWith('./') || relativePath.startsWith('../'))) { // 'a/b/c' is a relative path for Node but an absolute path for TS, so we must convert it. relativePath = `./${relativePath}`; diff --git a/packages/ngtools/webpack/src/transformers/export_ngfactory.ts b/packages/ngtools/webpack/src/transformers/export_ngfactory.ts index 04a8afd1bcce..6a5b13ee0426 100644 --- a/packages/ngtools/webpack/src/transformers/export_ngfactory.ts +++ b/packages/ngtools/webpack/src/transformers/export_ngfactory.ts @@ -7,6 +7,7 @@ */ import { dirname, relative } from 'path'; import * as ts from 'typescript'; +import { forwardSlashPath } from '../utils'; import { collectDeepNodes, getFirstNode } from './ast_helpers'; import { AddNodeOperation, StandardTransform, TransformOperation } from './interfaces'; import { makeTransform } from './make_transform'; @@ -35,7 +36,7 @@ export function exportNgFactory( } const relativeEntryModulePath = relative(dirname(sourceFile.fileName), entryModule.path); - const normalizedEntryModulePath = `./${relativeEntryModulePath}`.replace(/\\/g, '/'); + const normalizedEntryModulePath = forwardSlashPath(`./${relativeEntryModulePath}`); // Get the module path from the import. entryModuleIdentifiers.forEach((entryModuleIdentifier) => { diff --git a/packages/ngtools/webpack/src/transformers/import_factory.ts b/packages/ngtools/webpack/src/transformers/import_factory.ts index 3750f7b9c0fe..c1a5b3b7a477 100644 --- a/packages/ngtools/webpack/src/transformers/import_factory.ts +++ b/packages/ngtools/webpack/src/transformers/import_factory.ts @@ -5,7 +5,9 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import { dirname, relative } from 'path'; import * as ts from 'typescript'; +import { forwardSlashPath } from '../utils'; /** @@ -51,6 +53,7 @@ import * as ts from 'typescript'; export function importFactory( warningCb: (warning: string) => void, + getTypeChecker: () => ts.TypeChecker, ): ts.TransformerFactory { return (context: ts.TransformationContext) => { // TODO(filipesilva): change the link to https://angular.io/guide/ivy once it is out. @@ -59,9 +62,9 @@ export function importFactory( Found 'loadChildren' with a non-string syntax in ${sourceFile.fileName} but could not transform it. Make sure it matches the format below: -loadChildren: () => import('IMPORT_STRING').then(m => m.EXPORT_NAME) +loadChildren: () => import('IMPORT_STRING').then(M => M.EXPORT_NAME) -Please note that only IMPORT_STRING and EXPORT_NAME can be replaced in this format. +Please note that only IMPORT_STRING, M, and EXPORT_NAME can be replaced in this format. Visit https://next.angular.io/guide/ivy for more information on using Ivy. `; @@ -69,7 +72,7 @@ Visit https://next.angular.io/guide/ivy for more information on using Ivy. const emitWarning = () => warningCb(warning); const visitVariableStatement: ts.Visitor = (node: ts.Node) => { if (ts.isVariableDeclaration(node)) { - return replaceImport(node, context, emitWarning); + return replaceImport(node, context, emitWarning, sourceFile.fileName, getTypeChecker()); } return ts.visitEachChild(node, visitVariableStatement, context); @@ -95,16 +98,17 @@ function replaceImport( node: ts.VariableDeclaration, context: ts.TransformationContext, emitWarning: () => void, + fileName: string, + typeChecker: ts.TypeChecker, ): ts.Node { // This ONLY matches the original source code format below: - // loadChildren: () => import('IMPORT_STRING').then(m => m.EXPORT_NAME) + // loadChildren: () => import('IMPORT_STRING').then(M => M.EXPORT_NAME) // And expects that source code to be transformed by NGC (see comment for importFactory). // It will not match nor alter variations, for instance: // - not using arrow functions - // - not using `m` as the module argument // - using `await` instead of `then` // - using a default export (https://github.com/angular/angular/issues/11402) - // The only parts that can change are the ones in caps: IMPORT_STRING and EXPORT_NAME. + // The only parts that can change are the ones in caps: IMPORT_STRING, M and EXPORT_NAME. // Exit early if the structure is not what we expect. @@ -158,16 +162,20 @@ function replaceImport( return node; } + // Now that we know it's both `ɵ0` (generated by NGC) and a `import()`, start emitting a warning + // if the structure isn't as expected to help users identify unusable syntax. + const warnAndBail = () => { + emitWarning(); + + return node; + }; + // ɵ0 = () => import('IMPORT_STRING').then(m => m.EXPORT_NAME) if (!( topArrowFnBody.arguments.length === 1 && ts.isArrowFunction(topArrowFnBody.arguments[0]) )) { - // Now that we know it's both `ɵ0` (generated by NGC) and a `import()`, start emitting a warning - // if the structure isn't as expected to help users identify unusable syntax. - emitWarning(); - - return node; + return warnAndBail(); } const thenArrowFn = topArrowFnBody.arguments[0] as ts.ArrowFunction; @@ -176,20 +184,36 @@ function replaceImport( && ts.isPropertyAccessExpression(thenArrowFn.body) && ts.isIdentifier(thenArrowFn.body.name) )) { - emitWarning(); - - return node; + return warnAndBail(); } // At this point we know what are the nodes we need to replace. - const importStringLit = importCall.arguments[0] as ts.StringLiteral; const exportNameId = thenArrowFn.body.name; + const importStringLit = importCall.arguments[0] as ts.StringLiteral; + + // Try to resolve the import. It might be a reexport from somewhere and the ngfactory will only + // be present next to the original module. + const exportedSymbol = typeChecker.getSymbolAtLocation(exportNameId); + if (!exportedSymbol) { + return warnAndBail(); + } + const exportedSymbolDecl = exportedSymbol.getDeclarations(); + if (!exportedSymbolDecl || exportedSymbolDecl.length === 0) { + return warnAndBail(); + } + + // Get the relative path from the containing module to the imported module. + const relativePath = relative(dirname(fileName), exportedSymbolDecl[0].getSourceFile().fileName); + + // node's `relative` call doesn't actually add `./` so we add it here. + // Also replace the 'ts' extension with just 'ngfactory'. + const newImportString = `./${forwardSlashPath(relativePath)}`.replace(/ts$/, 'ngfactory'); // The easiest way to alter them is with a simple visitor. const replacementVisitor: ts.Visitor = (node: ts.Node) => { if (node === importStringLit) { // Transform the import string. - return ts.createStringLiteral(importStringLit.text + '.ngfactory'); + return ts.createStringLiteral(newImportString); } else if (node === exportNameId) { // Transform the export name. return ts.createIdentifier(exportNameId.text + 'NgFactory'); diff --git a/packages/ngtools/webpack/src/transformers/import_factory_spec.ts b/packages/ngtools/webpack/src/transformers/import_factory_spec.ts index 8e199c92b45b..7ba5e3c67fa8 100644 --- a/packages/ngtools/webpack/src/transformers/import_factory_spec.ts +++ b/packages/ngtools/webpack/src/transformers/import_factory_spec.ts @@ -6,12 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ import { tags } from '@angular-devkit/core'; -import { transformTypescript } from './ast_helpers'; +import { createTypescriptContext, transformTypescript } from './ast_helpers'; import { importFactory } from './import_factory'; describe('@ngtools/webpack transformers', () => { describe('import_factory', () => { it('should support arrow functions', () => { + const additionalFiles: Record = { + 'lazy/lazy.module.ts': ` + export const LazyModule = {}; + `, + }; const input = tags.stripIndent` const ɵ0 = () => import('./lazy/lazy.module').then(m => m.LazyModule); const routes = [{ @@ -28,14 +33,20 @@ describe('@ngtools/webpack transformers', () => { `; let warningCalled = false; - const transformer = importFactory(() => warningCalled = true); - const result = transformTypescript(input, [transformer]); + const { program, compilerHost } = createTypescriptContext(input, additionalFiles, true); + const transformer = importFactory(() => warningCalled = true, () => program.getTypeChecker()); + const result = transformTypescript(undefined, [transformer], program, compilerHost); expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); expect(warningCalled).toBeFalsy(); }); it('should not transform if the format is different than expected', () => { + const additionalFiles: Record = { + 'lazy/lazy.module.ts': ` + export const LazyModule = {}; + `, + }; const input = tags.stripIndent` const ɵ0 = () => import('./lazy/lazy.module').then(function (m) { return m.LazyModule; }); const routes = [{ @@ -45,11 +56,46 @@ describe('@ngtools/webpack transformers', () => { `; let warningCalled = false; - const transformer = importFactory(() => warningCalled = true); - const result = transformTypescript(input, [transformer]); + const { program, compilerHost } = createTypescriptContext(input, additionalFiles, true); + const transformer = importFactory(() => warningCalled = true, () => program.getTypeChecker()); + const result = transformTypescript(undefined, [transformer], program, compilerHost); expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${input}`); expect(warningCalled).toBeTruthy(); }); + + it('should support resolving reexports', () => { + const additionalFiles: Record = { + 'shared/index.ts': ` + export * from './path/to/lazy/lazy.module'; + `, + 'shared/path/to/lazy/lazy.module.ts': ` + export const LazyModule = {}; + `, + }; + const input = tags.stripIndent` + const ɵ0 = () => import('./shared').then(m => m.LazyModule); + const routes = [{ + path: 'lazy', + loadChildren: ɵ0 + }]; + `; + + // tslint:disable: max-line-length + const output = tags.stripIndent` + const ɵ0 = () => import("./shared/path/to/lazy/lazy.module.ngfactory").then(m => m.LazyModuleNgFactory); + const routes = [{ + path: 'lazy', + loadChildren: ɵ0 + }]; + `; + // tslint:enable: max-line-length + + const { program, compilerHost } = createTypescriptContext(input, additionalFiles, true); + const transformer = importFactory(() => { }, () => program.getTypeChecker()); + const result = transformTypescript(undefined, [transformer], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); }); }); diff --git a/packages/ngtools/webpack/src/transformers/replace_bootstrap.ts b/packages/ngtools/webpack/src/transformers/replace_bootstrap.ts index 37aeebcbbdb6..0153c8740d2d 100644 --- a/packages/ngtools/webpack/src/transformers/replace_bootstrap.ts +++ b/packages/ngtools/webpack/src/transformers/replace_bootstrap.ts @@ -7,6 +7,7 @@ */ import { dirname, relative } from 'path'; import * as ts from 'typescript'; +import { forwardSlashPath } from '../utils'; import { collectDeepNodes } from './ast_helpers'; import { insertStarImport } from './insert_import'; import { ReplaceNodeOperation, StandardTransform, TransformOperation } from './interfaces'; @@ -79,7 +80,7 @@ export function replaceBootstrap( // Add the transform operations. const relativeEntryModulePath = relative(dirname(sourceFile.fileName), entryModule.path); let className = entryModule.className; - let modulePath = `./${relativeEntryModulePath}`.replace(/\\/g, '/'); + let modulePath = forwardSlashPath(`./${relativeEntryModulePath}`); let bootstrapIdentifier = 'bootstrapModule'; if (useFactories) { diff --git a/packages/ngtools/webpack/src/transformers/replace_server_bootstrap.ts b/packages/ngtools/webpack/src/transformers/replace_server_bootstrap.ts index 483080bf4a6b..e9fcee0b748e 100644 --- a/packages/ngtools/webpack/src/transformers/replace_server_bootstrap.ts +++ b/packages/ngtools/webpack/src/transformers/replace_server_bootstrap.ts @@ -7,6 +7,7 @@ */ import { dirname, relative } from 'path'; import * as ts from 'typescript'; +import { forwardSlashPath } from '../utils'; import { collectDeepNodes } from './ast_helpers'; import { insertStarImport } from './insert_import'; import { ReplaceNodeOperation, StandardTransform, TransformOperation } from './interfaces'; @@ -37,7 +38,7 @@ export function replaceServerBootstrap( } const relativeEntryModulePath = relative(dirname(sourceFile.fileName), entryModule.path); - const normalizedEntryModulePath = `./${relativeEntryModulePath}`.replace(/\\/g, '/'); + const normalizedEntryModulePath = forwardSlashPath(`./${relativeEntryModulePath}`); const factoryClassName = entryModule.className + 'NgFactory'; const factoryModulePath = normalizedEntryModulePath + '.ngfactory'; diff --git a/packages/ngtools/webpack/src/utils.ts b/packages/ngtools/webpack/src/utils.ts index 08c2e0a67010..db4336ada4b2 100644 --- a/packages/ngtools/webpack/src/utils.ts +++ b/packages/ngtools/webpack/src/utils.ts @@ -12,9 +12,14 @@ import { Path, getSystemPath, normalize } from '@angular-devkit/core'; // To work around this we must provide the same path format as TS internally uses in // the SourceFile paths. export function workaroundResolve(path: Path | string) { - return getSystemPath(normalize(path)).replace(/\\/g, '/'); + return forwardSlashPath(getSystemPath(normalize(path))); } export function flattenArray(value: Array): T[] { return [].concat.apply([], value); } + +// TS represents paths internally with '/' and expects paths to be in this format. +export function forwardSlashPath(path: string) { + return path.replace(/\\/g, '/'); +}