diff --git a/packages/ngtools/webpack/src/angular_compiler_plugin.ts b/packages/ngtools/webpack/src/angular_compiler_plugin.ts index 7689c4cf4a09..752a530e42d1 100644 --- a/packages/ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/ngtools/webpack/src/angular_compiler_plugin.ts @@ -817,7 +817,7 @@ export class AngularCompilerPlugin { if (this._JitMode) { // Replace resources in JIT. - this._transformers.push(replaceResources(isAppPath)); + this._transformers.push(replaceResources(isAppPath, getTypeChecker)); } else { // Remove unneeded angular decorators. this._transformers.push(removeDecorators(isAppPath, getTypeChecker)); @@ -1013,9 +1013,7 @@ export class AngularCompilerPlugin { .filter(x => x); const resourceImports = findResources(sourceFile) - .map((resourceReplacement) => resourceReplacement.resourcePaths) - .reduce((prev, curr) => prev.concat(curr), []) - .map((resourcePath) => resolve(dirname(resolvedFileName), normalize(resourcePath))); + .map(resourcePath => resolve(dirname(resolvedFileName), normalize(resourcePath))); // These paths are meant to be used by the loader so we must denormalize them. const uniqueDependencies = new Set([ diff --git a/packages/ngtools/webpack/src/transformers/find_resources.ts b/packages/ngtools/webpack/src/transformers/find_resources.ts new file mode 100644 index 000000000000..1158e4465fde --- /dev/null +++ b/packages/ngtools/webpack/src/transformers/find_resources.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 * as ts from 'typescript'; +import { collectDeepNodes } from './ast_helpers'; +import { getResourceUrl } from './replace_resources'; + +export function findResources(sourceFile: ts.SourceFile): string[] { + const resources: string[] = []; + const decorators = collectDeepNodes(sourceFile, ts.SyntaxKind.Decorator); + + for (const node of decorators) { + if (!ts.isCallExpression(node.expression)) { + continue; + } + + const decoratorFactory = node.expression; + const args = decoratorFactory.arguments; + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + // Unsupported component metadata + continue; + } + + ts.visitNodes( + (args[0] as ts.ObjectLiteralExpression).properties, + (node: ts.ObjectLiteralElementLike) => { + if (!ts.isPropertyAssignment(node) || ts.isComputedPropertyName(node.name)) { + return node; + } + + const name = node.name.text; + switch (name) { + case 'templateUrl': + const url = getResourceUrl(node.initializer); + + if (url) { + resources.push(url); + } + break; + + case 'styleUrls': + if (!ts.isArrayLiteralExpression(node.initializer)) { + return node; + } + + ts.visitNodes(node.initializer.elements, (node: ts.Expression) => { + const url = getResourceUrl(node); + + if (url) { + resources.push(url); + } + + return node; + }); + break; + } + + return node; + }, + ); + } + + return resources; +} diff --git a/packages/ngtools/webpack/src/transformers/find_resources_spec.ts b/packages/ngtools/webpack/src/transformers/find_resources_spec.ts new file mode 100644 index 000000000000..0e0cf9cbaaa8 --- /dev/null +++ b/packages/ngtools/webpack/src/transformers/find_resources_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 { tags } from '@angular-devkit/core'; // tslint:disable-line:no-implicit-dependencies +import * as ts from 'typescript'; +import { findResources } from './find_resources'; + + +describe('@ngtools/webpack transformers', () => { + describe('find_resources', () => { + it('should return resources', () => { + const input = tags.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 result = findResources(ts.createSourceFile('temp.ts', input, ts.ScriptTarget.ES2015)); + expect(result).toEqual([ + './app.component.html', + './app.component.css', + './app.component.2.css', + ]); + }); + + it('should not return resources if they are not in decorator', () => { + const input = tags.stripIndent` + const foo = { + templateUrl: './app.component.html', + styleUrls: ['./app.component.css', './app.component.2.css'] + } + `; + + const result = findResources(ts.createSourceFile('temp.ts', input, ts.ScriptTarget.ES2015)); + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/ngtools/webpack/src/transformers/index.ts b/packages/ngtools/webpack/src/transformers/index.ts index bb3d03dc92af..de51a382ba1d 100644 --- a/packages/ngtools/webpack/src/transformers/index.ts +++ b/packages/ngtools/webpack/src/transformers/index.ts @@ -17,3 +17,4 @@ export * from './export_lazy_module_map'; export * from './register_locale_data'; export * from './replace_resources'; export * from './remove_decorators'; +export * from './find_resources'; diff --git a/packages/ngtools/webpack/src/transformers/replace_resources.ts b/packages/ngtools/webpack/src/transformers/replace_resources.ts index 28ade37e6f71..22428e15e262 100644 --- a/packages/ngtools/webpack/src/transformers/replace_resources.ts +++ b/packages/ngtools/webpack/src/transformers/replace_resources.ts @@ -6,155 +6,214 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { collectDeepNodes, getFirstNode } from './ast_helpers'; -import { - AddNodeOperation, - ReplaceNodeOperation, - StandardTransform, - TransformOperation, -} from './interfaces'; -import { makeTransform } from './make_transform'; - export function replaceResources( shouldTransform: (fileName: string) => boolean, + getTypeChecker: () => ts.TypeChecker, ): ts.TransformerFactory { - const standardTransform: StandardTransform = function (sourceFile: ts.SourceFile) { - const ops: TransformOperation[] = []; - if (!shouldTransform(sourceFile.fileName)) { - return ops; - } + return (context: ts.TransformationContext) => { + const typeChecker = getTypeChecker(); - const replacements = findResources(sourceFile); + const visitNode: ts.Visitor = (node: ts.Decorator) => { + if (ts.isClassDeclaration(node)) { + node.decorators = ts.visitNodes( + node.decorators, + (node: ts.Decorator) => visitDecorator(node, typeChecker), + ); + } - if (replacements.length > 0) { + return ts.visitEachChild(node, visitNode, context); + }; - // Add the replacement operations. - ops.push(...(replacements.map((rep) => rep.replaceNodeOperation))); + return (sourceFile: ts.SourceFile) => ( + shouldTransform(sourceFile.fileName) + ? ts.visitNode(sourceFile, visitNode) + : sourceFile + ); + }; +} - // 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. +function visitDecorator(node: ts.Decorator, typeChecker: ts.TypeChecker): ts.Decorator { + if (!isComponentDecorator(node, typeChecker)) { + return node; + } - // 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)), - ]); + if (!ts.isCallExpression(node.expression)) { + return node; + } - // declare var require: NodeRequire; - const varRequire = ts.createVariableStatement( - [ts.createToken(ts.SyntaxKind.DeclareKeyword)], - [ts.createVariableDeclaration('require', ts.createTypeReferenceNode('NodeRequire', []))], - ); + const decoratorFactory = node.expression; + const args = decoratorFactory.arguments; + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + // Unsupported component metadata + return node; + } - ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), nodeRequireInterface)); - ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), varRequire)); - } + const objectExpression = args[0] as ts.ObjectLiteralExpression; + const styleReplacements: ts.Expression[] = []; - return ops; - }; + // visit all properties + let properties = ts.visitNodes( + objectExpression.properties, + (node: ts.ObjectLiteralElementLike) => visitComponentMetadata(node, styleReplacements), + ); - return makeTransform(standardTransform); -} + // replace properties with updated properties + if (styleReplacements.length > 0) { + const styleProperty = ts.createPropertyAssignment( + ts.createIdentifier('styles'), + ts.createArrayLiteral(styleReplacements), + ); + + properties = ts.createNodeArray([...properties, styleProperty]); + } -export interface ResourceReplacement { - resourcePaths: string[]; - replaceNodeOperation: ReplaceNodeOperation; + return ts.updateDecorator( + node, + ts.updateCall( + decoratorFactory, + decoratorFactory.expression, + decoratorFactory.typeArguments, + [ts.updateObjectLiteral(objectExpression, properties)], + ), + ); } -export function findResources(sourceFile: ts.SourceFile): ResourceReplacement[] { - const replacements: ResourceReplacement[] = []; - - // Find all object literals. - collectDeepNodes(sourceFile, ts.SyntaxKind.ObjectLiteralExpression) - // Get all their property assignments. - .map(node => collectDeepNodes(node, 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; +function visitComponentMetadata( + node: ts.ObjectLiteralElementLike, + styleReplacements: ts.Expression[], +): ts.ObjectLiteralElementLike | undefined { + if (!ts.isPropertyAssignment(node) || ts.isComputedPropertyName(node.name)) { + return node; + } + + const name = node.name.text; + switch (name) { + case 'moduleId': + + return undefined; + + case 'templateUrl': + return ts.updatePropertyAssignment( + node, + ts.createIdentifier('template'), + createRequireExpression(node.initializer), + ); + + case 'styles': + case 'styleUrls': + if (!ts.isArrayLiteralExpression(node.initializer)) { + return node; } - 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 resourcePath = _getResourceRequest(node.initializer, sourceFile); - const requireCall = _createRequireCall(resourcePath); - const propAssign = ts.createPropertyAssignment('template', requireCall); - replacements.push({ - resourcePaths: [resourcePath], - replaceNodeOperation: new ReplaceNodeOperation(sourceFile, node, propAssign), - }); - } else if (key == 'styleUrls') { - const arr = collectDeepNodes(node, - ts.SyntaxKind.ArrayLiteralExpression); - 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 isInlineStyles = name === 'styles'; + const styles = ts.visitNodes( + node.initializer.elements, + (node: ts.Expression) => { + if (!ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node)) { + return node; + } + + return isInlineStyles ? ts.createLiteral(node.text) : createRequireExpression(node); + }, + ); - const propAssign = ts.createPropertyAssignment('styles', requireArray); - replacements.push({ - resourcePaths: stylePaths, - replaceNodeOperation: new ReplaceNodeOperation(sourceFile, node, propAssign), - }); + // Styles should be placed first + if (isInlineStyles) { + styleReplacements.unshift(...styles); + } else { + styleReplacements.push(...styles); } - }); - return replacements; + return undefined; + default: + return node; + } } -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 { +export function getResourceUrl(node: ts.Expression): string | null { + // only analyze strings + if (!ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node)) { return null; } + + return `${/^\.?\.\//.test(node.text) ? '' : './'}${node.text}`; } -function _getResourceRequest(element: ts.Expression, sourceFile: ts.SourceFile) { - if ( - element.kind === ts.SyntaxKind.StringLiteral || - element.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral - ) { - const url = (element as ts.StringLiteral).text; +function isComponentDecorator(node: ts.Node, typeChecker: ts.TypeChecker): node is ts.Decorator { + if (!ts.isDecorator(node)) { + return false; + } - // 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); + const origin = getDecoratorOrigin(node, typeChecker); + if (origin && origin.module === '@angular/core' && origin.name === 'Component') { + return true; } + + return false; } -function _createRequireCall(path: string) { +function createRequireExpression(node: ts.Expression): ts.Expression { + const url = getResourceUrl(node); + if (!url) { + return node; + } + return ts.createCall( ts.createIdentifier('require'), - [], - [ts.createLiteral(path)], + undefined, + [ts.createLiteral(url)], ); } + +interface DecoratorOrigin { + name: string; + module: string; +} + +function getDecoratorOrigin( + decorator: ts.Decorator, + typeChecker: ts.TypeChecker, +): DecoratorOrigin | null { + if (!ts.isCallExpression(decorator.expression)) { + return null; + } + + let identifier: ts.Node; + let name = ''; + + if (ts.isPropertyAccessExpression(decorator.expression.expression)) { + identifier = decorator.expression.expression.expression; + name = decorator.expression.expression.name.text; + } else if (ts.isIdentifier(decorator.expression.expression)) { + identifier = decorator.expression.expression; + } else { + return null; + } + + // NOTE: resolver.getReferencedImportDeclaration would work as well but is internal + const symbol = typeChecker.getSymbolAtLocation(identifier); + if (symbol && symbol.declarations && symbol.declarations.length > 0) { + const declaration = symbol.declarations[0]; + let module: string; + + if (ts.isImportSpecifier(declaration)) { + name = (declaration.propertyName || declaration.name).text; + module = (declaration.parent.parent.parent.moduleSpecifier as ts.Identifier).text; + } else if (ts.isNamespaceImport(declaration)) { + // Use the name from the decorator namespace property access + module = (declaration.parent.parent.moduleSpecifier as ts.Identifier).text; + } else if (ts.isImportClause(declaration)) { + name = (declaration.name as ts.Identifier).text; + module = (declaration.parent.moduleSpecifier as ts.Identifier).text; + } else { + return null; + } + + return { name, module }; + } + + return null; +} diff --git a/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts b/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts index 23e3a65acf42..0ba09eecb385 100644 --- a/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts +++ b/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts @@ -6,11 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ import { tags } from '@angular-devkit/core'; // tslint:disable-line:no-implicit-dependencies -import { transformTypescript } from './ast_helpers'; +import { createTypescriptContext, transformTypescript } from './ast_helpers'; import { replaceResources } from './replace_resources'; +function transform(input: string, shouldTransform = true) { + const { program } = createTypescriptContext(input); + const getTypeChecker = () => program.getTypeChecker(); + const transformer = replaceResources(() => shouldTransform, getTypeChecker); + + return transformTypescript(input, [transformer]); +} + +// tslint:disable-next-line:no-big-function describe('@ngtools/webpack transformers', () => { - describe('replace_resources', () => { + // tslint:disable-next-line:no-big-function + describe('find_resources', () => { it('should replace resources', () => { const input = tags.stripIndent` import { Component } from '@angular/core'; @@ -42,9 +52,43 @@ describe('@ngtools/webpack transformers', () => { export { AppComponent }; `; - const transformer = replaceResources(() => true); - const result = transformTypescript(input, [transformer]); + const result = transform(input); + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + it('should merge styleUrls with styles', () => { + const input = tags.stripIndent` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styles: ['a { color: red }'], + styleUrls: ['./app.component.css'], + }) + export class AppComponent { + title = 'app'; + } + `; + const output = tags.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: ["a { color: red }", require("./app.component.css")] + }) + ], AppComponent); + export { AppComponent }; + `; + + const result = transform(input); expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); @@ -79,12 +123,180 @@ describe('@ngtools/webpack transformers', () => { export { AppComponent }; `; - const transformer = replaceResources(() => true); + const result = transform(input); + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should replace resources if Component decorator is aliased', () => { + const input = tags.stripIndent` + import { Component as NgComponent } from '@angular/core'; + + @NgComponent({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css', './app.component.2.css'] + }) + export class AppComponent { + title = 'app'; + } + `; + const output = tags.stripIndent` + import * as tslib_1 from "tslib"; + import { Component as NgComponent } from '@angular/core'; + let AppComponent = class AppComponent { + constructor() { + this.title = 'app'; + } + }; + AppComponent = tslib_1.__decorate([ + NgComponent({ + selector: 'app-root', + template: require("./app.component.html"), + styles: [require("./app.component.css"), require("./app.component.2.css")] + }) + ], AppComponent); + export { AppComponent }; + `; + + const { program } = createTypescriptContext(input); + const getTypeChecker = () => program.getTypeChecker(); + const transformer = replaceResources(() => true, getTypeChecker); const result = transformTypescript(input, [transformer]); expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); + it('should replace resources if Angular Core import is namespaced', () => { + const input = tags.stripIndent` + import * as ng from '@angular/core'; + + @ng.Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css', './app.component.2.css'] + }) + export class AppComponent { + title = 'app'; + } + `; + const output = tags.stripIndent` + import * as tslib_1 from "tslib"; + import * as ng from '@angular/core'; + let AppComponent = class AppComponent { + constructor() { + this.title = 'app'; + } + }; + AppComponent = tslib_1.__decorate([ + ng.Component({ + selector: 'app-root', + template: require("./app.component.html"), + styles: [require("./app.component.css"), require("./app.component.2.css")] + }) + ], AppComponent); + export { AppComponent }; + `; + + const result = transform(input); + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should not replace resources if not in Component decorator', () => { + const input = tags.stripIndent` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] + }) + export class AppComponent { + obj = [ + { + 'labels': 'Content', + 'templateUrl': 'content.html' + } + ]; + } + `; + + const output = tags.stripIndent` + import * as tslib_1 from "tslib"; + import { Component } from '@angular/core'; + + let AppComponent = class AppComponent { + constructor() { + this.obj = [ + { + 'labels': 'Content', + 'templateUrl': 'content.html' + } + ]; + } + }; + + AppComponent = tslib_1.__decorate([ + Component({ + selector: 'app-root', + template: require("./app.component.html"), + styles: [require("./app.component.css")] + }) + ], AppComponent); + export { AppComponent }; + `; + + const result = transform(input); + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should not replace resources if not in an NG Component decorator', () => { + const input = tags.stripIndent` + import { Component } from 'foo'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] + }) + export class AppComponent { + obj = [ + { + 'labels': 'Content', + 'templateUrl': 'content.html' + } + ]; + } + `; + + const output = tags.stripIndent` + import * as tslib_1 from "tslib"; + import { Component } from 'foo'; + + let AppComponent = class AppComponent { + constructor() { + this.obj = [ + { + 'labels': 'Content', + 'templateUrl': 'content.html' + } + ]; + } + }; + + AppComponent = tslib_1.__decorate([ + Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] + }) + ], AppComponent); + export { AppComponent }; + `; + + const result = transform(input); + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + it('should not replace resources if shouldTransform returns false', () => { const input = tags.stripIndent` import { Component } from '@angular/core'; @@ -116,9 +328,7 @@ describe('@ngtools/webpack transformers', () => { export { AppComponent }; `; - const transformer = replaceResources(() => false); - const result = transformTypescript(input, [transformer]); - + const result = transform(input, false); expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); });