From 8c7d56e03adb9c3303760fc2e38e2d6d96452bac Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Sat, 10 Apr 2021 10:16:27 -0400 Subject: [PATCH] feat(@ngtools/webpack): support processing inline component styles in AOT This change updates the Angular Webpack Plugin's resource loader to support processing styles that do not exist on disk when the `inlineStyleMimeType` option is used. --- packages/ngtools/webpack/README.md | 2 +- packages/ngtools/webpack/src/ivy/host.ts | 26 +++- packages/ngtools/webpack/src/ivy/plugin.ts | 1 + .../ngtools/webpack/src/resource_loader.ts | 130 ++++++++++++------ .../src/transformers/replace_resources.ts | 72 ++++++++++ 5 files changed, 188 insertions(+), 43 deletions(-) diff --git a/packages/ngtools/webpack/README.md b/packages/ngtools/webpack/README.md index eb1d8e97b1d1..ac90027185c9 100644 --- a/packages/ngtools/webpack/README.md +++ b/packages/ngtools/webpack/README.md @@ -36,4 +36,4 @@ The loader works with webpack plugin to compile the application's TypeScript. It * `jitMode` [default: `false`] - Enables JIT compilation and do not refactor the code to bootstrap. This replaces `templateUrl: "string"` with `template: require("string")` (and similar for styles) to allow for webpack to properly link the resources. * `directTemplateLoading` [default: `true`] - Causes the plugin to load component templates (HTML) directly from the filesystem. This is more efficient if only using the `raw-loader` to load component templates. Do not enable this option if additional loaders are configured for component templates. * `fileReplacements` [default: none] - Allows replacing TypeScript files with other TypeScript files in the build. This option acts on fully resolved file paths. -* `inlineStyleMimeType` [default: none] - When set to a valid MIME type, enables conversion of an Angular Component's inline styles into data URIs. This allows a Webpack 5 configuration rule to use the `mimetype` condition to process the inline styles. A valid MIME type is a string starting with `text/` (Example for CSS: `text/css`). Currently only supported in JIT mode. +* `inlineStyleMimeType` [default: none] - When set to a valid MIME type, enables conversion of an Angular Component's inline styles into data URIs. This allows a Webpack 5 configuration rule to use the `mimetype` condition to process the inline styles. A valid MIME type is a string starting with `text/` (Example for CSS: `text/css`). diff --git a/packages/ngtools/webpack/src/ivy/host.ts b/packages/ngtools/webpack/src/ivy/host.ts index d832e0adde09..8dbb9e818810 100644 --- a/packages/ngtools/webpack/src/ivy/host.ts +++ b/packages/ngtools/webpack/src/ivy/host.ts @@ -11,12 +11,13 @@ import * as path from 'path'; import * as ts from 'typescript'; import { NgccProcessor } from '../ngcc_processor'; import { WebpackResourceLoader } from '../resource_loader'; +import { workaroundStylePreprocessing } from '../transformers'; import { normalizePath } from './paths'; export function augmentHostWithResources( host: ts.CompilerHost, resourceLoader: WebpackResourceLoader, - options: { directTemplateLoading?: boolean } = {}, + options: { directTemplateLoading?: boolean, inlineStyleMimeType?: string } = {}, ) { const resourceHost = host as CompilerHost; @@ -47,6 +48,24 @@ export function augmentHostWithResources( resourceHost.getModifiedResourceFiles = function () { return resourceLoader.getModifiedResourceFiles(); }; + + resourceHost.transformResource = async function (data, context) { + // Only inline style resources are supported currently + if (context.resourceFile || context.type !== 'style') { + return null; + } + + if (options.inlineStyleMimeType) { + const content = await resourceLoader.process( + data, + options.inlineStyleMimeType, + ); + + return { content }; + } + + return null; + }; } function augmentResolveModuleNames( @@ -332,6 +351,11 @@ export function augmentHostWithCaching( ); if (file) { + // Temporary workaround for upstream transform resource defect + if (file && !file.isDeclarationFile && file.text.includes('@Component')) { + workaroundStylePreprocessing(file); + } + cache.set(fileName, file); } diff --git a/packages/ngtools/webpack/src/ivy/plugin.ts b/packages/ngtools/webpack/src/ivy/plugin.ts index 03c806a3737e..e92e38430477 100644 --- a/packages/ngtools/webpack/src/ivy/plugin.ts +++ b/packages/ngtools/webpack/src/ivy/plugin.ts @@ -233,6 +233,7 @@ export class AngularWebpackPlugin { resourceLoader.update(compilation, changedFiles); augmentHostWithResources(host, resourceLoader, { directTemplateLoading: this.pluginOptions.directTemplateLoading, + inlineStyleMimeType: this.pluginOptions.inlineStyleMimeType, }); // Setup source file adjustment options diff --git a/packages/ngtools/webpack/src/resource_loader.ts b/packages/ngtools/webpack/src/resource_loader.ts index 4a2aee963c17..9d107fc2e7ad 100644 --- a/packages/ngtools/webpack/src/resource_loader.ts +++ b/packages/ngtools/webpack/src/resource_loader.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import * as vm from 'vm'; -import { Compilation } from 'webpack'; +import { Compilation, NormalModule } from 'webpack'; import { RawSource } from 'webpack-sources'; import { normalizePath } from './ivy/paths'; import { isWebpackFiveOrHigher } from './webpack-version'; @@ -29,6 +29,7 @@ export class WebpackResourceLoader { private cache = new Map(); private modifiedResources = new Set(); + private outputPathCounter = 1; update( parentCompilation: Compilation, @@ -66,19 +67,27 @@ export class WebpackResourceLoader { this._reverseDependencies.set(file, new Set(resources)); } - private async _compile(filePath: string): Promise { + private async _compile( + filePath?: string, + data?: string, + mimeType?: string, + ): Promise { if (!this._parentCompilation) { throw new Error('WebpackResourceLoader cannot be used without parentCompilation'); } // Simple sanity check. - if (filePath.match(/\.[jt]s$/)) { + if (filePath?.match(/\.[jt]s$/)) { return Promise.reject( `Cannot use a JavaScript or TypeScript file (${filePath}) in a component's styleUrls or templateUrl.`, ); } - const outputOptions = { filename: filePath }; + // Create a special URL for reading the resource from memory + const angularScheme = 'angular-resource://'; + + const outputFilePath = filePath || `angular-resource-output-${this.outputPathCounter++}.css`; + const outputOptions = { filename: outputFilePath }; const context = this._parentCompilation.compiler.context; const childCompiler = this._parentCompilation.createChildCompiler( 'angular-compiler:resource', @@ -86,53 +95,80 @@ export class WebpackResourceLoader { [ new NodeTemplatePlugin(outputOptions), new NodeTargetPlugin(), - new SingleEntryPlugin(context, filePath, 'resource'), + new SingleEntryPlugin(context, data ? angularScheme : filePath, 'resource'), new LibraryTemplatePlugin('resource', 'var'), ], ); - childCompiler.hooks.thisCompilation.tap('angular-compiler', (compilation) => { - compilation.hooks.additionalAssets.tap('angular-compiler', () => { - const asset = compilation.assets[filePath]; - if (!asset) { - return; + childCompiler.hooks.thisCompilation.tap( + 'angular-compiler', + (compilation, { normalModuleFactory }) => { + // If no data is provided, the resource will be read from the filesystem + if (data !== undefined) { + normalModuleFactory.hooks.resolveForScheme + .for('angular-resource') + .tap('angular-compiler', (resourceData) => { + if (filePath) { + resourceData.path = filePath; + resourceData.resource = filePath; + } + + if (mimeType) { + resourceData.data.mimetype = mimeType; + } + + return true; + }); + NormalModule.getCompilationHooks(compilation) + .readResourceForScheme.for('angular-resource') + .tap('angular-compiler', () => data); } - try { - const output = this._evaluate(filePath, asset.source().toString()); + compilation.hooks.additionalAssets.tap('angular-compiler', () => { + const asset = compilation.assets[outputFilePath]; + if (!asset) { + return; + } - if (typeof output === 'string') { - // `webpack-sources` package has incomplete typings - // tslint:disable-next-line: no-any - compilation.assets[filePath] = new RawSource(output) as any; + try { + const output = this._evaluate(outputFilePath, asset.source().toString()); + + if (typeof output === 'string') { + // `webpack-sources` package has incomplete typings + // tslint:disable-next-line: no-any + compilation.assets[outputFilePath] = new RawSource(output) as any; + } + } catch (error) { + // Use compilation errors, as otherwise webpack will choke + compilation.errors.push(error); } - } catch (error) { - // Use compilation errors, as otherwise webpack will choke - compilation.errors.push(error); - } - }); - }); + }); + }, + ); let finalContent: string | undefined; let finalMap: string | undefined; if (isWebpackFiveOrHigher()) { childCompiler.hooks.compilation.tap('angular-compiler', (childCompilation) => { // tslint:disable-next-line: no-any - (childCompilation.hooks as any).processAssets.tap({name: 'angular-compiler', stage: 5000}, () => { - finalContent = childCompilation.assets[filePath]?.source().toString(); - finalMap = childCompilation.assets[filePath + '.map']?.source().toString(); - - delete childCompilation.assets[filePath]; - delete childCompilation.assets[filePath + '.map']; - }); + (childCompilation.hooks as any).processAssets.tap( + { name: 'angular-compiler', stage: 5000 }, + () => { + finalContent = childCompilation.assets[outputFilePath]?.source().toString(); + finalMap = childCompilation.assets[outputFilePath + '.map']?.source().toString(); + + delete childCompilation.assets[outputFilePath]; + delete childCompilation.assets[outputFilePath + '.map']; + }, + ); }); } else { childCompiler.hooks.afterCompile.tap('angular-compiler', (childCompilation) => { - finalContent = childCompilation.assets[filePath]?.source().toString(); - finalMap = childCompilation.assets[filePath + '.map']?.source().toString(); + finalContent = childCompilation.assets[outputFilePath]?.source().toString(); + finalMap = childCompilation.assets[outputFilePath + '.map']?.source().toString(); - delete childCompilation.assets[filePath]; - delete childCompilation.assets[filePath + '.map']; + delete childCompilation.assets[outputFilePath]; + delete childCompilation.assets[outputFilePath + '.map']; }); } @@ -149,14 +185,16 @@ export class WebpackResourceLoader { } // Save the dependencies for this resource. - this._fileDependencies.set(filePath, new Set(childCompilation.fileDependencies)); - for (const file of childCompilation.fileDependencies) { - const resolvedFile = normalizePath(file); - const entry = this._reverseDependencies.get(resolvedFile); - if (entry) { - entry.add(filePath); - } else { - this._reverseDependencies.set(resolvedFile, new Set([filePath])); + if (filePath) { + this._fileDependencies.set(filePath, new Set(childCompilation.fileDependencies)); + for (const file of childCompilation.fileDependencies) { + const resolvedFile = normalizePath(file); + const entry = this._reverseDependencies.get(resolvedFile); + if (entry) { + entry.add(filePath); + } else { + this._reverseDependencies.set(resolvedFile, new Set([filePath])); + } } } @@ -205,4 +243,14 @@ export class WebpackResourceLoader { return compilationResult.content; } + + async process(data: string, mimeType: string): Promise { + if (data.trim().length === 0) { + return ''; + } + + const compilationResult = await this._compile(undefined, data, mimeType); + + return compilationResult.content; + } } diff --git a/packages/ngtools/webpack/src/transformers/replace_resources.ts b/packages/ngtools/webpack/src/transformers/replace_resources.ts index 2850c0639c86..200b4e928e92 100644 --- a/packages/ngtools/webpack/src/transformers/replace_resources.ts +++ b/packages/ngtools/webpack/src/transformers/replace_resources.ts @@ -322,3 +322,75 @@ function getDecoratorOrigin( return null; } + +export function workaroundStylePreprocessing(sourceFile: ts.SourceFile): void { + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isClassDeclaration(node) && node.decorators?.length) { + for (const decorator of node.decorators) { + visitDecoratorWorkaround(decorator); + } + } + + return ts.forEachChild(node, visitNode); + }; + + ts.forEachChild(sourceFile, visitNode); +} + +function visitDecoratorWorkaround(node: ts.Decorator): void { + if (!ts.isCallExpression(node.expression)) { + return; + } + + const decoratorFactory = node.expression; + if ( + !ts.isIdentifier(decoratorFactory.expression) || + decoratorFactory.expression.text !== 'Component' + ) { + return; + } + + const args = decoratorFactory.arguments; + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + // Unsupported component metadata + return; + } + + const objectExpression = args[0] as ts.ObjectLiteralExpression; + + // check if a `styles` property is present + let hasStyles = false; + for (const element of objectExpression.properties) { + if (!ts.isPropertyAssignment(element) || ts.isComputedPropertyName(element.name)) { + continue; + } + + if (element.name.text === 'styles') { + hasStyles = true; + break; + } + } + + if (hasStyles) { + return; + } + + const nodeFactory = ts.factory; + + // add a `styles` property to workaround upstream compiler defect + const emptyArray = nodeFactory.createArrayLiteralExpression(); + const stylePropertyName = nodeFactory.createIdentifier('styles'); + const styleProperty = nodeFactory.createPropertyAssignment(stylePropertyName, emptyArray); + // tslint:disable-next-line: no-any + (stylePropertyName.parent as any) = styleProperty; + // tslint:disable-next-line: no-any + (emptyArray.parent as any) = styleProperty; + // tslint:disable-next-line: no-any + (styleProperty.parent as any) = objectExpression; + + // tslint:disable-next-line: no-any + (objectExpression.properties as any) = nodeFactory.createNodeArray([ + ...objectExpression.properties, + styleProperty, + ]); +}