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. *