Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(compiler-cli): add resource inlining to ngc #22615

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/compiler-cli/src/transformers/api.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Perfect! The Promise<string> | string allows to integrate external tools like scss, less and other preprocessors preprocessors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, if it doesn't return string this transform fails because the typescript compiler is a synchronous API. Under this toolchain the preprocessors are expected to run before ngc.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm 😐 Nevertheless, I'll get rid of the horrible replace source text by string-position-marker thing that you'll solved in a more elegant way in ngc.

};

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I think this accounts for named imports – import { Component } from '@angular/core' – and aliased imports – import { Component as Foo } from '@angular/core'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should add tests for those cases though - I borrowed some of this code from you but dropped all the conditional logic I wasn't testing

// 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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, Angular has a build-time dependency on devkit but not yet a runtime dependency... will have to think about that.
What's the failing test case look like?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be:

import * as ngCore from '@angular/core';

@ngCore.Component({
  templateUrl: `..`
})
export class Something {}

https://github.com/dherges/ng-packagr/blob/master/src/lib/ts/ng-ts-ast.spec.ts#L64-L66

}

/**
* 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for redundant checks as you can emit on error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow - are you saying this code is good as written since it's tolerant of incorrect input?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

// 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
Original file line number Diff line number Diff line change
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