From 5e5b2d9b1a15dc0f4f1690bab109bdd8e5613be3 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 7 Apr 2021 15:12:52 -0400 Subject: [PATCH] feat(@ngtools/webpack): support generating data URIs for inline component styles in JIT This change adds the new `inlineStyleMimeType` option. 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`). --- packages/ngtools/webpack/README.md | 1 + packages/ngtools/webpack/src/ivy/plugin.ts | 1 + .../ngtools/webpack/src/ivy/transformation.ts | 15 +++- .../src/transformers/replace_resources.ts | 71 ++++++++++++++----- .../transformers/replace_resources_spec.ts | 47 +++++++++++- 5 files changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/ngtools/webpack/README.md b/packages/ngtools/webpack/README.md index 54be0bc51bd4..eb1d8e97b1d1 100644 --- a/packages/ngtools/webpack/README.md +++ b/packages/ngtools/webpack/README.md @@ -36,3 +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. diff --git a/packages/ngtools/webpack/src/ivy/plugin.ts b/packages/ngtools/webpack/src/ivy/plugin.ts index d53cb193c02a..03c806a3737e 100644 --- a/packages/ngtools/webpack/src/ivy/plugin.ts +++ b/packages/ngtools/webpack/src/ivy/plugin.ts @@ -47,6 +47,7 @@ export interface AngularWebpackPluginOptions { emitClassMetadata: boolean; emitNgModuleScope: boolean; jitMode: boolean; + inlineStyleMimeType?: string; } // Add support for missing properties in Webpack types as well as the loader's file emitter diff --git a/packages/ngtools/webpack/src/ivy/transformation.ts b/packages/ngtools/webpack/src/ivy/transformation.ts index 29eee28d6e3e..1e34732564cc 100644 --- a/packages/ngtools/webpack/src/ivy/transformation.ts +++ b/packages/ngtools/webpack/src/ivy/transformation.ts @@ -35,13 +35,18 @@ export function createAotTransformers( export function createJitTransformers( builder: ts.BuilderProgram, - options: { directTemplateLoading?: boolean }, + options: { directTemplateLoading?: boolean; inlineStyleMimeType?: string }, ): ts.CustomTransformers { const getTypeChecker = () => builder.getProgram().getTypeChecker(); return { before: [ - replaceResources(() => true, getTypeChecker, options.directTemplateLoading), + replaceResources( + () => true, + getTypeChecker, + options.directTemplateLoading, + options.inlineStyleMimeType, + ), constructorParametersDownlevelTransform(builder.getProgram()), ], }; @@ -89,7 +94,11 @@ export function replaceBootstrap( bootstrapImport = nodeFactory.createImportDeclaration( undefined, undefined, - nodeFactory.createImportClause(false, undefined, nodeFactory.createNamespaceImport(bootstrapNamespace)), + nodeFactory.createImportClause( + false, + undefined, + nodeFactory.createNamespaceImport(bootstrapNamespace), + ), nodeFactory.createStringLiteral('@angular/platform-browser'), ); } diff --git a/packages/ngtools/webpack/src/transformers/replace_resources.ts b/packages/ngtools/webpack/src/transformers/replace_resources.ts index 80ec4b146374..2850c0639c86 100644 --- a/packages/ngtools/webpack/src/transformers/replace_resources.ts +++ b/packages/ngtools/webpack/src/transformers/replace_resources.ts @@ -11,7 +11,12 @@ export function replaceResources( shouldTransform: (fileName: string) => boolean, getTypeChecker: () => ts.TypeChecker, directTemplateLoading = false, + inlineStyleMimeType?: string, ): ts.TransformerFactory { + if (inlineStyleMimeType && !/^text\/[-.\w]+$/.test(inlineStyleMimeType)) { + throw new Error('Invalid inline style MIME type.'); + } + return (context: ts.TransformationContext) => { const typeChecker = getTypeChecker(); const resourceImportDeclarations: ts.ImportDeclaration[] = []; @@ -20,9 +25,17 @@ export function replaceResources( const visitNode: ts.Visitor = (node: ts.Node) => { if (ts.isClassDeclaration(node)) { - const decorators = ts.visitNodes(node.decorators, node => + const decorators = ts.visitNodes(node.decorators, (node) => ts.isDecorator(node) - ? visitDecorator(nodeFactory, node, typeChecker, directTemplateLoading, resourceImportDeclarations, moduleKind) + ? visitDecorator( + nodeFactory, + node, + typeChecker, + directTemplateLoading, + resourceImportDeclarations, + moduleKind, + inlineStyleMimeType, + ) : node, ); @@ -72,6 +85,7 @@ function visitDecorator( directTemplateLoading: boolean, resourceImportDeclarations: ts.ImportDeclaration[], moduleKind?: ts.ModuleKind, + inlineStyleMimeType?: string, ): ts.Decorator { if (!isComponentDecorator(node, typeChecker)) { return node; @@ -92,9 +106,17 @@ function visitDecorator( const styleReplacements: ts.Expression[] = []; // visit all properties - let properties = ts.visitNodes(objectExpression.properties, node => + let properties = ts.visitNodes(objectExpression.properties, (node) => ts.isObjectLiteralElementLike(node) - ? visitComponentMetadata(nodeFactory, node, styleReplacements, directTemplateLoading, resourceImportDeclarations, moduleKind) + ? visitComponentMetadata( + nodeFactory, + node, + styleReplacements, + directTemplateLoading, + resourceImportDeclarations, + moduleKind, + inlineStyleMimeType, + ) : node, ); @@ -123,6 +145,7 @@ function visitComponentMetadata( directTemplateLoading: boolean, resourceImportDeclarations: ts.ImportDeclaration[], moduleKind?: ts.ModuleKind, + inlineStyleMimeType?: string, ): ts.ObjectLiteralElementLike | undefined { if (!ts.isPropertyAssignment(node) || ts.isComputedPropertyName(node.name)) { return node; @@ -134,10 +157,14 @@ function visitComponentMetadata( return undefined; case 'templateUrl': + const url = getResourceUrl(node.initializer, directTemplateLoading ? '!raw-loader!' : ''); + if (!url) { + return node; + } + const importName = createResourceImport( nodeFactory, - node.initializer, - directTemplateLoading ? '!raw-loader!' : '', + url, resourceImportDeclarations, moduleKind, ); @@ -156,21 +183,33 @@ function visitComponentMetadata( return node; } - const isInlineStyles = name === 'styles'; + const isInlineStyle = name === 'styles'; const styles = ts.visitNodes(node.initializer.elements, node => { if (!ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node)) { return node; } - if (isInlineStyles) { - return nodeFactory.createStringLiteral(node.text); + let url; + if (isInlineStyle) { + if (inlineStyleMimeType) { + const data = Buffer.from(node.text).toString('base64'); + url = `data:${inlineStyleMimeType};charset=utf-8;base64,${data}`; + } else { + return nodeFactory.createStringLiteral(node.text); + } + } else { + url = getResourceUrl(node); } - return createResourceImport(nodeFactory, node, undefined, resourceImportDeclarations, moduleKind) || node; + if (!url) { + return node; + } + + return createResourceImport(nodeFactory, url, resourceImportDeclarations, moduleKind); }); // Styles should be placed first - if (isInlineStyles) { + if (isInlineStyle) { styleReplacements.unshift(...styles); } else { styleReplacements.push(...styles); @@ -206,16 +245,10 @@ function isComponentDecorator(node: ts.Node, typeChecker: ts.TypeChecker): node function createResourceImport( nodeFactory: ts.NodeFactory, - node: ts.Node, - loader: string | undefined, + url: string, resourceImportDeclarations: ts.ImportDeclaration[], moduleKind = ts.ModuleKind.ES2015, -): ts.Identifier | ts.Expression | null { - const url = getResourceUrl(node, loader); - if (!url) { - return null; - } - +): ts.Identifier | ts.Expression { const urlLiteral = nodeFactory.createStringLiteral(url); if (moduleKind < ts.ModuleKind.ES2015) { diff --git a/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts b/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts index c07660351a15..b34f458c8a73 100644 --- a/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts +++ b/packages/ngtools/webpack/src/transformers/replace_resources_spec.ts @@ -16,11 +16,12 @@ function transform( directTemplateLoading = true, importHelpers = true, module: ts.ModuleKind = ts.ModuleKind.ESNext, + inlineStyleMimeType?: string, ) { const { program, compilerHost } = createTypescriptContext(input, undefined, undefined, { importHelpers, module }); const getTypeChecker = () => program.getTypeChecker(); const transformer = replaceResources( - () => shouldTransform, getTypeChecker, directTemplateLoading); + () => shouldTransform, getTypeChecker, directTemplateLoading, inlineStyleMimeType); return transformTypescript(input, [transformer], program, compilerHost); } @@ -219,6 +220,50 @@ describe('@ngtools/webpack transformers', () => { expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); + it('should create data URIs for inline styles when inlineStyleMimeType is set', () => { + const input = tags.stripIndent` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styles: ['a { color: red }'], + }) + export class AppComponent { + title = 'app'; + } + `; + const output = tags.stripIndent` + import { __decorate } from "tslib"; + import __NG_CLI_RESOURCE__0 from "!raw-loader!./app.component.html"; + import __NG_CLI_RESOURCE__1 from "data:text/css;charset=utf-8;base64,YSB7IGNvbG9yOiByZWQgfQ=="; + import { Component } from '@angular/core'; + + let AppComponent = class AppComponent { + constructor() { + this.title = 'app'; + } + }; + AppComponent = __decorate([ + Component({ + selector: 'app-root', + template: __NG_CLI_RESOURCE__0, + styles: [__NG_CLI_RESOURCE__1] + }) + ], AppComponent); + export { AppComponent }; + `; + + const result = transform(input, true, true, true, ts.ModuleKind.ESNext, 'text/css'); + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should throw error if inlineStyleMimeType value has invalid format', () => { + expect(() => + transform('', true, true, true, ts.ModuleKind.ESNext, 'asdfsd;sdfsd//sdfsdf'), + ).toThrowError('Invalid inline style MIME type.'); + }); + it('should replace resources with backticks', () => { const input = ` import { Component } from '@angular/core';