Skip to content

Commit

Permalink
feat(@ngtools/webpack): support processing inline component styles in…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
clydin committed Apr 12, 2021
1 parent 5e5b2d9 commit 8c7d56e
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 43 deletions.
2 changes: 1 addition & 1 deletion packages/ngtools/webpack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
26 changes: 25 additions & 1 deletion packages/ngtools/webpack/src/ivy/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions packages/ngtools/webpack/src/ivy/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 89 additions & 41 deletions packages/ngtools/webpack/src/resource_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,6 +29,7 @@ export class WebpackResourceLoader {

private cache = new Map<string, CompilationOutput>();
private modifiedResources = new Set<string>();
private outputPathCounter = 1;

update(
parentCompilation: Compilation,
Expand Down Expand Up @@ -66,73 +67,108 @@ export class WebpackResourceLoader {
this._reverseDependencies.set(file, new Set(resources));
}

private async _compile(filePath: string): Promise<CompilationOutput> {
private async _compile(
filePath?: string,
data?: string,
mimeType?: string,
): Promise<CompilationOutput> {
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',
outputOptions,
[
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'];
});
}

Expand All @@ -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]));
}
}
}

Expand Down Expand Up @@ -205,4 +243,14 @@ export class WebpackResourceLoader {

return compilationResult.content;
}

async process(data: string, mimeType: string): Promise<string> {
if (data.trim().length === 0) {
return '';
}

const compilationResult = await this._compile(undefined, data, mimeType);

return compilationResult.content;
}
}
72 changes: 72 additions & 0 deletions packages/ngtools/webpack/src/transformers/replace_resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}

0 comments on commit 8c7d56e

Please sign in to comment.