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
command: |
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
test-browsers:

View File

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

View File

@ -160,8 +160,9 @@ export async function buildEsbuildBrowser(
{ virtualName: `angular:style/global;${name}`, resolvePath: workspaceRoot },
{
optimization: !!optimizationOptions.styles.minify,
sourcemap: !!sourcemapOptions.styles,
sourcemap: !!sourcemapOptions.styles && (sourcemapOptions.hidden ? 'external' : true),
outputNames: noInjectNames.includes(name) ? { media: outputNames.media } : outputNames,
includePaths: options.stylePreprocessorOptions?.includePaths,
},
);
@ -334,6 +335,7 @@ async function bundleCode(
// of sourcemap processing.
!!sourcemapOptions.styles && (sourcemapOptions.hidden ? false : 'inline'),
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 * as path from 'path';
import { DEFAULT_OUTDIR, bundle } from './esbuild';
import { createSassPlugin } from './sass-plugin';
export interface BundleStylesheetOptions {
workspaceRoot?: string;
@ -16,6 +17,7 @@ export interface BundleStylesheetOptions {
preserveSymlinks?: boolean;
sourcemap: boolean | 'external' | 'inline';
outputNames?: { bundles?: string; media?: string };
includePaths?: string[];
}
async function bundleStylesheet(
@ -39,7 +41,7 @@ async function bundleStylesheet(
conditions: ['style'],
mainFields: ['style'],
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 { ng } from '../../../utils/process';
import { updateJsonFile } from '../../../utils/project';
export default function () {
return (
Promise.resolve()
.then(() => createDir('src/style-paths'))
.then(() =>
writeMultipleFiles({
'src/style-paths/_variables.scss': '$primary-color: red;',
'src/styles.scss': `
export default async function () {
// esbuild currently only supports Sass
const esbuild = getGlobalVariable('argv')['esbuild'];
await createDir('src/style-paths');
await writeMultipleFiles({
'src/style-paths/_variables.scss': '$primary-color: red;',
'src/styles.scss': `
@import 'variables';
h1 { color: $primary-color; }
`,
'src/app/app.component.scss': `
`,
'src/app/app.component.scss': `
@import 'variables';
h2 { background-color: $primary-color; }
`,
'src/style-paths/variables.styl': '$primary-color = green',
'src/styles.styl': `
`,
'src/style-paths/variables.styl': '$primary-color = green',
'src/styles.styl': `
@import 'variables'
h3
color: $primary-color
`,
'src/app/app.component.styl': `
`,
'src/app/app.component.styl': `
@import 'variables'
h4
background-color: $primary-color
`,
'src/style-paths/variables.less': '@primary-color: #ADDADD;',
'src/styles.less': `
`,
'src/style-paths/variables.less': '@primary-color: #ADDADD;',
'src/styles.less': `
@import 'variables';
h5 { color: @primary-color; }
`,
'src/app/app.component.less': `
`,
'src/app/app.component.less': `
@import 'variables';
h6 { color: @primary-color; }
`,
}),
)
.then(() =>
replaceInFile(
'src/app/app.component.ts',
`'./app.component.css\'`,
`'./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 replaceInFile(
'src/app/app.component.ts',
`'./app.component.css\'`,
`'./app.component.scss'` + (esbuild ? '' : `, './app.component.styl', './app.component.less'`),
);
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;.*}/);
}
}