feat(@angular-devkit/build-angular): add Sass file support to experimental esbuild-based builder

This change adds support for using Sass stylesheets within an application built with the experimental
esbuild-based browser application builder. Global stylesheets (`styles` build option) and component
stylesheets (`@Component({ styleUrls: [...], ...})`) with Sass can now be used.
The `stylePreprocessorOptions.includePaths` option is also available for Sass stylesheets.
Both the default format (`.scss`) and the indented format (`.sass`) are supported.
Inline component stylesheet support is not yet available with the esbuild-based builder.
This commit is contained in:
Charles Lyding 2022-04-15 10:43:36 -04:00 committed by Douglas Parker
parent 669345998b
commit 248860ad67
6 changed files with 150 additions and 69 deletions

View File

@ -221,7 +221,7 @@ jobs:
name: Execute CLI E2E Tests Subset with esbuild builder name: Execute CLI E2E Tests Subset with esbuild builder
command: | command: |
mkdir /mnt/ramdisk/e2e-esbuild mkdir /mnt/ramdisk/e2e-esbuild
node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<</ parameters.snapshots >> --esbuild --tmpdir=/mnt/ramdisk/e2e-esbuild --glob="{tests/basic/**,tests/build/prod-build.ts,tests/commands/add/add-pwa.ts}" --ignore="tests/basic/{environment,rebuild,serve,scripts-array}.ts" node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<</ parameters.snapshots >> --esbuild --tmpdir=/mnt/ramdisk/e2e-esbuild --glob="{tests/basic/**,tests/build/prod-build.ts,tests/build/styles/scss.ts,tests/build/styles/include-paths.ts,tests/commands/add/add-pwa.ts}" --ignore="tests/basic/{environment,rebuild,serve,scripts-array}.ts"
- fail_fast - fail_fast
test-browsers: test-browsers:

View File

@ -16,7 +16,7 @@ import ts from 'typescript';
import angularApplicationPreset from '../../babel/presets/application'; import angularApplicationPreset from '../../babel/presets/application';
import { requiresLinking } from '../../babel/webpack-loader'; import { requiresLinking } from '../../babel/webpack-loader';
import { loadEsmModule } from '../../utils/load-esm'; import { loadEsmModule } from '../../utils/load-esm';
import { BundleStylesheetOptions, bundleStylesheetText } from './stylesheets'; import { BundleStylesheetOptions, bundleStylesheetFile, bundleStylesheetText } from './stylesheets';
interface EmitFileResult { interface EmitFileResult {
content?: string; content?: string;
@ -191,17 +191,27 @@ export function createCompilerPlugin(
// Create TypeScript compiler host // Create TypeScript compiler host
const host = ts.createIncrementalCompilerHost(compilerOptions); const host = ts.createIncrementalCompilerHost(compilerOptions);
// Temporarily add a readResource hook to allow for a transformResource hook. // Temporarily process external resources via readResource.
// Once the AOT compiler allows only a transformResource hook this can be removed. // The AOT compiler currently requires this hook to allow for a transformResource hook.
(host as CompilerHost).readResource = function (fileName) { // Once the AOT compiler allows only a transformResource hook, this can be reevaluated.
// Provide same no file found behavior as @ngtools/webpack (host as CompilerHost).readResource = async function (fileName) {
return this.readFile(fileName) ?? ''; // Template resources (.html) files are not bundled or transformed
if (fileName.endsWith('.html')) {
return this.readFile(fileName) ?? '';
}
const { contents, errors, warnings } = await bundleStylesheetFile(fileName, styleOptions);
(result.errors ??= []).push(...errors);
(result.warnings ??= []).push(...warnings);
return contents;
}; };
// Add an AOT compiler resource transform hook // Add an AOT compiler resource transform hook
(host as CompilerHost).transformResource = async function (data, context) { (host as CompilerHost).transformResource = async function (data, context) {
// Only style resources are transformed currently // Only inline style resources are transformed separately currently
if (context.type !== 'style') { if (context.resourceFile || context.type !== 'style') {
return null; return null;
} }

View File

@ -160,8 +160,9 @@ export async function buildEsbuildBrowser(
{ virtualName: `angular:style/global;${name}`, resolvePath: workspaceRoot }, { virtualName: `angular:style/global;${name}`, resolvePath: workspaceRoot },
{ {
optimization: !!optimizationOptions.styles.minify, optimization: !!optimizationOptions.styles.minify,
sourcemap: !!sourcemapOptions.styles, sourcemap: !!sourcemapOptions.styles && (sourcemapOptions.hidden ? 'external' : true),
outputNames: noInjectNames.includes(name) ? { media: outputNames.media } : outputNames, outputNames: noInjectNames.includes(name) ? { media: outputNames.media } : outputNames,
includePaths: options.stylePreprocessorOptions?.includePaths,
}, },
); );
@ -334,6 +335,7 @@ async function bundleCode(
// of sourcemap processing. // of sourcemap processing.
!!sourcemapOptions.styles && (sourcemapOptions.hidden ? false : 'inline'), !!sourcemapOptions.styles && (sourcemapOptions.hidden ? false : 'inline'),
outputNames, outputNames,
includePaths: options.stylePreprocessorOptions?.includePaths,
}, },
), ),
], ],

