Skip to content

Commit 8c7d56e

Browse files
committed
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.
1 parent 5e5b2d9 commit 8c7d56e

File tree

5 files changed

+188
-43
lines changed

5 files changed

+188
-43
lines changed

packages/ngtools/webpack/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ The loader works with webpack plugin to compile the application's TypeScript. It
3636
* `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.
3737
* `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.
3838
* `fileReplacements` [default: none] - Allows replacing TypeScript files with other TypeScript files in the build. This option acts on fully resolved file paths.
39-
* `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.
39+
* `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`).

packages/ngtools/webpack/src/ivy/host.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import * as path from 'path';
1111
import * as ts from 'typescript';
1212
import { NgccProcessor } from '../ngcc_processor';
1313
import { WebpackResourceLoader } from '../resource_loader';
14+
import { workaroundStylePreprocessing } from '../transformers';
1415
import { normalizePath } from './paths';
1516

1617
export function augmentHostWithResources(
1718
host: ts.CompilerHost,
1819
resourceLoader: WebpackResourceLoader,
19-
options: { directTemplateLoading?: boolean } = {},
20+
options: { directTemplateLoading?: boolean, inlineStyleMimeType?: string } = {},
2021
) {
2122
const resourceHost = host as CompilerHost;
2223

@@ -47,6 +48,24 @@ export function augmentHostWithResources(
4748
resourceHost.getModifiedResourceFiles = function () {
4849
return resourceLoader.getModifiedResourceFiles();
4950
};
51+
52+
resourceHost.transformResource = async function (data, context) {
53+
// Only inline style resources are supported currently
54+
if (context.resourceFile || context.type !== 'style') {
55+
return null;
56+
}
57+
58+
if (options.inlineStyleMimeType) {
59+
const content = await resourceLoader.process(
60+
data,
61+
options.inlineStyleMimeType,
62+
);
63+
64+
return { content };
65+
}
66+
67+
return null;
68+
};
5069
}
5170

5271
function augmentResolveModuleNames(
@@ -332,6 +351,11 @@ export function augmentHostWithCaching(
332351
);
333352

334353
if (file) {
354+
// Temporary workaround for upstream transform resource defect
355+
if (file && !file.isDeclarationFile && file.text.includes('@Component')) {
356+
workaroundStylePreprocessing(file);
357+
}
358+
335359
cache.set(fileName, file);
336360
}
337361

packages/ngtools/webpack/src/ivy/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ export class AngularWebpackPlugin {
233233
resourceLoader.update(compilation, changedFiles);
234234
augmentHostWithResources(host, resourceLoader, {
235235
directTemplateLoading: this.pluginOptions.directTemplateLoading,
236+
inlineStyleMimeType: this.pluginOptions.inlineStyleMimeType,
236237
});
237238

238239
// Setup source file adjustment options

packages/ngtools/webpack/src/resource_loader.ts

Lines changed: 89 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import * as vm from 'vm';
9-
import { Compilation } from 'webpack';
9+
import { Compilation, NormalModule } from 'webpack';
1010
import { RawSource } from 'webpack-sources';
1111
import { normalizePath } from './ivy/paths';
1212
import { isWebpackFiveOrHigher } from './webpack-version';
@@ -29,6 +29,7 @@ export class WebpackResourceLoader {
2929

3030
private cache = new Map<string, CompilationOutput>();
3131
private modifiedResources = new Set<string>();
32+
private outputPathCounter = 1;
3233

3334
update(
3435
parentCompilation: Compilation,
@@ -66,73 +67,108 @@ export class WebpackResourceLoader {
6667
this._reverseDependencies.set(file, new Set(resources));
6768
}
6869

69-
private async _compile(filePath: string): Promise<CompilationOutput> {
70+
private async _compile(
71+
filePath?: string,
72+
data?: string,
73+
mimeType?: string,
74+
): Promise<CompilationOutput> {
7075
if (!this._parentCompilation) {
7176
throw new Error('WebpackResourceLoader cannot be used without parentCompilation');
7277
}
7378

7479
// Simple sanity check.
75-
if (filePath.match(/\.[jt]s$/)) {
80+
if (filePath?.match(/\.[jt]s$/)) {
7681
return Promise.reject(
7782
`Cannot use a JavaScript or TypeScript file (${filePath}) in a component's styleUrls or templateUrl.`,
7883
);
7984
}
8085

