Skip to content

Commit

Permalink
feat(compiler-cli): add resource inlining to ngc (#22615)
Browse files Browse the repository at this point in the history
When angularCompilerOptions { enableResourceInlining: true }, we replace all templateUrl and styleUrls properties in @component with template/styles

PR Close #22615
  • Loading branch information
alexeagle authored and kara committed Mar 9, 2018
1 parent 1e6cc42 commit b5be18f
Show file tree
Hide file tree
Showing 6 changed files with 555 additions and 8 deletions.
10 changes: 10 additions & 0 deletions packages/compiler-cli/src/transformers/api.ts
Expand Up @@ -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`.
Expand Down
306 changes: 306 additions & 0 deletions 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>| 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<ts.SourceFile> {
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<ts.Expression>, host: ResourceLoader): ts.NodeArray<ts.Expression> {
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<ts.Expression>([newArgument]);
}
15 changes: 12 additions & 3 deletions packages/compiler-cli/src/transformers/program.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -471,10 +472,16 @@ class AngularCompilerProgram implements Program {
private calculateTransforms(
genFiles: Map<string, GeneratedFile>|undefined, partialModules: PartialModule[]|undefined,
customTransformers?: CustomTransformers): ts.CustomTransformers {
const beforeTs: ts.TransformerFactory<ts.SourceFile>[] = [];
const beforeTs: Array<ts.TransformerFactory<ts.SourceFile>> = [];
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()));
Expand All @@ -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};
}
Expand Down

0 comments on commit b5be18f

Please sign in to comment.