View File

@ -0,0 +1,59 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import type { Plugin, PluginBuild } from 'esbuild';
import type { LegacyResult } from 'sass';
import { SassWorkerImplementation } from '../../sass/sass-service';
export function createSassPlugin(options: { sourcemap: boolean; includePaths?: string[] }): Plugin {
return {
name: 'angular-sass',
setup(build: PluginBuild): void {
let sass: SassWorkerImplementation;
build.onStart(() => {
sass = new SassWorkerImplementation();
});
build.onEnd(() => {
sass?.close();
});
build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
const result = await new Promise<LegacyResult>((resolve, reject) => {
sass.render(
{
file: args.path,
includePaths: options.includePaths,
indentedSyntax: args.path.endsWith('.sass'),
outputStyle: 'expanded',
sourceMap: options.sourcemap,
sourceMapContents: options.sourcemap,
sourceMapEmbed: options.sourcemap,
quietDeps: true,
},
(error, result) => {
if (error) {
reject(error);
}
if (result) {
resolve(result);
}
},
);
});
return {
contents: result.css,
loader: 'css',
watchFiles: result.stats.includedFiles,
};
});
},
};
}

View File

@ -9,6 +9,7 @@
import type { BuildOptions, OutputFile } from 'esbuild'; import type { BuildOptions, OutputFile } from 'esbuild';
import * as path from 'path'; import * as path from 'path';
import { DEFAULT_OUTDIR, bundle } from './esbuild'; import { DEFAULT_OUTDIR, bundle } from './esbuild';
import { createSassPlugin } from './sass-plugin';
export interface BundleStylesheetOptions { export interface BundleStylesheetOptions {
workspaceRoot?: string; workspaceRoot?: string;
@ -16,6 +17,7 @@ export interface BundleStylesheetOptions {
preserveSymlinks?: boolean; preserveSymlinks?: boolean;
sourcemap: boolean | 'external' | 'inline'; sourcemap: boolean | 'external' | 'inline';
outputNames?: { bundles?: string; media?: string }; outputNames?: { bundles?: string; media?: string };
includePaths?: string[];
} }
async function bundleStylesheet( async function bundleStylesheet(
@ -39,7 +41,7 @@ async function bundleStylesheet(
conditions: ['style'], conditions: ['style'],
mainFields: ['style'], mainFields: ['style'],
plugins: [ plugins: [
// TODO: preprocessor plugins createSassPlugin({ sourcemap: !!options.sourcemap, includePaths: options.includePaths }),
], ],
}); });

View File

