From 1de04b124e1e92ea21a070c9d928664f193d220c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 1 Mar 2021 10:43:15 -0500 Subject: [PATCH] feat(compiler-cli): support transforming component style resources (#41307) This change introduces a new hook on the `ResourceHost` interface named `transformResource`. Resource transformation allows both external and inline resources to be transformed prior to compilation by the AOT compiler. This provides support for tooling integrations to enable features such as preprocessor support for inline styles. Only style resources are currently supported. However, the infrastructure is in place to add template support in the future. PR Close #41307 --- .../ngcc/src/analysis/decoration_analyzer.ts | 4 + .../src/ngtsc/annotations/index.ts | 2 +- .../src/ngtsc/annotations/src/api.ts | 37 +++++++++- .../src/ngtsc/annotations/src/component.ts | 74 +++++++++++++------ .../ngtsc/annotations/test/component_spec.ts | 52 ++++++++++++- .../src/ngtsc/core/api/src/adapter.ts | 2 +- .../src/ngtsc/core/api/src/interfaces.ts | 46 ++++++++++++ .../compiler-cli/src/ngtsc/core/src/host.ts | 1 + .../src/ngtsc/resource/src/loader.ts | 45 ++++++++++- 9 files changed, 231 insertions(+), 32 deletions(-) diff --git a/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts b/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts index 54f2b08dc4..ce1a5a154b 100644 --- a/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts +++ b/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts @@ -40,9 +40,13 @@ import {isWithinPackage, NOOP_DEPENDENCY_TRACKER} from './util'; class NgccResourceLoader implements ResourceLoader { constructor(private fs: ReadonlyFileSystem) {} canPreload = false; + canPreprocess = false; preload(): undefined|Promise { throw new Error('Not implemented.'); } + preprocessInline(): Promise { + throw new Error('Not implemented.'); + } load(url: string): string { return this.fs.readFile(this.fs.resolve(url)); } diff --git a/packages/compiler-cli/src/ngtsc/annotations/index.ts b/packages/compiler-cli/src/ngtsc/annotations/index.ts index f36b2a1a28..98f12928a1 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/index.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/index.ts @@ -8,7 +8,7 @@ /// -export {ResourceLoader} from './src/api'; +export {ResourceLoader, ResourceLoaderContext} 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 index 544bbdf461..2ff84162ea 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/api.ts @@ -21,6 +21,11 @@ export interface ResourceLoader { */ canPreload: boolean; + /** + * If true, the resource loader is able to preprocess inline resources. + */ + canPreprocess: boolean; + /** * Resolve the url of a resource relative to the file that contains the reference to it. * The return value of this method can be used in the `load()` and `preload()` methods. @@ -37,11 +42,22 @@ export interface ResourceLoader { * should be cached so it can be accessed synchronously via the `load()` method. * * @param resolvedUrl The url (resolved by a call to `resolve()`) of the resource to preload. + * @param context Information regarding the resource such as the type and containing file. * @returns A Promise that is resolved once the resource has been loaded or `undefined` * if the file has already been loaded. * @throws An Error if pre-loading is not available. */ - preload(resolvedUrl: string): Promise|undefined; + preload(resolvedUrl: string, context: ResourceLoaderContext): Promise|undefined; + + /** + * Preprocess the content data of an inline resource, asynchronously. + * + * @param data The existing content data from the inline resource. + * @param context Information regarding the resource such as the type and containing file. + * @returns A Promise that resolves to the processed data. If no processing occurs, the + * same data string that was passed to the function will be resolved. + */ + preprocessInline(data: string, context: ResourceLoaderContext): Promise; /** * Load the resource at the given url, synchronously. @@ -53,3 +69,22 @@ export interface ResourceLoader { */ load(resolvedUrl: string): string; } + +/** + * Contextual information used by members of the ResourceLoader interface. + */ +export interface ResourceLoaderContext { + /** + * The type of the component resource. + * * Resources referenced via a component's `styles` or `styleUrls` properties are of + * type `style`. + * * Resources referenced via a component's `template` or `templateUrl` properties are of type + * `template`. + */ + type: 'style'|'template'; + + /** + * The absolute path to the file that contains the resource or reference to the resource. + */ + containingFile: string; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index 3795f7e0b7..dd58ad5b56 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -220,6 +220,7 @@ export class ComponentDecoratorHandler implements * thrown away, and the parsed template is reused during the analyze phase. */ private preanalyzeTemplateCache = new Map(); + private preanalyzeStylesCache = new Map(); readonly precedence = HandlerPrecedence.PRIMARY; readonly name = ComponentDecoratorHandler.name; @@ -266,7 +267,7 @@ export class ComponentDecoratorHandler implements resourceType: ResourceTypeForDiagnostics): Promise|undefined => { const resourceUrl = this._resolveResourceOrThrow(styleUrl, containingFile, nodeForError, resourceType); - return this.resourceLoader.preload(resourceUrl); + return this.resourceLoader.preload(resourceUrl, {type: 'style', containingFile}); }; // A Promise that waits for the template and all ed styles within it to be preloaded. @@ -289,22 +290,33 @@ export class ComponentDecoratorHandler implements // Extract all the styleUrls in the decorator. const componentStyleUrls = this._extractComponentStyleUrls(component); - if (componentStyleUrls === null) { - // A fast path exists if there are no styleUrls, to just wait for - // templateAndTemplateStyleResources. - return templateAndTemplateStyleResources; - } else { - // Wait for both the template and all styleUrl resources to resolve. - return Promise - .all([ - templateAndTemplateStyleResources, - ...componentStyleUrls.map( - styleUrl => resolveStyleUrl( - styleUrl.url, styleUrl.nodeForError, - ResourceTypeForDiagnostics.StylesheetFromDecorator)) - ]) - .then(() => undefined); + // Extract inline styles, process, and cache for use in synchronous analyze phase + let inlineStyles; + if (component.has('styles')) { + const litStyles = parseFieldArrayValue(component, 'styles', this.evaluator); + if (litStyles === null) { + this.preanalyzeStylesCache.set(node, null); + } else { + inlineStyles = Promise + .all(litStyles.map( + style => this.resourceLoader.preprocessInline( + style, {type: 'style', containingFile}))) + .then(styles => { + this.preanalyzeStylesCache.set(node, styles); + }); + } } + + // Wait for both the template and all styleUrl resources to resolve. + return Promise + .all([ + templateAndTemplateStyleResources, inlineStyles, + ...componentStyleUrls.map( + styleUrl => resolveStyleUrl( + styleUrl.url, styleUrl.nodeForError, + ResourceTypeForDiagnostics.StylesheetFromDecorator)) + ]) + .then(() => undefined); } analyze( @@ -409,12 +421,29 @@ export class ComponentDecoratorHandler implements } } + // If inline styles were preprocessed use those let inlineStyles: string[]|null = null; - if (component.has('styles')) { - const litStyles = parseFieldArrayValue(component, 'styles', this.evaluator); - if (litStyles !== null) { - inlineStyles = [...litStyles]; - styles.push(...litStyles); + if (this.preanalyzeStylesCache.has(node)) { + inlineStyles = this.preanalyzeStylesCache.get(node)!; + this.preanalyzeStylesCache.delete(node); + if (inlineStyles !== null) { + styles.push(...inlineStyles); + } + } else { + // Preprocessing is only supported asynchronously + // If no style cache entry is present asynchronous preanalyze was not executed. + // This protects against accidental differences in resource contents when preanalysis + // is not used with a provided transformResource hook on the ResourceHost. + if (this.resourceLoader.canPreprocess) { + throw new Error('Inline resource processing requires asynchronous preanalyze.'); + } + + if (component.has('styles')) { + const litStyles = parseFieldArrayValue(component, 'styles', this.evaluator); + if (litStyles !== null) { + inlineStyles = [...litStyles]; + styles.push(...litStyles); + } } } if (template.styles.length > 0) { @@ -979,7 +1008,8 @@ export class ComponentDecoratorHandler implements } const resourceUrl = this._resolveResourceOrThrow( templateUrl, containingFile, templateUrlExpr, ResourceTypeForDiagnostics.Template); - const templatePromise = this.resourceLoader.preload(resourceUrl); + const templatePromise = + this.resourceLoader.preload(resourceUrl, {type: 'template', containingFile}); // If the preload worked, then actually load and parse the template, and wait for any style // URLs to resolve. diff --git a/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts index ccd010802a..63d33bd2c3 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts @@ -20,7 +20,7 @@ import {NOOP_PERF_RECORDER} from '../../perf'; import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection'; import {LocalModuleScopeRegistry, MetadataDtsModuleScopeResolver, TypeCheckScopeRegistry} from '../../scope'; import {getDeclaration, makeProgram} from '../../testing'; -import {ResourceLoader} from '../src/api'; +import {ResourceLoader, ResourceLoaderContext} from '../src/api'; import {ComponentDecoratorHandler} from '../src/component'; export class StubResourceLoader implements ResourceLoader { @@ -28,12 +28,16 @@ export class StubResourceLoader implements ResourceLoader { return v; } canPreload = false; + canPreprocess = false; load(v: string): string { return ''; } preload(): Promise|undefined { throw new Error('Not implemented'); } + preprocessInline(_data: string, _context: ResourceLoaderContext): Promise { + throw new Error('Not implemented'); + } } function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.CompilerHost) { @@ -54,6 +58,7 @@ function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.Compil const injectableRegistry = new InjectableClassRegistry(reflectionHost); const resourceRegistry = new ResourceRegistry(); const typeCheckScopeRegistry = new TypeCheckScopeRegistry(scopeRegistry, metaReader); + const resourceLoader = new StubResourceLoader(); const handler = new ComponentDecoratorHandler( reflectionHost, @@ -65,7 +70,7 @@ function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.Compil typeCheckScopeRegistry, resourceRegistry, /* isCore */ false, - new StubResourceLoader(), + resourceLoader, /* rootDirs */['/'], /* defaultPreserveWhitespaces */ false, /* i18nUseExternalIds */ true, @@ -83,7 +88,7 @@ function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.Compil /* annotateForClosureCompiler */ false, NOOP_PERF_RECORDER, ); - return {reflectionHost, handler}; + return {reflectionHost, handler, resourceLoader}; } runInEachFileSystem(() => { @@ -257,6 +262,47 @@ runInEachFileSystem(() => { handler.compileFull(TestCmp, analysis!, resolution.data!, new ConstantPool()); expect(compileResult).toEqual([]); }); + + it('should replace inline style content with transformed content', async () => { + const {program, options, host} = makeProgram([ + { + name: _('/node_modules/@angular/core/index.d.ts'), + contents: 'export const Component: any;', + }, + { + name: _('/entry.ts'), + contents: ` + import {Component} from '@angular/core'; + + @Component({ + template: '', + styles: ['.abc {}'] + }) class TestCmp {} + ` + }, + ]); + const {reflectionHost, handler, resourceLoader} = setup(program, options, host); + resourceLoader.canPreload = true; + resourceLoader.canPreprocess = true; + resourceLoader.preprocessInline = async function(data, context) { + expect(data).toBe('.abc {}'); + expect(context.containingFile).toBe(_('/entry.ts').toLowerCase()); + expect(context.type).toBe('style'); + + return '.xyz {}'; + }; + + const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); + const detected = handler.detect(TestCmp, reflectionHost.getDecoratorsOfDeclaration(TestCmp)); + if (detected === undefined) { + return fail('Failed to recognize @Component'); + } + + await handler.preanalyze(TestCmp, detected.metadata); + + const {analysis} = handler.analyze(TestCmp, detected.metadata); + expect(analysis?.inlineStyles).toEqual(jasmine.arrayWithExactContents(['.xyz {}'])); + }); }); function ivyCode(code: ErrorCode): number { diff --git a/packages/compiler-cli/src/ngtsc/core/api/src/adapter.ts b/packages/compiler-cli/src/ngtsc/core/api/src/adapter.ts index ee93112ee9..b9ba08a2cc 100644 --- a/packages/compiler-cli/src/ngtsc/core/api/src/adapter.ts +++ b/packages/compiler-cli/src/ngtsc/core/api/src/adapter.ts @@ -28,7 +28,7 @@ export type ExtendedCompilerHostMethods = 'getCurrentDirectory'| // Additional methods of `ExtendedTsCompilerHost` related to resource files (e.g. HTML // templates). These are optional. - 'getModifiedResourceFiles'|'readResource'|'resourceNameToFileName'; + 'getModifiedResourceFiles'|'readResource'|'resourceNameToFileName'|'transformResource'; /** * Adapter for `NgCompiler` that allows it to be used in various circumstances, such as diff --git a/packages/compiler-cli/src/ngtsc/core/api/src/interfaces.ts b/packages/compiler-cli/src/ngtsc/core/api/src/interfaces.ts index 36184d8a81..3c3a2f0179 100644 --- a/packages/compiler-cli/src/ngtsc/core/api/src/interfaces.ts +++ b/packages/compiler-cli/src/ngtsc/core/api/src/interfaces.ts @@ -49,6 +49,52 @@ export interface ResourceHost { * or `undefined` if this is not an incremental build. */ getModifiedResourceFiles?(): Set|undefined; + + /** + * Transform an inline or external resource asynchronously. + * It is assumed the consumer of the corresponding `Program` will call + * `loadNgStructureAsync()`. Using outside `loadNgStructureAsync()` will + * cause a diagnostics error or an exception to be thrown. + * Only style resources are currently supported. + * + * @param data The resource data to transform. + * @param context Information regarding the resource such as the type and containing file. + * @returns A promise of either the transformed resource data or null if no transformation occurs. + */ + transformResource? + (data: string, context: ResourceHostContext): Promise; +} + +/** + * Contextual information used by members of the ResourceHost interface. + */ +export interface ResourceHostContext { + /** + * The type of the component resource. Templates are not yet supported. + * * Resources referenced via a component's `styles` or `styleUrls` properties are of + * type `style`. + */ + readonly type: 'style'; + /** + * The absolute path to the resource file. If the resource is inline, the value will be null. + */ + readonly resourceFile: string|null; + /** + * The absolute path to the file that contains the resource or reference to the resource. + */ + readonly containingFile: string; +} + +/** + * The successful transformation result of the `ResourceHost.transformResource` function. + * This interface may be expanded in the future to include diagnostic information and source mapping + * support. + */ +export interface TransformResourceResult { + /** + * The content generated by the transformation. + */ + content: string; } /** diff --git a/packages/compiler-cli/src/ngtsc/core/src/host.ts b/packages/compiler-cli/src/ngtsc/core/src/host.ts index 1dfeba4f18..b6378b9d2d 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/host.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/host.ts @@ -59,6 +59,7 @@ export class DelegatingCompilerHost implements readDirectory = this.delegateMethod('readDirectory'); readFile = this.delegateMethod('readFile'); readResource = this.delegateMethod('readResource'); + transformResource = this.delegateMethod('transformResource'); realpath = this.delegateMethod('realpath'); resolveModuleNames = this.delegateMethod('resolveModuleNames'); resolveTypeReferenceDirectives = this.delegateMethod('resolveTypeReferenceDirectives'); diff --git a/packages/compiler-cli/src/ngtsc/resource/src/loader.ts b/packages/compiler-cli/src/ngtsc/resource/src/loader.ts index d4abec76ff..9db512d65b 100644 --- a/packages/compiler-cli/src/ngtsc/resource/src/loader.ts +++ b/packages/compiler-cli/src/ngtsc/resource/src/loader.ts @@ -8,8 +8,8 @@ import * as ts from 'typescript'; -import {ResourceLoader} from '../../annotations'; -import {NgCompilerAdapter} from '../../core/api'; +import {ResourceLoader, ResourceLoaderContext} from '../../annotations'; +import {NgCompilerAdapter, ResourceHostContext} from '../../core/api'; import {AbsoluteFsPath, join, PathSegment} from '../../file_system'; import {RequiredDelegations} from '../../util/src/typescript'; @@ -27,6 +27,7 @@ export class AdapterResourceLoader implements ResourceLoader { private lookupResolutionHost = createLookupResolutionHost(this.adapter); canPreload = !!this.adapter.readResource; + canPreprocess = !!this.adapter.transformResource; constructor(private adapter: NgCompilerAdapter, private options: ts.CompilerOptions) {} @@ -62,11 +63,12 @@ export class AdapterResourceLoader implements ResourceLoader { * `load()` method. * * @param resolvedUrl The url (resolved by a call to `resolve()`) of the resource to preload. + * @param context Information about the resource such as the type and containing file. * @returns A Promise that is resolved once the resource has been loaded or `undefined` if the * file has already been loaded. * @throws An Error if pre-loading is not available. */ - preload(resolvedUrl: string): Promise|undefined { + preload(resolvedUrl: string, context: ResourceLoaderContext): Promise|undefined { if (!this.adapter.readResource) { throw new Error( 'HostResourceLoader: the CompilerHost provided does not support pre-loading resources.'); @@ -77,7 +79,20 @@ export class AdapterResourceLoader implements ResourceLoader { return this.fetching.get(resolvedUrl); } - const result = this.adapter.readResource(resolvedUrl); + let result = this.adapter.readResource(resolvedUrl); + + if (this.adapter.transformResource && context.type === 'style') { + const resourceContext: ResourceHostContext = { + type: 'style', + containingFile: context.containingFile, + resourceFile: resolvedUrl, + }; + result = Promise.resolve(result).then(async (str) => { + const transformResult = await this.adapter.transformResource!(str, resourceContext); + return transformResult === null ? str : transformResult.content; + }); + } + if (typeof result === 'string') { this.cache.set(resolvedUrl, result); return undefined; @@ -91,6 +106,28 @@ export class AdapterResourceLoader implements ResourceLoader { } } + /** + * Preprocess the content data of an inline resource, asynchronously. + * + * @param data The existing content data from the inline resource. + * @param context Information regarding the resource such as the type and containing file. + * @returns A Promise that resolves to the processed data. If no processing occurs, the + * same data string that was passed to the function will be resolved. + */ + async preprocessInline(data: string, context: ResourceLoaderContext): Promise { + if (!this.adapter.transformResource || context.type !== 'style') { + return data; + } + + const transformResult = await this.adapter.transformResource( + data, {type: 'style', containingFile: context.containingFile, resourceFile: null}); + if (transformResult === null) { + return data; + } + + return transformResult.content; + } + /** * Load the resource at the given url, synchronously. *