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
This commit is contained in:
Charles Lyding 2021-03-01 10:43:15 -05:00 committed by atscott
parent dc655262be
commit 1de04b124e
9 changed files with 231 additions and 32 deletions

View File

@ -40,9 +40,13 @@ import {isWithinPackage, NOOP_DEPENDENCY_TRACKER} from './util';
class NgccResourceLoader implements ResourceLoader { class NgccResourceLoader implements ResourceLoader {
constructor(private fs: ReadonlyFileSystem) {} constructor(private fs: ReadonlyFileSystem) {}
canPreload = false; canPreload = false;
canPreprocess = false;
preload(): undefined|Promise<void> { preload(): undefined|Promise<void> {
throw new Error('Not implemented.'); throw new Error('Not implemented.');
} }
preprocessInline(): Promise<string> {
throw new Error('Not implemented.');
}
load(url: string): string { load(url: string): string {
return this.fs.readFile(this.fs.resolve(url)); return this.fs.readFile(this.fs.resolve(url));
} }

View File

@ -8,7 +8,7 @@
/// <reference types="node" /> /// <reference types="node" />
export {ResourceLoader} from './src/api'; export {ResourceLoader, ResourceLoaderContext} from './src/api';
export {ComponentDecoratorHandler} from './src/component'; export {ComponentDecoratorHandler} from './src/component';
export {DirectiveDecoratorHandler} from './src/directive'; export {DirectiveDecoratorHandler} from './src/directive';
export {InjectableDecoratorHandler} from './src/injectable'; export {InjectableDecoratorHandler} from './src/injectable';

View File

@ -21,6 +21,11 @@ export interface ResourceLoader {
*/ */
canPreload: boolean; 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. * 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. * 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. * 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 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` * @returns A Promise that is resolved once the resource has been loaded or `undefined`
* if the file has already been loaded. * if the file has already been loaded.
* @throws An Error if pre-loading is not available. * @throws An Error if pre-loading is not available.
*/ */
preload(resolvedUrl: string): Promise<void>|undefined; preload(resolvedUrl: string, context: ResourceLoaderContext): Promise<void>|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<string>;
/** /**
* Load the resource at the given url, synchronously. * Load the resource at the given url, synchronously.
@ -53,3 +69,22 @@ export interface ResourceLoader {
*/ */
load(resolvedUrl: string): string; 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;
}

View File

@ -220,6 +220,7 @@ export class ComponentDecoratorHandler implements
* thrown away, and the parsed template is reused during the analyze phase. * thrown away, and the parsed template is reused during the analyze phase.
*/ */
private preanalyzeTemplateCache = new Map<DeclarationNode, ParsedTemplateWithSource>(); private preanalyzeTemplateCache = new Map<DeclarationNode, ParsedTemplateWithSource>();
private preanalyzeStylesCache = new Map<DeclarationNode, string[]|null>();
readonly precedence = HandlerPrecedence.PRIMARY; readonly precedence = HandlerPrecedence.PRIMARY;
readonly name = ComponentDecoratorHandler.name; readonly name = ComponentDecoratorHandler.name;
@ -266,7 +267,7 @@ export class ComponentDecoratorHandler implements
resourceType: ResourceTypeForDiagnostics): Promise<void>|undefined => { resourceType: ResourceTypeForDiagnostics): Promise<void>|undefined => {
const resourceUrl = const resourceUrl =
this._resolveResourceOrThrow(styleUrl, containingFile, nodeForError, resourceType); 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 <link>ed styles within it to be preloaded. // A Promise that waits for the template and all <link>ed styles within it to be preloaded.
@ -289,22 +290,33 @@ export class ComponentDecoratorHandler implements
// Extract all the styleUrls in the decorator. // Extract all the styleUrls in the decorator.
const componentStyleUrls = this._extractComponentStyleUrls(component); const componentStyleUrls = this._extractComponentStyleUrls(component);
if (componentStyleUrls === null) { // Extract inline styles, process, and cache for use in synchronous analyze phase
// A fast path exists if there are no styleUrls, to just wait for let inlineStyles;
// templateAndTemplateStyleResources. if (component.has('styles')) {
return templateAndTemplateStyleResources; const litStyles = parseFieldArrayValue(component, 'styles', this.evaluator);
} else { if (litStyles === null) {
// Wait for both the template and all styleUrl resources to resolve. this.preanalyzeStylesCache.set(node, null);
return Promise } else {
.all([ inlineStyles = Promise
templateAndTemplateStyleResources, .all(litStyles.map(
...componentStyleUrls.map( style => this.resourceLoader.preprocessInline(
styleUrl => resolveStyleUrl( style, {type: 'style', containingFile})))
styleUrl.url, styleUrl.nodeForError, .then(styles => {
ResourceTypeForDiagnostics.StylesheetFromDecorator)) this.preanalyzeStylesCache.set(node, styles);
]) });
.then(() => undefined); }
} }
// 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( analyze(
@ -409,12 +421,29 @@ export class ComponentDecoratorHandler implements
} }
} }
// If inline styles were preprocessed use those
let inlineStyles: string[]|null = null; let inlineStyles: string[]|null = null;
if (component.has('styles')) { if (this.preanalyzeStylesCache.has(node)) {
const litStyles = parseFieldArrayValue(component, 'styles', this.evaluator); inlineStyles = this.preanalyzeStylesCache.get(node)!;
if (litStyles !== null) { this.preanalyzeStylesCache.delete(node);
inlineStyles = [...litStyles]; if (inlineStyles !== null) {
styles.push(...litStyles); 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) { if (template.styles.length > 0) {
@ -979,7 +1008,8 @@ export class ComponentDecoratorHandler implements
} }
const resourceUrl = this._resolveResourceOrThrow( const resourceUrl = this._resolveResourceOrThrow(
templateUrl, containingFile, templateUrlExpr, ResourceTypeForDiagnostics.Template); 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 // If the preload worked, then actually load and parse the template, and wait for any style
// URLs to resolve. // URLs to resolve.

View File

@ -20,7 +20,7 @@ import {NOOP_PERF_RECORDER} from '../../perf';
import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection'; import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
import {LocalModuleScopeRegistry, MetadataDtsModuleScopeResolver, TypeCheckScopeRegistry} from '../../scope'; import {LocalModuleScopeRegistry, MetadataDtsModuleScopeResolver, TypeCheckScopeRegistry} from '../../scope';
import {getDeclaration, makeProgram} from '../../testing'; import {getDeclaration, makeProgram} from '../../testing';
import {ResourceLoader} from '../src/api'; import {ResourceLoader, ResourceLoaderContext} from '../src/api';
import {ComponentDecoratorHandler} from '../src/component'; import {ComponentDecoratorHandler} from '../src/component';
export class StubResourceLoader implements ResourceLoader { export class StubResourceLoader implements ResourceLoader {
@ -28,12 +28,16 @@ export class StubResourceLoader implements ResourceLoader {
return v; return v;
} }
canPreload = false; canPreload = false;
canPreprocess = false;
load(v: string): string { load(v: string): string {
return ''; return '';
} }
preload(): Promise<void>|undefined { preload(): Promise<void>|undefined {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
preprocessInline(_data: string, _context: ResourceLoaderContext): Promise<string> {
throw new Error('Not implemented');
}
} }
function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.CompilerHost) { 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 injectableRegistry = new InjectableClassRegistry(reflectionHost);
const resourceRegistry = new ResourceRegistry(); const resourceRegistry = new ResourceRegistry();
const typeCheckScopeRegistry = new TypeCheckScopeRegistry(scopeRegistry, metaReader); const typeCheckScopeRegistry = new TypeCheckScopeRegistry(scopeRegistry, metaReader);
const resourceLoader = new StubResourceLoader();
const handler = new ComponentDecoratorHandler( const handler = new ComponentDecoratorHandler(
reflectionHost, reflectionHost,
@ -65,7 +70,7 @@ function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.Compil
typeCheckScopeRegistry, typeCheckScopeRegistry,
resourceRegistry, resourceRegistry,
/* isCore */ false, /* isCore */ false,
new StubResourceLoader(), resourceLoader,
/* rootDirs */['/'], /* rootDirs */['/'],
/* defaultPreserveWhitespaces */ false, /* defaultPreserveWhitespaces */ false,
/* i18nUseExternalIds */ true, /* i18nUseExternalIds */ true,
@ -83,7 +88,7 @@ function setup(program: ts.Program, options: ts.CompilerOptions, host: ts.Compil
/* annotateForClosureCompiler */ false, /* annotateForClosureCompiler */ false,
NOOP_PERF_RECORDER, NOOP_PERF_RECORDER,
); );
return {reflectionHost, handler}; return {reflectionHost, handler, resourceLoader};
} }
runInEachFileSystem(() => { runInEachFileSystem(() => {
@ -257,6 +262,47 @@ runInEachFileSystem(() => {
handler.compileFull(TestCmp, analysis!, resolution.data!, new ConstantPool()); handler.compileFull(TestCmp, analysis!, resolution.data!, new ConstantPool());
expect(compileResult).toEqual([]); 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 { function ivyCode(code: ErrorCode): number {

View File

@ -28,7 +28,7 @@ export type ExtendedCompilerHostMethods =
'getCurrentDirectory'| 'getCurrentDirectory'|
// Additional methods of `ExtendedTsCompilerHost` related to resource files (e.g. HTML // Additional methods of `ExtendedTsCompilerHost` related to resource files (e.g. HTML
// templates). These are optional. // 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 * Adapter for `NgCompiler` that allows it to be used in various circumstances, such as

View File

@ -49,6 +49,52 @@ export interface ResourceHost {
* or `undefined` if this is not an incremental build. * or `undefined` if this is not an incremental build.
*/ */
getModifiedResourceFiles?(): Set<string>|undefined; getModifiedResourceFiles?(): Set<string>|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<TransformResourceResult|null>;
}
/**
* 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;
} }
/** /**

View File

@ -59,6 +59,7 @@ export class DelegatingCompilerHost implements
readDirectory = this.delegateMethod('readDirectory'); readDirectory = this.delegateMethod('readDirectory');
readFile = this.delegateMethod('readFile'); readFile = this.delegateMethod('readFile');
readResource = this.delegateMethod('readResource'); readResource = this.delegateMethod('readResource');
transformResource = this.delegateMethod('transformResource');
realpath = this.delegateMethod('realpath'); realpath = this.delegateMethod('realpath');
resolveModuleNames = this.delegateMethod('resolveModuleNames'); resolveModuleNames = this.delegateMethod('resolveModuleNames');
resolveTypeReferenceDirectives = this.delegateMethod('resolveTypeReferenceDirectives'); resolveTypeReferenceDirectives = this.delegateMethod('resolveTypeReferenceDirectives');

View File

@ -8,8 +8,8 @@
import * as ts from 'typescript'; import * as ts from 'typescript';
import {ResourceLoader} from '../../annotations'; import {ResourceLoader, ResourceLoaderContext} from '../../annotations';
import {NgCompilerAdapter} from '../../core/api'; import {NgCompilerAdapter, ResourceHostContext} from '../../core/api';
import {AbsoluteFsPath, join, PathSegment} from '../../file_system'; import {AbsoluteFsPath, join, PathSegment} from '../../file_system';
import {RequiredDelegations} from '../../util/src/typescript'; import {RequiredDelegations} from '../../util/src/typescript';
@ -27,6 +27,7 @@ export class AdapterResourceLoader implements ResourceLoader {
private lookupResolutionHost = createLookupResolutionHost(this.adapter); private lookupResolutionHost = createLookupResolutionHost(this.adapter);
canPreload = !!this.adapter.readResource; canPreload = !!this.adapter.readResource;
canPreprocess = !!this.adapter.transformResource;
constructor(private adapter: NgCompilerAdapter, private options: ts.CompilerOptions) {} constructor(private adapter: NgCompilerAdapter, private options: ts.CompilerOptions) {}
@ -62,11 +63,12 @@ export class AdapterResourceLoader implements ResourceLoader {
* `load()` method. * `load()` method.
* *
* @param resolvedUrl The url (resolved by a call to `resolve()`) of the resource to preload. * @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 * @returns A Promise that is resolved once the resource has been loaded or `undefined` if the
* file has already been loaded. * file has already been loaded.
* @throws An Error if pre-loading is not available. * @throws An Error if pre-loading is not available.
*/ */
preload(resolvedUrl: string): Promise<void>|undefined { preload(resolvedUrl: string, context: ResourceLoaderContext): Promise<void>|undefined {
if (!this.adapter.readResource) { if (!this.adapter.readResource) {
throw new Error( throw new Error(
'HostResourceLoader: the CompilerHost provided does not support pre-loading resources.'); 'HostResourceLoader: the CompilerHost provided does not support pre-loading resources.');
@ -77,7 +79,20 @@ export class AdapterResourceLoader implements ResourceLoader {
return this.fetching.get(resolvedUrl); 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') { if (typeof result === 'string') {
this.cache.set(resolvedUrl, result); this.cache.set(resolvedUrl, result);
return undefined; 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<string> {
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. * Load the resource at the given url, synchronously.
* *