@ -1,78 +1,86 @@
import { getGlobalVariable } from '../../../utils/env';
import { writeMultipleFiles, expectFileToMatch, replaceInFile, createDir } from '../../../utils/fs'; import { writeMultipleFiles, expectFileToMatch, replaceInFile, createDir } from '../../../utils/fs';
import { ng } from '../../../utils/process'; import { ng } from '../../../utils/process';
import { updateJsonFile } from '../../../utils/project'; import { updateJsonFile } from '../../../utils/project';
export default function () { export default async function () {
return ( // esbuild currently only supports Sass
Promise.resolve() const esbuild = getGlobalVariable('argv')['esbuild'];
.then(() => createDir('src/style-paths'))
.then(() => await createDir('src/style-paths');
writeMultipleFiles({ await writeMultipleFiles({
'src/style-paths/_variables.scss': '$primary-color: red;', 'src/style-paths/_variables.scss': '$primary-color: red;',
'src/styles.scss': ` 'src/styles.scss': `
@import 'variables'; @import 'variables';
h1 { color: $primary-color; } h1 { color: $primary-color; }
`, `,
'src/app/app.component.scss': ` 'src/app/app.component.scss': `
@import 'variables'; @import 'variables';
h2 { background-color: $primary-color; } h2 { background-color: $primary-color; }
`, `,
'src/style-paths/variables.styl': '$primary-color = green', 'src/style-paths/variables.styl': '$primary-color = green',
'src/styles.styl': ` 'src/styles.styl': `
@import 'variables' @import 'variables'
h3 h3
color: $primary-color color: $primary-color
`, `,
'src/app/app.component.styl': ` 'src/app/app.component.styl': `
@import 'variables' @import 'variables'
h4 h4
background-color: $primary-color background-color: $primary-color
`, `,
'src/style-paths/variables.less': '@primary-color: #ADDADD;', 'src/style-paths/variables.less': '@primary-color: #ADDADD;',
'src/styles.less': ` 'src/styles.less': `
@import 'variables'; @import 'variables';
h5 { color: @primary-color; } h5 { color: @primary-color; }
`, `,
'src/app/app.component.less': ` 'src/app/app.component.less': `
@import 'variables'; @import 'variables';
h6 { color: @primary-color; } h6 { color: @primary-color; }
`, `,
}), });
)
.then(() => await replaceInFile(
replaceInFile( 'src/app/app.component.ts',
'src/app/app.component.ts', `'./app.component.css\'`,
`'./app.component.css\'`, `'./app.component.scss'` + (esbuild ? '' : `, './app.component.styl', './app.component.less'`),
`'./app.component.scss', './app.component.styl', './app.component.less'`,
),
)
.then(() =>
updateJsonFile('angular.json', (workspaceJson) => {
const appArchitect = workspaceJson.projects['test-project'].architect;
appArchitect.build.options.styles = [
{ input: 'src/styles.scss' },
{ input: 'src/styles.styl' },
{ input: 'src/styles.less' },
];
appArchitect.build.options.stylePreprocessorOptions = {
includePaths: ['src/style-paths'],
};
}),
)
// files were created successfully
.then(() => ng('build', '--configuration=development'))
.then(() => expectFileToMatch('dist/test-project/styles.css', /h1\s*{\s*color: red;\s*}/))
.then(() => expectFileToMatch('dist/test-project/main.js', /h2.*{.*color: red;.*}/))
.then(() => expectFileToMatch('dist/test-project/styles.css', /h3\s*{\s*color: #008000;\s*}/))
.then(() => expectFileToMatch('dist/test-project/main.js', /h4.*{.*color: #008000;.*}/))
.then(() => expectFileToMatch('dist/test-project/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/))
.then(() => expectFileToMatch('dist/test-project/main.js', /h6.*{.*color: #ADDADD;.*}/))
.then(() => ng('build', '--aot', '--configuration=development'))
.then(() => expectFileToMatch('dist/test-project/styles.css', /h1\s*{\s*color: red;\s*}/))
.then(() => expectFileToMatch('dist/test-project/main.js', /h2.*{.*color: red;.*}/))
.then(() => expectFileToMatch('dist/test-project/styles.css', /h3\s*{\s*color: #008000;\s*}/))
.then(() => expectFileToMatch('dist/test-project/main.js', /h4.*{.*color: #008000;.*}/))
.then(() => expectFileToMatch('dist/test-project/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/))
.then(() => expectFileToMatch('dist/test-project/main.js', /h6.*{.*color: #ADDADD;.*}/))
); );
await updateJsonFile('angular.json', (workspaceJson) => {
const appArchitect = workspaceJson.projects['test-project'].architect;
appArchitect.build.options.styles = [{ input: 'src/styles.scss' }];
if (!esbuild) {
appArchitect.build.options.styles.push(
{ input: 'src/styles.styl' },
{ input: 'src/styles.less' },
);
}
appArchitect.build.options.stylePreprocessorOptions = {
includePaths: ['src/style-paths'],
};
});
await ng('build', '--configuration=development');
expectFileToMatch('dist/test-project/styles.css', /h1\s*{\s*color: red;\s*}/);
expectFileToMatch('dist/test-project/main.js', /h2.*{.*color: red;.*}/);
if (!esbuild) {
// These checks are for the less and stylus files
expectFileToMatch('dist/test-project/styles.css', /h3\s*{\s*color: #008000;\s*}/);
expectFileToMatch('dist/test-project/main.js', /h4.*{.*color: #008000;.*}/);
expectFileToMatch('dist/test-project/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/);
expectFileToMatch('dist/test-project/main.js', /h6.*{.*color: #ADDADD;.*}/);
}
// esbuild currently only supports AOT and not JIT mode
if (!esbuild) {
ng('build', '--no-aot', '--configuration=development');
expectFileToMatch('dist/test-project/styles.css', /h1\s*{\s*color: red;\s*}/);
expectFileToMatch('dist/test-project/main.js', /h2.*{.*color: red;.*}/);
expectFileToMatch('dist/test-project/styles.css', /h3\s*{\s*color: #008000;\s*}/);
expectFileToMatch('dist/test-project/main.js', /h4.*{.*color: #008000;.*}/);
expectFileToMatch('dist/test-project/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/);
expectFileToMatch('dist/test-project/main.js', /h6.*{.*color: #ADDADD;.*}/);
}
} }