Skip to content

Commit 216991b

Browse files
clydinangular-robot[bot]
authored andcommitted
feat(@angular-devkit/build-angular): support inline component Sass styles with esbuild builder
When using the experimental esbuild-based browser application builder, the `inlineStyleLanguage` option and the usage of inline Angular component styles that contain Sass are now supported. The `inlineStyleLanguage` option values of `css`, `sass`, and `scss` can be used and will behave as they would with the default Webpack-based builder. The less stylesheet preprocessor is not yet supported in general with the esbuild-based builder. However, when support is added for less, the `inlineStyleLanguage` option will also be able to be used with the `less` option value.
1 parent 8d000d1 commit 216991b

File tree

6 files changed

+207
-168
lines changed

6 files changed

+207
-168
lines changed

packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ import {
2929
profileSync,
3030
resetCumulativeDurations,
3131
} from './profiling';
32-
import { BundleStylesheetOptions, bundleStylesheetFile, bundleStylesheetText } from './stylesheets';
32+
import { BundleStylesheetOptions, bundleComponentStylesheet } from './stylesheets';
33+
34+
/**
35+
* A counter for component styles used to generate unique build-time identifiers for each stylesheet.
36+
*/
37+
let componentStyleCounter = 0;
3338

3439
/**
3540
* Converts TypeScript Diagnostic related information into an esbuild compatible note object.
@@ -150,7 +155,7 @@ export interface CompilerPluginOptions {
150155
// eslint-disable-next-line max-lines-per-function
151156
export function createCompilerPlugin(
152157
pluginOptions: CompilerPluginOptions,
153-
styleOptions: BundleStylesheetOptions,
158+
styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string },
154159
): Plugin {
155160
return {
156161
name: 'angular-compiler',
@@ -253,21 +258,15 @@ export function createCompilerPlugin(
253258
// Stylesheet file only exists for external stylesheets
254259
const filename = stylesheetFile ?? containingFile;
255260

256-
// Temporary workaround for lack of virtual file support in the Sass plugin.
257-
// External Sass stylesheets are transformed using the file instead of the already read content.
258-
let stylesheetResult;
259-
if (filename.endsWith('.scss') || filename.endsWith('.sass')) {
260-
stylesheetResult = await bundleStylesheetFile(filename, styleOptions);
261-
} else {
262-
stylesheetResult = await bundleStylesheetText(
263-
data,
264-
{
265-
resolvePath: path.dirname(filename),
266-
virtualName: filename,
267-
},
268-
styleOptions,
269-
);
270-
}
261+
const stylesheetResult = await bundleComponentStylesheet(
262+
// TODO: Evaluate usage of a fast hash instead
263+
`${++componentStyleCounter}`,
264+
styleOptions.inlineStyleLanguage,
265+
data,
266+
filename,
267+
!stylesheetFile,
268+
styleOptions,
269+
);
271270

272271
const { contents, resourceFiles, errors, warnings } = stylesheetResult;
273272
(result.errors ??= []).push(...errors);

packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,6 @@ const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
2323
// 'i18nDuplicateTranslation',
2424
// 'i18nMissingTranslation',
2525

26-
// * Stylesheet preprocessor support
27-
'inlineStyleLanguage',
28-
// The following option has no effect until preprocessors are supported
29-
// 'stylePreprocessorOptions',
30-
3126
// * Deprecated
3227
'deployUrl',
3328

@@ -60,12 +55,13 @@ export function logExperimentalWarnings(options: BrowserBuilderOptions, context:
6055
if (typeof value === 'object' && Object.keys(value).length === 0) {
6156
continue;
6257
}
63-
if (unsupportedOption === 'inlineStyleLanguage' && value === 'css') {
64-
continue;
65-
}
6658

6759
context.logger.warn(
6860
`The '${unsupportedOption}' option is currently unsupported by this experimental builder and will be ignored.`,
6961
);
7062
}
63+
64+
if (options.inlineStyleLanguage === 'less') {
65+
context.logger.warn('The less stylesheet preprocessor is not currently supported.');
66+
}
7167
}

packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ function createCodeBundleOptions(
242242
preserveSymlinks,
243243
stylePreprocessorOptions,
244244
advancedOptimizations,
245+
inlineStyleLanguage,
245246
} = options;
246247

247248
return {
@@ -292,6 +293,7 @@ function createCodeBundleOptions(
292293
includePaths: stylePreprocessorOptions?.includePaths,
293294
externalDependencies,
294295
target,
296+
inlineStyleLanguage,
295297
},
296298
),
297299
],

packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export async function normalizeOptions(
136136
buildOptimizer,
137137
crossOrigin,
138138
externalDependencies,
139+
inlineStyleLanguage = 'css',
139140
poll,
140141
preserveSymlinks,
141142
stylePreprocessorOptions,
@@ -151,6 +152,7 @@ export async function normalizeOptions(
151152
cacheOptions,
152153
crossOrigin,
153154
externalDependencies,
155+
inlineStyleLanguage,
154156
poll,
155157
// If not explicitly set, default to the Node.js process argument
156158
preserveSymlinks: preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'),

packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts

Lines changed: 121 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import type { PartialMessage, Plugin, PluginBuild } from 'esbuild';
9+
import type { OnLoadResult, PartialMessage, Plugin, PluginBuild, ResolveResult } from 'esbuild';
10+
import assert from 'node:assert';
1011
import { readFile } from 'node:fs/promises';
11-
import { dirname, join, relative } from 'node:path';
12+
import { dirname, extname, join, relative } from 'node:path';
1213
import { fileURLToPath, pathToFileURL } from 'node:url';
13-
import type { CompileResult, Exception } from 'sass';
14+
import type { CompileResult, Exception, Syntax } from 'sass';
1415
import {
1516
FileImporterWithRequestContextOptions,
1617
SassWorkerImplementation,
1718
} from '../../sass/sass-service';
1819

20+
export interface SassPluginOptions {
21+
sourcemap: boolean;
22+
loadPaths?: string[];
23+
inlineComponentData?: Record<string, string>;
24+
}
25+
1926
let sassWorkerPool: SassWorkerImplementation | undefined;
2027

2128
function isSassException(error: unknown): error is Exception {
@@ -27,7 +34,7 @@ export function shutdownSassWorkerPool(): void {
2734
sassWorkerPool = undefined;
2835
}
2936

30-
export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: string[] }): Plugin {
37+
export function createSassPlugin(options: SassPluginOptions): Plugin {
3138
return {
3239
name: 'angular-sass',
3340
setup(build: PluginBuild): void {
@@ -55,105 +62,123 @@ export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: stri
5562
return result;
5663
};
5764

58-
build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
59-
// Lazily load Sass when a Sass file is found
60-
sassWorkerPool ??= new SassWorkerImplementation(true);
61-
62-
const warnings: PartialMessage[] = [];
63-
try {
64-
const data = await readFile(args.path, 'utf-8');
65-
const { css, sourceMap, loadedUrls } = await sassWorkerPool.compileStringAsync(data, {
66-
url: pathToFileURL(args.path),
67-
style: 'expanded',
68-
loadPaths: options.loadPaths,
69-
sourceMap: options.sourcemap,
70-
sourceMapIncludeSources: options.sourcemap,
71-
quietDeps: true,
72-
importers: [
73-
{
74-
findFileUrl: async (
75-
url,
76-
{ previousResolvedModules }: FileImporterWithRequestContextOptions,
77-
): Promise<URL | null> => {
78-
const result = await resolveUrl(url, previousResolvedModules);
79-
80-
// Check for package deep imports
81-
if (!result.path) {
82-
const parts = url.split('/');
83-
const hasScope = parts.length >= 2 && parts[0].startsWith('@');
84-
const [nameOrScope, nameOrFirstPath, ...pathPart] = parts;
85-
const packageName = hasScope
86-
? `${nameOrScope}/${nameOrFirstPath}`
87-
: nameOrScope;
88-
89-
const packageResult = await resolveUrl(
90-
packageName + '/package.json',
91-
previousResolvedModules,
92-
);
93-
94-
if (packageResult.path) {
95-
return pathToFileURL(
96-
join(
97-
dirname(packageResult.path),
98-
!hasScope ? nameOrFirstPath : '',
99-
...pathPart,
100-
),
101-
);
102-
}
103-
}
104-
105-
return result.path ? pathToFileURL(result.path) : null;
106-
},
107-
},
108-
],
109-
logger: {
110-
warn: (text, { deprecation, span }) => {
111-
warnings.push({
112-
text: deprecation ? 'Deprecation' : text,
113-
location: span && {
114-
file: span.url && fileURLToPath(span.url),
115-
lineText: span.context,
116-
// Sass line numbers are 0-based while esbuild's are 1-based
117-
line: span.start.line + 1,
118-
column: span.start.column,
119-
},
120-
notes: deprecation ? [{ text }] : undefined,
121-
});
122-
},
123-
},
124-
});
65+
build.onLoad(
66+
{ filter: /^angular:styles\/component;s[ac]ss;/, namespace: 'angular:styles/component' },
67+
async (args) => {
68+
const data = options.inlineComponentData?.[args.path];
69+
assert(data, `component style name should always be found [${args.path}]`);
12570

126-
return {
127-
loader: 'css',
128-
contents: sourceMap
129-
? `${css}\n${sourceMapToUrlComment(sourceMap, dirname(args.path))}`
130-
: css,
131-
watchFiles: loadedUrls.map((url) => fileURLToPath(url)),
132-
warnings,
133-
};
134-
} catch (error) {
135-
if (isSassException(error)) {
136-
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;
137-
138-
return {
139-
loader: 'css',
140-
errors: [
141-
{
142-
text: error.message,
143-
},
144-
],
145-
warnings,
146-
watchFiles: file ? [file] : undefined,
147-
};
148-
}
71+
const [, language, , filePath] = args.path.split(';', 4);
72+
const syntax = language === 'sass' ? 'indented' : 'scss';
14973

150-
throw error;
151-
}
74+
return compileString(data, filePath, syntax, options, resolveUrl);
75+
},
76+
);
77+
78+
build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
79+
const data = await readFile(args.path, 'utf-8');
80+
const syntax = extname(args.path).toLowerCase() === '.sass' ? 'indented' : 'scss';
81+
82+
return compileString(data, args.path, syntax, options, resolveUrl);
15283
});
15384
},
15485
};
15586
}
15687

88+
async function compileString(
89+
data: string,
90+
filePath: string,
91+
syntax: Syntax,
92+
options: SassPluginOptions,
93+
resolveUrl: (url: string, previousResolvedModules?: Set<string>) => Promise<ResolveResult>,
94+
): Promise<OnLoadResult> {
95+
// Lazily load Sass when a Sass file is found
96+
sassWorkerPool ??= new SassWorkerImplementation(true);
97+
98+
const warnings: PartialMessage[] = [];
99+
try {
100+
const { css, sourceMap, loadedUrls } = await sassWorkerPool.compileStringAsync(data, {
101+
url: pathToFileURL(filePath),
102+
style: 'expanded',
103+
syntax,
104+
loadPaths: options.loadPaths,
105+
sourceMap: options.sourcemap,
106+
sourceMapIncludeSources: options.sourcemap,
107+
quietDeps: true,
108+
importers: [
109+
{
110+
findFileUrl: async (
111+
url,
112+
{ previousResolvedModules }: FileImporterWithRequestContextOptions,
113+
): Promise<URL | null> => {
114+
const result = await resolveUrl(url, previousResolvedModules);
115+
116+
// Check for package deep imports
117+
if (!result.path) {
118+
const parts = url.split('/');
119+
const hasScope = parts.length >= 2 && parts[0].startsWith('@');
120+
const [nameOrScope, nameOrFirstPath, ...pathPart] = parts;
121+
const packageName = hasScope ? `${nameOrScope}/${nameOrFirstPath}` : nameOrScope;
122+
123+
const packageResult = await resolveUrl(
124+
packageName + '/package.json',
125+
previousResolvedModules,
126+
);
127+
128+
if (packageResult.path) {
129+
return pathToFileURL(
130+
join(dirname(packageResult.path), !hasScope ? nameOrFirstPath : '', ...pathPart),
131+
);
132+
}
133+
}
134+
135+
return result.path ? pathToFileURL(result.path) : null;
136+
},
137+
},
138+
],
139+
logger: {
140+
warn: (text, { deprecation, span }) => {
141+
warnings.push({
142+
text: deprecation ? 'Deprecation' : text,
143+
location: span && {
144+
file: span.url && fileURLToPath(span.url),
145+
lineText: span.context,
146+
// Sass line numbers are 0-based while esbuild's are 1-based
147+
line: span.start.line + 1,
148+
column: span.start.column,
149+
},
150+
notes: deprecation ? [{ text }] : undefined,
151+
});
152+
},
153+
},
154+
});
155+
156+
return {
157+
loader: 'css',
158+
contents: sourceMap ? `${css}\n${sourceMapToUrlComment(sourceMap, dirname(filePath))}` : css,
159+
watchFiles: loadedUrls.map((url) => fileURLToPath(url)),
160+
warnings,
161+
};
162+
} catch (error) {
163+
if (isSassException(error)) {
164+
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;
165+
166+
return {
167+
loader: 'css',
168+
errors: [
169+
{
170+
text: error.message,
171+
},
172+
],
173+
warnings,
174+
watchFiles: file ? [file] : undefined,
175+
};
176+
}
177+
178+
throw error;
179+
}
180+
}
181+
157182
function sourceMapToUrlComment(
158183
sourceMap: Exclude<CompileResult['sourceMap'], undefined>,
159184
root: string,

0 commit comments

Comments
 (0)