81-
const outputOptions = { filename: filePath };
86+
// Create a special URL for reading the resource from memory
87+
const angularScheme = 'angular-resource://';
88+
89+
const outputFilePath = filePath || `angular-resource-output-${this.outputPathCounter++}.css`;
90+
const outputOptions = { filename: outputFilePath };
8291
const context = this._parentCompilation.compiler.context;
8392
const childCompiler = this._parentCompilation.createChildCompiler(
8493
'angular-compiler:resource',
8594
outputOptions,
8695
[
8796
new NodeTemplatePlugin(outputOptions),
8897
new NodeTargetPlugin(),
89-
new SingleEntryPlugin(context, filePath, 'resource'),
98+
new SingleEntryPlugin(context, data ? angularScheme : filePath, 'resource'),
9099
new LibraryTemplatePlugin('resource', 'var'),
91100
],
92101
);
93102

94-
childCompiler.hooks.thisCompilation.tap('angular-compiler', (compilation) => {
95-
compilation.hooks.additionalAssets.tap('angular-compiler', () => {
96-
const asset = compilation.assets[filePath];
97-
if (!asset) {
98-
return;
103+
childCompiler.hooks.thisCompilation.tap(
104+
'angular-compiler',
105+
(compilation, { normalModuleFactory }) => {
106+
// If no data is provided, the resource will be read from the filesystem
107+
if (data !== undefined) {
108+
normalModuleFactory.hooks.resolveForScheme
109+
.for('angular-resource')
110+
.tap('angular-compiler', (resourceData) => {
111+
if (filePath) {
112+
resourceData.path = filePath;
113+
resourceData.resource = filePath;
114+
}
115+
116+
if (mimeType) {
117+
resourceData.data.mimetype = mimeType;
118+
}
119+
120+
return true;
121+
});
122+
NormalModule.getCompilationHooks(compilation)
123+
.readResourceForScheme.for('angular-resource')
124+
.tap('angular-compiler', () => data);
99125
}
100126

101-
try {
102-
const output = this._evaluate(filePath, asset.source().toString());
127+
compilation.hooks.additionalAssets.tap('angular-compiler', () => {
128+
const asset = compilation.assets[outputFilePath];
129+
if (!asset) {
130+
return;
131+
}
103132

104-
if (typeof output === 'string') {
105-
// `webpack-sources` package has incomplete typings
106-
// tslint:disable-next-line: no-any
107-
compilation.assets[filePath] = new RawSource(output) as any;
133+
try {
134+
const output = this._evaluate(outputFilePath, asset.source().toString());
135+
136+
if (typeof output === 'string') {
137+
// `webpack-sources` package has incomplete typings
138+
// tslint:disable-next-line: no-any
139+
compilation.assets[outputFilePath] = new RawSource(output) as any;
140+
}
141+
} catch (error) {
142+
// Use compilation errors, as otherwise webpack will choke
143+
compilation.errors.push(error);
108144
}
109-
} catch (error) {
110-
// Use compilation errors, as otherwise webpack will choke
111-
compilation.errors.push(error);
112-
}
113-
});
114-
});
145+
});
146+
},
147+
);
115148

116149
let finalContent: string | undefined;
117150
let finalMap: string | undefined;
118151
if (isWebpackFiveOrHigher()) {
119152
childCompiler.hooks.compilation.tap('angular-compiler', (childCompilation) => {
120153
// tslint:disable-next-line: no-any
121-
(childCompilation.hooks as any).processAssets.tap({name: 'angular-compiler', stage: 5000}, () => {
122-
finalContent = childCompilation.assets[filePath]?.source().toString();
123-
finalMap = childCompilation.assets[filePath + '.map']?.source().toString();
124-
125-
delete childCompilation.assets[filePath];
126-
delete childCompilation.assets[filePath + '.map'];
127-
});
154+
(childCompilation.hooks as any).processAssets.tap(
155+
{ name: 'angular-compiler', stage: 5000 },
156+
() => {
157+
finalContent = childCompilation.assets[outputFilePath]?.source().toString();
158+
finalMap = childCompilation.assets[outputFilePath + '.map']?.source().toString();
159+
160+
delete childCompilation.assets[outputFilePath];
161+
delete childCompilation.assets[outputFilePath + '.map'];
162+
},
163+
);
128164
});
129165
} else {
130166
childCompiler.hooks.afterCompile.tap('angular-compiler', (childCompilation) => {
131-
finalContent = childCompilation.assets[filePath]?.source().toString();
132-
finalMap = childCompilation.assets[filePath + '.map']?.source().toString();
167+
finalContent = childCompilation.assets[outputFilePath]?.source().toString();
168+
finalMap = childCompilation.assets[outputFilePath + '.map']?.source().toString();
133169

134-
delete childCompilation.assets[filePath];
135-
delete childCompilation.assets[filePath + '.map'];
170+
delete childCompilation.assets[outputFilePath];
171+
delete childCompilation.assets[outputFilePath + '.map'];
136172
});
137173
}
138174

@@ -149,14 +185,16 @@ export class WebpackResourceLoader {
149185
}
150186

151187
// Save the dependencies for this resource.
152-
this._fileDependencies.set(filePath, new Set(childCompilation.fileDependencies));
153-
for (const file of childCompilation.fileDependencies) {
154-
const resolvedFile = normalizePath(file);
155-
const entry = this._reverseDependencies.get(resolvedFile);
156-
if (entry) {
157-
entry.add(filePath);
158-
} else {
159-
this._reverseDependencies.set(resolvedFile, new Set([filePath]));
188+
if (filePath) {
189+
this._fileDependencies.set(filePath, new Set(childCompilation.fileDependencies));
190+
for (const file of childCompilation.fileDependencies) {
191+
const resolvedFile = normalizePath(file);
192+
const entry = this._reverseDependencies.get(resolvedFile);
193+
if (entry) {
194+
entry.add(filePath);
195+
} else {
196+
this._reverseDependencies.set(resolvedFile, new Set([filePath]));
197+
}
160198
}
161199
}
162200

@@ -205,4 +243,14 @@ export class WebpackResourceLoader {
205243

206244
return compilationResult.content;
207245
}
246+
247+
async process(data: string, mimeType: string): Promise<string> {
248+
if (data.trim().length === 0) {
249+
return '';
250+
}
251+
252+
const compilationResult = await this._compile(undefined, data, mimeType);
253+
254+
return compilationResult.content;
255+
}
208256
}

packages/ngtools/webpack/src/transformers/replace_resources.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,75 @@ function getDecoratorOrigin(
322322

323323
return null;
324324
}
325+
326+
export function workaroundStylePreprocessing(sourceFile: ts.SourceFile): void {
327+
const visitNode: ts.Visitor = (node: ts.Node) => {
328+
if (ts.isClassDeclaration(node) && node.decorators?.length) {
329+
for (const decorator of node.decorators) {
330+
visitDecoratorWorkaround(decorator);
331+
}
332+
}
333+
334+
return ts.forEachChild(node, visitNode);
335+
};
336+
337+
ts.forEachChild(sourceFile, visitNode);
338+
}
339+
340+
function visitDecoratorWorkaround(node: ts.Decorator): void {
341+
if (!ts.isCallExpression(node.expression)) {
342+
return;
343+
}
344+
345+
const decoratorFactory = node.expression;
346+
if (
347+
!ts.isIdentifier(decoratorFactory.expression) ||
348+
decoratorFactory.expression.text !== 'Component'
349+
) {
350+
return;
351+
}
352+
353+
const args = decoratorFactory.arguments;
354+
if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) {
355+
// Unsupported component metadata
356+
return;
357+
}
358+
359+
const objectExpression = args[0] as ts.ObjectLiteralExpression;
360+
361+
// check if a `styles` property is present
362+
let hasStyles = false;
363+
for (const element of objectExpression.properties) {
364+
if (!ts.isPropertyAssignment(element) || ts.isComputedPropertyName(element.name)) {
365+
continue;
366+
}
367+
368+
if (element.name.text === 'styles') {
369+
hasStyles = true;
370+
break;
371+
}
372+
}
373+
374+
if (hasStyles) {
375+
return;
376+
}
377+
378+
const nodeFactory = ts.factory;
379+
380+
// add a `styles` property to workaround upstream compiler defect
381+
const emptyArray = nodeFactory.createArrayLiteralExpression();
382+
const stylePropertyName = nodeFactory.createIdentifier('styles');
383+
const styleProperty = nodeFactory.createPropertyAssignment(stylePropertyName, emptyArray);
384+
// tslint:disable-next-line: no-any
385+
(stylePropertyName.parent as any) = styleProperty;
386+
// tslint:disable-next-line: no-any
387+
(emptyArray.parent as any) = styleProperty;
388+
// tslint:disable-next-line: no-any
389+
(styleProperty.parent as any) = objectExpression;
390+
391+
// tslint:disable-next-line: no-any
392+
(objectExpression.properties as any) = nodeFactory.createNodeArray([
393+
...objectExpression.properties,
394+
styleProperty,
395+
]);
396+
}

0 commit comments

Comments
 (0)