From 0c3738a780c1392bb9e1e9cba00e5c8e134e4ebd Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 26 Jun 2018 15:01:09 -0700 Subject: [PATCH] feat(ivy): support templateUrl for ngtsc (#24704) This commit adds support for templateUrl in component templates within ngtsc. The compilation pipeline is split into sync and async versions, where asynchronous compilation invokes a special preanalyze() phase of analysis. The preanalyze() phase can optionally return a Promise which will delay compilation until it resolves. A ResourceLoader interface is used to resolve templateUrls to template strings and can return results either synchronously or asynchronously. During sync compilation it is an error if the ResourceLoader returns a Promise. Two ResourceLoader implementations are provided. One uses 'fs' to read resources directly from disk and is chosen if the CompilerHost doesn't provide a readResource method. The other wraps the readResource method from CompilerHost if it's provided. PR Close #24704 --- .../src/ngtsc/annotations/index.ts | 1 + .../src/ngtsc/annotations/src/api.ts | 12 +++ .../src/ngtsc/annotations/src/component.ts | 73 ++++++++++---- packages/compiler-cli/src/ngtsc/program.ts | 96 +++++++++++++------ .../compiler-cli/src/ngtsc/resource_loader.ts | 59 ++++++++++++ .../src/ngtsc/transform/src/api.ts | 9 ++ .../src/ngtsc/transform/src/compilation.ts | 73 +++++++++----- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 21 ++++ 8 files changed, 278 insertions(+), 66 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/annotations/src/api.ts create mode 100644 packages/compiler-cli/src/ngtsc/resource_loader.ts diff --git a/packages/compiler-cli/src/ngtsc/annotations/index.ts b/packages/compiler-cli/src/ngtsc/annotations/index.ts index 4328de07ba..3beccc7aa8 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/index.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/index.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +export {ResourceLoader} from './src/api'; export {ComponentDecoratorHandler} from './src/component'; export {DirectiveDecoratorHandler} from './src/directive'; export {InjectableDecoratorHandler} from './src/injectable'; diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/api.ts b/packages/compiler-cli/src/ngtsc/annotations/src/api.ts new file mode 100644 index 0000000000..32b4c3ff00 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/src/api.ts @@ -0,0 +1,12 @@ +/** + * @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 + */ + +export interface ResourceLoader { + preload?(url: string): Promise|undefined; + load(url: string): string; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index 2de6d830d9..64b2913fe1 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -7,12 +7,14 @@ */ import {ConstantPool, Expression, R3ComponentMetadata, R3DirectiveMetadata, WrappedNodeExpr, compileComponentFromMetadata, makeBindingParser, parseTemplate} from '@angular/compiler'; +import * as path from 'path'; import * as ts from 'typescript'; import {Decorator, ReflectionHost} from '../../host'; import {reflectObjectLiteral, staticallyResolve} from '../../metadata'; import {AnalysisOutput, CompileResult, DecoratorHandler} from '../../transform'; +import {ResourceLoader} from './api'; import {extractDirectiveMetadata} from './directive'; import {SelectorScopeRegistry} from './selector_scope'; import {isAngularCore} from './util'; @@ -25,21 +27,35 @@ const EMPTY_MAP = new Map(); export class ComponentDecoratorHandler implements DecoratorHandler { constructor( private checker: ts.TypeChecker, private reflector: ReflectionHost, - private scopeRegistry: SelectorScopeRegistry, private isCore: boolean) {} + private scopeRegistry: SelectorScopeRegistry, private isCore: boolean, + private resourceLoader: ResourceLoader) {} + + private literalCache = new Map(); + detect(decorators: Decorator[]): Decorator|undefined { return decorators.find( decorator => decorator.name === 'Component' && (this.isCore || isAngularCore(decorator))); } + preanalyze(node: ts.ClassDeclaration, decorator: Decorator): Promise|undefined { + const meta = this._resolveLiteral(decorator); + const component = reflectObjectLiteral(meta); + + if (this.resourceLoader.preload !== undefined && component.has('templateUrl')) { + const templateUrl = staticallyResolve(component.get('templateUrl') !, this.checker); + if (typeof templateUrl !== 'string') { + throw new Error(`templateUrl should be a string`); + } + const url = path.posix.resolve(path.dirname(node.getSourceFile().fileName), templateUrl); + return this.resourceLoader.preload(url); + } + return undefined; + } + analyze(node: ts.ClassDeclaration, decorator: Decorator): AnalysisOutput { - if (decorator.args === null || decorator.args.length !== 1) { - throw new Error(`Incorrect number of arguments to @Component decorator`); - } - const meta = decorator.args[0]; - if (!ts.isObjectLiteralExpression(meta)) { - throw new Error(`Decorator argument must be literal.`); - } + const meta = this._resolveLiteral(decorator); + this.literalCache.delete(decorator); // @Component inherits @Directive, so begin by extracting the @Directive metadata and building // on it. @@ -55,14 +71,23 @@ export class ComponentDecoratorHandler implements DecoratorHandler, private options: api.CompilerOptions, private host: api.CompilerHost, oldProgram?: api.Program) { + this.resourceLoader = host.readResource !== undefined ? + new HostResourceLoader(host.readResource.bind(host)) : + new FileResourceLoader(); + this.tsProgram = ts.createProgram(rootNames, options, host, oldProgram && oldProgram.getTsProgram()); } @@ -62,7 +73,16 @@ export class NgtscProgram implements api.Program { return []; } - loadNgStructureAsync(): Promise { return Promise.resolve(); } + async loadNgStructureAsync(): Promise { + if (this.compilation === undefined) { + this.compilation = this.makeCompilation(); + + await this.tsProgram.getSourceFiles() + .filter(file => !file.fileName.endsWith('.d.ts')) + .map(file => this.compilation !.analyzeAsync(file)) + .filter((result): result is Promise => result !== undefined); + } + } listLazyRoutes(entryRoute?: string|undefined): api.LazyRoute[] { throw new Error('Method not implemented.'); @@ -88,30 +108,13 @@ export class NgtscProgram implements api.Program { mergeEmitResultsCallback?: api.TsMergeEmitResultsCallback }): ts.EmitResult { const emitCallback = opts && opts.emitCallback || defaultEmitCallback; - const mergeEmitResultsCallback = opts && opts.mergeEmitResultsCallback || mergeEmitResults; - const checker = this.tsProgram.getTypeChecker(); - const isCore = isAngularCorePackage(this.tsProgram); - const reflector = new TypeScriptReflectionHost(checker); - const scopeRegistry = new SelectorScopeRegistry(checker, reflector); - - // Set up the IvyCompilation, which manages state for the Ivy transformer. - const handlers = [ - new ComponentDecoratorHandler(checker, reflector, scopeRegistry, isCore), - new DirectiveDecoratorHandler(checker, reflector, scopeRegistry, isCore), - new InjectableDecoratorHandler(reflector, isCore), - new NgModuleDecoratorHandler(checker, reflector, scopeRegistry, isCore), - new PipeDecoratorHandler(reflector, isCore), - ]; - - const coreImportsFrom = isCore && getR3SymbolsFile(this.tsProgram) || null; - - const compilation = new IvyCompilation(handlers, checker, reflector, coreImportsFrom); - - // Analyze every source file in the program. - this.tsProgram.getSourceFiles() - .filter(file => !file.fileName.endsWith('.d.ts')) - .forEach(file => compilation.analyze(file)); + if (this.compilation === undefined) { + this.compilation = this.makeCompilation(); + this.tsProgram.getSourceFiles() + .filter(file => !file.fileName.endsWith('.d.ts')) + .forEach(file => this.compilation !.analyzeSync(file)); + } // Since there is no .d.ts transformation API, .d.ts files are transformed during write. const writeFile: ts.WriteFileCallback = @@ -120,7 +123,8 @@ export class NgtscProgram implements api.Program { sourceFiles: ReadonlyArray) => { if (fileName.endsWith('.d.ts')) { data = sourceFiles.reduce( - (data, sf) => compilation.transformedDtsFor(sf.fileName, data, fileName), data); + (data, sf) => this.compilation !.transformedDtsFor(sf.fileName, data, fileName), + data); } this.host.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles); }; @@ -133,11 +137,49 @@ export class NgtscProgram implements api.Program { options: this.options, emitOnlyDtsFiles: false, writeFile, customTransformers: { - before: [ivyTransformFactory(compilation, reflector, coreImportsFrom)], + before: [ivyTransformFactory(this.compilation !, this.reflector, this.coreImportsFrom)], }, }); return emitResult; } + + private makeCompilation(): IvyCompilation { + const checker = this.tsProgram.getTypeChecker(); + const scopeRegistry = new SelectorScopeRegistry(checker, this.reflector); + + // Set up the IvyCompilation, which manages state for the Ivy transformer. + const handlers = [ + new ComponentDecoratorHandler( + checker, this.reflector, scopeRegistry, this.isCore, this.resourceLoader), + new DirectiveDecoratorHandler(checker, this.reflector, scopeRegistry, this.isCore), + new InjectableDecoratorHandler(this.reflector, this.isCore), + new NgModuleDecoratorHandler(checker, this.reflector, scopeRegistry, this.isCore), + new PipeDecoratorHandler(this.reflector, this.isCore), + ]; + + return new IvyCompilation(handlers, checker, this.reflector, this.coreImportsFrom); + } + + private get reflector(): TypeScriptReflectionHost { + if (this._reflector === undefined) { + this._reflector = new TypeScriptReflectionHost(this.tsProgram.getTypeChecker()); + } + return this._reflector; + } + + private get coreImportsFrom(): ts.SourceFile|null { + if (this._coreImportsFrom === undefined) { + this._coreImportsFrom = this.isCore && getR3SymbolsFile(this.tsProgram) || null; + } + return this._coreImportsFrom; + } + + private get isCore(): boolean { + if (this._isCore === undefined) { + this._isCore = isAngularCorePackage(this.tsProgram); + } + return this._isCore; + } } const defaultEmitCallback: api.TsEmitCallback = diff --git a/packages/compiler-cli/src/ngtsc/resource_loader.ts b/packages/compiler-cli/src/ngtsc/resource_loader.ts new file mode 100644 index 0000000000..ea54032e3c --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/resource_loader.ts @@ -0,0 +1,59 @@ +/** + * @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 fs from 'fs'; + +import {ResourceLoader} from './annotations'; + +/** + * `ResourceLoader` which delegates to a `CompilerHost` resource loading method. + */ +export class HostResourceLoader implements ResourceLoader { + private cache = new Map(); + private fetching = new Set(); + + constructor(private host: (url: string) => string | Promise) {} + + preload(url: string): Promise|undefined { + if (this.cache.has(url) || this.fetching.has(url)) { + return undefined; + } + + const result = this.host(url); + if (typeof result === 'string') { + this.cache.set(url, result); + return undefined; + } else { + this.fetching.add(url); + return result.then(str => { + this.fetching.delete(url); + this.cache.set(url, str); + }); + } + } + + load(url: string): string { + if (this.cache.has(url)) { + return this.cache.get(url) !; + } + + const result = this.host(url); + if (typeof result !== 'string') { + throw new Error(`HostResourceLoader: host(${url}) returned a Promise`); + } + this.cache.set(url, result); + return result; + } +} + +/** + * `ResourceLoader` which directly uses the filesystem to resolve resources synchronously. + */ +export class FileResourceLoader implements ResourceLoader { + load(url: string): string { return fs.readFileSync(url, 'utf8'); } +} diff --git a/packages/compiler-cli/src/ngtsc/transform/src/api.ts b/packages/compiler-cli/src/ngtsc/transform/src/api.ts index 445216bc96..97e33e2cf1 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/api.ts @@ -26,6 +26,15 @@ export interface DecoratorHandler { */ detect(decorator: Decorator[]): Decorator|undefined; + + /** + * Asynchronously perform pre-analysis on the decorator/class combination. + * + * `preAnalyze` is optional and is not guaranteed to be called through all compilation flows. It + * will only be called if asynchronicity is supported in the CompilerHost. + */ + preanalyze?(node: ts.Declaration, decorator: Decorator): Promise|undefined; + /** * Perform analysis on the decorator/class combination, producing instructions for compilation * if successful, or an array of diagnostic messages if the analysis fails or the decorator diff --git a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts index 7129efa8f2..153375463b 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import {Expression, Type} from '@angular/compiler'; import * as ts from 'typescript'; import {Decorator, ReflectionHost} from '../../host'; @@ -14,9 +13,6 @@ import {reflectNameOfDeclaration} from '../../metadata/src/reflector'; import {AnalysisOutput, CompileResult, DecoratorHandler} from './api'; import {DtsFileTransformer} from './declaration'; -import {ImportManager, translateType} from './translator'; - - /** * Record of an adapter which decided to emit a static field, and the analysis it performed to @@ -45,6 +41,8 @@ export class IvyCompilation { * Tracks the `DtsFileTransformer`s for each TS file that needs .d.ts transformations. */ private dtsMap = new Map(); + private _diagnostics: ts.Diagnostic[] = []; + /** * @param handlers array of `DecoratorHandler`s which will be executed against each class in the @@ -59,11 +57,18 @@ export class IvyCompilation { private handlers: DecoratorHandler[], private checker: ts.TypeChecker, private reflector: ReflectionHost, private coreImportsFrom: ts.SourceFile|null) {} + + analyzeSync(sf: ts.SourceFile): void { return this.analyze(sf, false); } + + analyzeAsync(sf: ts.SourceFile): Promise|undefined { return this.analyze(sf, true); } + /** * Analyze a source file and produce diagnostics for it (if any). */ - analyze(sf: ts.SourceFile): ts.Diagnostic[] { - const diagnostics: ts.Diagnostic[] = []; + private analyze(sf: ts.SourceFile, preanalyze: false): undefined; + private analyze(sf: ts.SourceFile, preanalyze: true): Promise|undefined; + private analyze(sf: ts.SourceFile, preanalyze: boolean): Promise|undefined { + const promises: Promise[] = []; const analyzeClass = (node: ts.Declaration): void => { // The first step is to reflect the decorators. @@ -79,23 +84,38 @@ export class IvyCompilation { return; } - // Check for multiple decorators on the same node. Technically speaking this - // could be supported, but right now it's an error. - if (this.analysis.has(node)) { - throw new Error('TODO.Diagnostic: Class has multiple Angular decorators.'); - } + const completeAnalysis = () => { + // Check for multiple decorators on the same node. Technically speaking this + // could be supported, but right now it's an error. + if (this.analysis.has(node)) { + throw new Error('TODO.Diagnostic: Class has multiple Angular decorators.'); + } - // Run analysis on the decorator. This will produce either diagnostics, an - // analysis result, or both. - const analysis = adapter.analyze(node, decorator); - if (analysis.diagnostics !== undefined) { - diagnostics.push(...analysis.diagnostics); - } - if (analysis.analysis !== undefined) { - this.analysis.set(node, { - adapter, - analysis: analysis.analysis, decorator, - }); + // Run analysis on the decorator. This will produce either diagnostics, an + // analysis result, or both. + const analysis = adapter.analyze(node, decorator); + + if (analysis.analysis !== undefined) { + this.analysis.set(node, { + adapter, + analysis: analysis.analysis, decorator, + }); + } + + if (analysis.diagnostics !== undefined) { + this._diagnostics.push(...analysis.diagnostics); + } + }; + + if (preanalyze && adapter.preanalyze !== undefined) { + const preanalysis = adapter.preanalyze(node, decorator); + if (preanalysis !== undefined) { + promises.push(preanalysis.then(() => completeAnalysis())); + } else { + completeAnalysis(); + } + } else { + completeAnalysis(); } }); }; @@ -109,7 +129,12 @@ export class IvyCompilation { }; visit(sf); - return diagnostics; + + if (preanalyze && promises.length > 0) { + return Promise.all(promises).then(() => undefined); + } else { + return undefined; + } } /** @@ -166,6 +191,8 @@ export class IvyCompilation { return this.dtsMap.get(tsFileName) !.transform(dtsOriginalSource, tsFileName); } + get diagnostics(): ReadonlyArray { return this._diagnostics; } + private getDtsTransformer(tsFileName: string): DtsFileTransformer { if (!this.dtsMap.has(tsFileName)) { this.dtsMap.set(tsFileName, new DtsFileTransformer(this.coreImportsFrom)); diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index af0867849c..e280dd90b2 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -147,6 +147,27 @@ describe('ngtsc behavioral tests', () => { expect(dtsContents).toContain('static ngComponentDef: i0.ComponentDef'); }); + it('should compile Components without errors', () => { + writeConfig(); + write('test.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + templateUrl: './dir/test.html', + }) + export class TestCmp {} + `); + write('dir/test.html', '

Hello World

'); + + const exitCode = main(['-p', basePath], errorSpy); + expect(errorSpy).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + + const jsContents = getContents('test.js'); + expect(jsContents).toContain('Hello World'); + }); + it('should compile NgModules without errors', () => { writeConfig(); write('test.ts', `