diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index c257d301d7..88861d8eac 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -164,6 +164,16 @@ export interface CompilerOptions extends ts.CompilerOptions { */ enableSummariesForJit?: boolean; + /** + * Whether to replace the `templateUrl` and `styleUrls` property in all + * @Component decorators with inlined contents in `template` and `styles` + * properties. + * When enabled, the .js output of ngc will have no lazy-loaded `templateUrl` + * or `styleUrl`s. Note that this requires that resources be available to + * load statically at compile-time. + */ + enableResourceInlining?: boolean; + /** * Tells the compiler to generate definitions using the Render3 style code generation. * This option defaults to `false`. diff --git a/packages/compiler-cli/src/transformers/inline_resources.ts b/packages/compiler-cli/src/transformers/inline_resources.ts new file mode 100644 index 0000000000..1af5868f14 --- /dev/null +++ b/packages/compiler-cli/src/transformers/inline_resources.ts @@ -0,0 +1,306 @@ +/** + * @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 {MetadataObject, MetadataValue, isClassMetadata, isMetadataImportedSymbolReferenceExpression, isMetadataSymbolicCallExpression} from '../metadata/index'; + +import {MetadataTransformer, ValueTransform} from './metadata_cache'; + +export type ResourceLoader = { + loadResource(path: string): Promise| string; +}; + +export class InlineResourcesMetadataTransformer implements MetadataTransformer { + constructor(private host: ResourceLoader) {} + + start(sourceFile: ts.SourceFile): ValueTransform|undefined { + return (value: MetadataValue, node: ts.Node): MetadataValue => { + if (isClassMetadata(value) && ts.isClassDeclaration(node) && value.decorators) { + value.decorators.forEach(d => { + if (isMetadataSymbolicCallExpression(d) && + isMetadataImportedSymbolReferenceExpression(d.expression) && + d.expression.module === '@angular/core' && d.expression.name === 'Component' && + d.arguments) { + d.arguments = d.arguments.map(this.updateDecoratorMetadata.bind(this)); + } + }); + } + return value; + }; + } + + inlineResource(url: MetadataValue): string|undefined { + if (typeof url === 'string') { + const content = this.host.loadResource(url); + if (typeof content === 'string') { + return content; + } + } + } + + updateDecoratorMetadata(arg: MetadataObject): MetadataObject { + if (arg['templateUrl']) { + const template = this.inlineResource(arg['templateUrl']); + if (template) { + arg['template'] = template; + delete arg.templateUrl; + } + } + if (arg['styleUrls']) { + const styleUrls = arg['styleUrls']; + if (Array.isArray(styleUrls)) { + let allStylesInlined = true; + const newStyles = styleUrls.map(styleUrl => { + const style = this.inlineResource(styleUrl); + if (style) return style; + allStylesInlined = false; + return styleUrl; + }); + if (allStylesInlined) { + arg['styles'] = newStyles; + delete arg.styleUrls; + } + } + } + + return arg; + } +} + +export function getInlineResourcesTransformFactory( + program: ts.Program, host: ResourceLoader): ts.TransformerFactory { + return (context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => { + const visitor: ts.Visitor = node => { + // Components are always classes; skip any other node + if (!ts.isClassDeclaration(node)) { + return node; + } + + // Decorator case - before or without decorator downleveling + // @Component() + const newDecorators = ts.visitNodes(node.decorators, (node: ts.Decorator) => { + if (isComponentDecorator(node, program.getTypeChecker())) { + return updateDecorator(node, host); + } + return node; + }); + + // Annotation case - after decorator downleveling + // static decorators: {type: Function, args?: any[]}[] + const newMembers = ts.visitNodes( + node.members, + (node: ts.ClassElement) => updateAnnotations(node, host, program.getTypeChecker())); + + // Create a new AST subtree with our modifications + return ts.updateClassDeclaration( + node, newDecorators, node.modifiers, node.name, node.typeParameters, + node.heritageClauses || [], newMembers); + }; + + return ts.visitEachChild(sourceFile, visitor, context); + }; +} + +/** + * Update a Decorator AST node to inline the resources + * @param node the @Component decorator + * @param host provides access to load resources + */ +function updateDecorator(node: ts.Decorator, host: ResourceLoader): ts.Decorator { + if (!ts.isCallExpression(node.expression)) { + // User will get an error somewhere else with bare @Component + return node; + } + const expr = node.expression; + const newArguments = updateComponentProperties(expr.arguments, host); + return ts.updateDecorator( + node, ts.updateCall(expr, expr.expression, expr.typeArguments, newArguments)); +} + +/** + * Update an Annotations AST node to inline the resources + * @param node the static decorators property + * @param host provides access to load resources + * @param typeChecker provides access to symbol table + */ +function updateAnnotations( + node: ts.ClassElement, host: ResourceLoader, typeChecker: ts.TypeChecker): ts.ClassElement { + // Looking for a member of this shape: + // PropertyDeclaration called decorators, with static modifier + // Initializer is ArrayLiteralExpression + // One element is the Component type, its initializer is the @angular/core Component symbol + // One element is the component args, its initializer is the Component arguments to change + // e.g. + // static decorators: {type: Function, args?: any[]}[] = + // [{ + // type: Component, + // args: [{ + // templateUrl: './my.component.html', + // styleUrls: ['./my.component.css'], + // }], + // }]; + if (!ts.isPropertyDeclaration(node) || // ts.ModifierFlags.Static && + !ts.isIdentifier(node.name) || node.name.text !== 'decorators' || !node.initializer || + !ts.isArrayLiteralExpression(node.initializer)) { + return node; + } + + const newAnnotations = node.initializer.elements.map(annotation => { + // No-op if there's a non-object-literal mixed in the decorators values + if (!ts.isObjectLiteralExpression(annotation)) return annotation; + + const decoratorType = annotation.properties.find(p => isIdentifierNamed(p, 'type')); + + // No-op if there's no 'type' property, or if it's not initialized to the Component symbol + if (!decoratorType || !ts.isPropertyAssignment(decoratorType) || + !ts.isIdentifier(decoratorType.initializer) || + !isComponentSymbol(decoratorType.initializer, typeChecker)) { + return annotation; + } + + const newAnnotation = annotation.properties.map(prop => { + // No-op if this isn't the 'args' property or if it's not initialized to an array + if (!isIdentifierNamed(prop, 'args') || !ts.isPropertyAssignment(prop) || + !ts.isArrayLiteralExpression(prop.initializer)) + return prop; + + const newDecoratorArgs = ts.updatePropertyAssignment( + prop, prop.name, + ts.createArrayLiteral(updateComponentProperties(prop.initializer.elements, host))); + + return newDecoratorArgs; + }); + + return ts.updateObjectLiteral(annotation, newAnnotation); + }); + + return ts.updateProperty( + node, node.decorators, node.modifiers, node.name, node.questionToken, node.type, + ts.updateArrayLiteral(node.initializer, newAnnotations)); +} + +function isIdentifierNamed(p: ts.ObjectLiteralElementLike, name: string): boolean { + return !!p.name && ts.isIdentifier(p.name) && p.name.text === name; +} + +/** + * Check that the node we are visiting is the actual Component decorator defined in @angular/core. + */ +function isComponentDecorator(node: ts.Decorator, typeChecker: ts.TypeChecker): boolean { + if (!ts.isCallExpression(node.expression)) { + return false; + } + const callExpr = node.expression; + + let identifier: ts.Node; + + if (ts.isIdentifier(callExpr.expression)) { + identifier = callExpr.expression; + } else { + return false; + } + return isComponentSymbol(identifier, typeChecker); +} + +function isComponentSymbol(identifier: ts.Node, typeChecker: ts.TypeChecker) { + // Only handle identifiers, not expressions + if (!ts.isIdentifier(identifier)) return false; + + // NOTE: resolver.getReferencedImportDeclaration would work as well but is internal + const symbol = typeChecker.getSymbolAtLocation(identifier); + + if (!symbol || !symbol.declarations || !symbol.declarations.length) { + console.error( + `Unable to resolve symbol '${identifier.text}' in the program, does it type-check?`); + return false; + } + + const declaration = symbol.declarations[0]; + + if (!declaration || !ts.isImportSpecifier(declaration)) { + return false; + } + + const name = (declaration.propertyName || declaration.name).text; + // We know that parent pointers are set because we created the SourceFile ourselves. + // The number of parent references here match the recursion depth at this point. + const moduleId = + (declaration.parent !.parent !.parent !.moduleSpecifier as ts.StringLiteral).text; + return moduleId === '@angular/core' && name === 'Component'; +} + +/** + * For each property in the object literal, if it's templateUrl or styleUrls, replace it + * with content. + * @param node the arguments to @Component() or args property of decorators: [{type:Component}] + * @param host provides access to the loadResource method of the host + * @returns updated arguments + */ +function updateComponentProperties( + args: ts.NodeArray, host: ResourceLoader): ts.NodeArray { + if (args.length !== 1) { + // User should have gotten a type-check error because @Component takes one argument + return args; + } + const componentArg = args[0]; + if (!ts.isObjectLiteralExpression(componentArg)) { + // User should have gotten a type-check error because @Component takes an object literal + // argument + return args; + } + const newArgument = ts.updateObjectLiteral( + componentArg, ts.visitNodes(componentArg.properties, (node: ts.ObjectLiteralElementLike) => { + if (!ts.isPropertyAssignment(node)) { + // Error: unsupported + return node; + } + + if (ts.isComputedPropertyName(node.name)) { + // computed names are not supported + return node; + } + + const name = node.name.text; + switch (name) { + case 'styleUrls': + if (!ts.isArrayLiteralExpression(node.initializer)) { + // Error: unsupported + return node; + } + const styleUrls = node.initializer.elements; + + return ts.updatePropertyAssignment( + node, ts.createIdentifier('styles'), + ts.createArrayLiteral(ts.visitNodes(styleUrls, (expr: ts.Expression) => { + if (ts.isStringLiteral(expr)) { + const styles = host.loadResource(expr.text); + if (typeof styles === 'string') { + return ts.createLiteral(styles); + } + } + return expr; + }))); + + + case 'templateUrl': + if (ts.isStringLiteral(node.initializer)) { + const template = host.loadResource(node.initializer.text); + if (typeof template === 'string') { + return ts.updatePropertyAssignment( + node, ts.createIdentifier('template'), ts.createLiteral(template)); + } + } + return node; + + default: + return node; + } + })); + return ts.createNodeArray([newArgument]); +} diff --git a/packages/compiler-cli/src/transformers/program.ts b/packages/compiler-cli/src/transformers/program.ts index 9970fb692e..012f605a1e 100644 --- a/packages/compiler-cli/src/transformers/program.ts +++ b/packages/compiler-cli/src/transformers/program.ts @@ -17,6 +17,7 @@ import {MetadataCollector, ModuleMetadata, createBundleIndexHost} from '../metad import {CompilerHost, CompilerOptions, CustomTransformers, DEFAULT_ERROR_CODE, Diagnostic, DiagnosticMessageChain, EmitFlags, LazyRoute, LibrarySummary, Program, SOURCE, TsEmitArguments, TsEmitCallback} from './api'; import {CodeGenerator, TsCompilerAotCompilerTypeCheckHostAdapter, getOriginalReferences} from './compiler_host'; +import {InlineResourcesMetadataTransformer, getInlineResourcesTransformFactory} from './inline_resources'; import {LowerMetadataTransform, getExpressionLoweringTransformFactory} from './lower_expressions'; import {MetadataCache, MetadataTransformer} from './metadata_cache'; import {getAngularEmitterTransformFactory} from './node_emitter_transform'; @@ -471,10 +472,16 @@ class AngularCompilerProgram implements Program { private calculateTransforms( genFiles: Map|undefined, partialModules: PartialModule[]|undefined, customTransformers?: CustomTransformers): ts.CustomTransformers { - const beforeTs: ts.TransformerFactory[] = []; + const beforeTs: Array> = []; + const metadataTransforms: MetadataTransformer[] = []; + if (this.options.enableResourceInlining) { + beforeTs.push(getInlineResourcesTransformFactory(this.tsProgram, this.hostAdapter)); + metadataTransforms.push(new InlineResourcesMetadataTransformer(this.hostAdapter)); + } if (!this.options.disableExpressionLowering) { beforeTs.push( getExpressionLoweringTransformFactory(this.loweringMetadataTransform, this.tsProgram)); + metadataTransforms.push(this.loweringMetadataTransform); } if (genFiles) { beforeTs.push(getAngularEmitterTransformFactory(genFiles, this.getTsProgram())); @@ -484,12 +491,14 @@ class AngularCompilerProgram implements Program { // If we have partial modules, the cached metadata might be incorrect as it doesn't reflect // the partial module transforms. - this.metadataCache = this.createMetadataCache( - [this.loweringMetadataTransform, new PartialModuleMetadataTransformer(partialModules)]); + metadataTransforms.push(new PartialModuleMetadataTransformer(partialModules)); } if (customTransformers && customTransformers.beforeTs) { beforeTs.push(...customTransformers.beforeTs); } + if (metadataTransforms.length > 0) { + this.metadataCache = this.createMetadataCache(metadataTransforms); + } const afterTs = customTransformers ? customTransformers.afterTs : undefined; return {before: beforeTs, after: afterTs}; } diff --git a/packages/compiler-cli/test/ngc_spec.ts b/packages/compiler-cli/test/ngc_spec.ts index 6c53bb4417..0f059a49e2 100644 --- a/packages/compiler-cli/test/ngc_spec.ts +++ b/packages/compiler-cli/test/ngc_spec.ts @@ -1244,6 +1244,51 @@ describe('ngc transformer command-line', () => { expect(main(['-p', path.join(basePath, 'app', 'tsconfig-app.json')], errorSpy)).toBe(0); } }); + + describe('enableResourceInlining', () => { + it('should inline templateUrl and styleUrl in JS and metadata', () => { + writeConfig(`{ + "extends": "./tsconfig-base.json", + "files": ["mymodule.ts"], + "angularCompilerOptions": { + "enableResourceInlining": true + } + }`); + write('my.component.ts', ` + import {Component} from '@angular/core'; + @Component({ + templateUrl: './my.component.html', + styleUrls: ['./my.component.css'], + }) + export class MyComp {} + `); + write('my.component.html', `

Some template content

`); + write('my.component.css', `h1 {color: blue}`); + write('mymodule.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './my.component'; + + @NgModule({declarations: [MyComp]}) + export class MyModule {} + `); + + const exitCode = main(['-p', basePath]); + expect(exitCode).toEqual(0); + outDir = path.resolve(basePath, 'built'); + const outputJs = fs.readFileSync(path.join(outDir, 'my.component.js'), {encoding: 'utf-8'}); + expect(outputJs).not.toContain('templateUrl'); + expect(outputJs).not.toContain('styleUrls'); + expect(outputJs).toContain('Some template content'); + expect(outputJs).toContain('color: blue'); + + const outputMetadata = + fs.readFileSync(path.join(outDir, 'my.component.metadata.json'), {encoding: 'utf-8'}); + expect(outputMetadata).not.toContain('templateUrl'); + expect(outputMetadata).not.toContain('styleUrls'); + expect(outputMetadata).toContain('Some template content'); + expect(outputMetadata).toContain('color: blue'); + }); + }); }); describe('expression lowering', () => { @@ -1972,12 +2017,12 @@ describe('ngc transformer command-line', () => { it('on simple services', () => { const source = compileService(` import {Injectable, NgModule} from '@angular/core'; - + @Injectable() export class Service { constructor(public param: string) {} } - + @NgModule({ providers: [{provide: Service, useValue: new Service('test')}], }) @@ -1988,7 +2033,7 @@ describe('ngc transformer command-line', () => { it('on a service with a base class service', () => { const source = compileService(` import {Injectable, NgModule} from '@angular/core'; - + @Injectable() export class Dep {} @@ -1997,7 +2042,7 @@ describe('ngc transformer command-line', () => { } @Injectable() export class Service extends Base {} - + @NgModule({ providers: [Service], }) diff --git a/packages/compiler-cli/test/transformers/inline_resources_spec.ts b/packages/compiler-cli/test/transformers/inline_resources_spec.ts new file mode 100644 index 0000000000..7a19155dc7 --- /dev/null +++ b/packages/compiler-cli/test/transformers/inline_resources_spec.ts @@ -0,0 +1,177 @@ +/** + * @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 {MetadataCollector, isClassMetadata} from '../../src/metadata/index'; +import {InlineResourcesMetadataTransformer, getInlineResourcesTransformFactory} from '../../src/transformers/inline_resources'; +import {MetadataCache} from '../../src/transformers/metadata_cache'; +import {MockAotContext, MockCompilerHost} from '../mocks'; + +describe('inline resources transformer', () => { + describe('decorator input', () => { + describe('should not touch unrecognized decorators', () => { + it('Not from @angular/core', () => { + expect(convert(`declare const Component: Function; + @Component({templateUrl: './thing.html'}) class Foo {}`)) + .toContain('templateUrl'); + }); + it('missing @ sign', () => { + expect(convert(`import {Component} from '@angular/core'; + Component({templateUrl: './thing.html'}) class Foo {}`)) + .toContain('templateUrl'); + }); + it('too many arguments to @Component', () => { + expect(convert(`import {Component} from '@angular/core'; + @Component(1, {templateUrl: './thing.html'}) class Foo {}`)) + .toContain('templateUrl'); + }); + it('wrong argument type to @Component', () => { + expect(convert(`import {Component} from '@angular/core'; + @Component([{templateUrl: './thing.html'}]) class Foo {}`)) + .toContain('templateUrl'); + }); + }); + + it('should replace templateUrl', () => { + const actual = convert(`import {Component} from '@angular/core'; + @Component({ + templateUrl: './thing.html', + otherProp: 3, + }) export class Foo {}`); + expect(actual).not.toContain('templateUrl:'); + expect(actual.replace(/\s+/g, ' ')) + .toContain( + 'Foo = __decorate([ core_1.Component({ template: "Some template", otherProp: 3, }) ], Foo)'); + }); + it('should replace styleUrls', () => { + const actual = convert(`import {Component} from '@angular/core'; + @Component({ + styleUrls: ['./thing1.css', './thing2.css'], + }) + export class Foo {}`); + expect(actual).not.toContain('styleUrls:'); + expect(actual).toContain('styles: [".some_style {}", ".some_other_style {}"]'); + }); + it('should handle empty styleUrls', () => { + const actual = convert(`import {Component} from '@angular/core'; + @Component({styleUrls: []}) export class Foo {}`); + expect(actual).not.toContain('styleUrls:'); + expect(actual).toContain('styles: []'); + }); + }); + describe('annotation input', () => { + it('should replace templateUrl', () => { + const actual = convert(`import {Component} from '@angular/core'; + declare const NotComponent: Function; + + export class Foo { + static decorators: {type: Function, args?: any[]}[] = [ + { + type: NotComponent, + args: [], + },{ + type: Component, + args: [{ + templateUrl: './thing.html' + }], + }]; + } + `); + expect(actual).not.toContain('templateUrl:'); + expect(actual.replace(/\s+/g, ' ')) + .toMatch( + /Foo\.decorators = [{ .*type: core_1\.Component, args: [{ template: "Some template" }]/); + }); + it('should replace styleUrls', () => { + const actual = convert(`import {Component} from '@angular/core'; + declare const NotComponent: Function; + + export class Foo { + static decorators: {type: Function, args?: any[]}[] = [{ + type: Component, + args: [{ + styleUrls: ['./thing1.css', './thing2.css'], + }], + }]; + } + `); + expect(actual).not.toContain('styleUrls:'); + expect(actual.replace(/\s+/g, ' ')) + .toMatch( + /Foo\.decorators = [{ .*type: core_1\.Component, args: [{ style: "Some template" }]/); + }); + }); +}); + +describe('metadata transformer', () => { + it('should transform decorators', () => { + const source = `import {Component} from '@angular/core'; + @Component({ + templateUrl: './thing.html', + styleUrls: ['./thing1.css', './thing2.css'], + }) + export class Foo {} + `; + const sourceFile = ts.createSourceFile( + 'someFile.ts', source, ts.ScriptTarget.Latest, /* setParentNodes */ true); + const cache = new MetadataCache( + new MetadataCollector(), /* strict */ true, + [new InlineResourcesMetadataTransformer({loadResource})]); + const metadata = cache.getMetadata(sourceFile); + expect(metadata).toBeDefined('Expected metadata from test source file'); + if (metadata) { + const classData = metadata.metadata['Foo']; + expect(classData && isClassMetadata(classData)) + .toBeDefined(`Expected metadata to contain data for Foo`); + if (classData && isClassMetadata(classData)) { + expect(JSON.stringify(classData)).not.toContain('templateUrl'); + expect(JSON.stringify(classData)).toContain('"template":"Some template"'); + expect(JSON.stringify(classData)).not.toContain('styleUrls'); + expect(JSON.stringify(classData)) + .toContain('"styles":[".some_style {}",".some_other_style {}"]'); + } + } + }); +}); + +function loadResource(path: string): Promise|string { + if (path === './thing.html') return 'Some template'; + if (path === './thing1.css') return '.some_style {}'; + if (path === './thing2.css') return '.some_other_style {}'; + throw new Error('No fake data for path ' + path); +} + +function convert(source: string) { + const baseFileName = 'someFile'; + const moduleName = '/' + baseFileName; + const fileName = moduleName + '.ts'; + const context = new MockAotContext('/', {[baseFileName + '.ts']: source}); + const host = new MockCompilerHost(context); + + const sourceFile = + ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, /* setParentNodes */ true); + const program = ts.createProgram( + [fileName], { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2017, + }, + host); + const moduleSourceFile = program.getSourceFile(fileName); + const transformers: ts.CustomTransformers = { + before: [getInlineResourcesTransformFactory(program, {loadResource})] + }; + let result = ''; + const emitResult = program.emit( + moduleSourceFile, (emittedFileName, data, writeByteOrderMark, onError, sourceFiles) => { + if (fileName.startsWith(moduleName)) { + result = data; + } + }, undefined, undefined, transformers); + return result; +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 0517a6978b..a387a0595a 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -18,7 +18,7 @@ }, "rootDir": ".", "inlineSourceMap": true, - "lib": ["es5", "dom", "es2015.promise", "es2015.collection", "es2015.iterable"], + "lib": ["es5", "dom", "es2015.promise", "es2015.collection", "es2015.iterable", "es2015.core"], "skipDefaultLibCheck": true, "skipLibCheck": true, "target": "es5",