Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): support inline component Sass st…
Browse files Browse the repository at this point in the history
…yles 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.
  • Loading branch information
clydin authored and angular-robot[bot] committed Dec 13, 2022
1 parent 8d000d1 commit 216991b
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 168 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import {
profileSync,
resetCumulativeDurations,
} from './profiling';
import { BundleStylesheetOptions, bundleStylesheetFile, bundleStylesheetText } from './stylesheets';
import { BundleStylesheetOptions, bundleComponentStylesheet } from './stylesheets';

/**
* A counter for component styles used to generate unique build-time identifiers for each stylesheet.
*/
let componentStyleCounter = 0;

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

// Temporary workaround for lack of virtual file support in the Sass plugin.
// External Sass stylesheets are transformed using the file instead of the already read content.
let stylesheetResult;
if (filename.endsWith('.scss') || filename.endsWith('.sass')) {
stylesheetResult = await bundleStylesheetFile(filename, styleOptions);
} else {
stylesheetResult = await bundleStylesheetText(
data,
{
resolvePath: path.dirname(filename),
virtualName: filename,
},
styleOptions,
);
}
const stylesheetResult = await bundleComponentStylesheet(
// TODO: Evaluate usage of a fast hash instead
`${++componentStyleCounter}`,
styleOptions.inlineStyleLanguage,
data,
filename,
!stylesheetFile,
styleOptions,
);

const { contents, resourceFiles, errors, warnings } = stylesheetResult;
(result.errors ??= []).push(...errors);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@ const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
// 'i18nDuplicateTranslation',
// 'i18nMissingTranslation',

// * Stylesheet preprocessor support
'inlineStyleLanguage',
// The following option has no effect until preprocessors are supported
// 'stylePreprocessorOptions',

// * Deprecated
'deployUrl',

Expand Down Expand Up @@ -60,12 +55,13 @@ export function logExperimentalWarnings(options: BrowserBuilderOptions, context:
if (typeof value === 'object' && Object.keys(value).length === 0) {
continue;
}
if (unsupportedOption === 'inlineStyleLanguage' && value === 'css') {
continue;
}

context.logger.warn(
`The '${unsupportedOption}' option is currently unsupported by this experimental builder and will be ignored.`,
);
}

if (options.inlineStyleLanguage === 'less') {
context.logger.warn('The less stylesheet preprocessor is not currently supported.');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ function createCodeBundleOptions(
preserveSymlinks,
stylePreprocessorOptions,
advancedOptimizations,
inlineStyleLanguage,
} = options;

return {
Expand Down Expand Up @@ -292,6 +293,7 @@ function createCodeBundleOptions(
includePaths: stylePreprocessorOptions?.includePaths,
externalDependencies,
target,
inlineStyleLanguage,
},
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function normalizeOptions(
buildOptimizer,
crossOrigin,
externalDependencies,
inlineStyleLanguage = 'css',
poll,
preserveSymlinks,
stylePreprocessorOptions,
Expand All @@ -151,6 +152,7 @@ export async function normalizeOptions(
cacheOptions,
crossOrigin,
externalDependencies,
inlineStyleLanguage,
poll,
// If not explicitly set, default to the Node.js process argument
preserveSymlinks: preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@
* found in the LICENSE file at https://angular.io/license
*/

import type { PartialMessage, Plugin, PluginBuild } from 'esbuild';
import type { OnLoadResult, PartialMessage, Plugin, PluginBuild, ResolveResult } from 'esbuild';
import assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { dirname, extname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { CompileResult, Exception } from 'sass';
import type { CompileResult, Exception, Syntax } from 'sass';
import {
FileImporterWithRequestContextOptions,
SassWorkerImplementation,
} from '../../sass/sass-service';

export interface SassPluginOptions {
sourcemap: boolean;
loadPaths?: string[];
inlineComponentData?: Record<string, string>;
}

let sassWorkerPool: SassWorkerImplementation | undefined;

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

export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: string[] }): Plugin {
export function createSassPlugin(options: SassPluginOptions): Plugin {
return {
name: 'angular-sass',
setup(build: PluginBuild): void {
Expand Down Expand Up @@ -55,105 +62,123 @@ export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: stri
return result;
};

build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
// Lazily load Sass when a Sass file is found
sassWorkerPool ??= new SassWorkerImplementation(true);

const warnings: PartialMessage[] = [];
try {
const data = await readFile(args.path, 'utf-8');
const { css, sourceMap, loadedUrls } = await sassWorkerPool.compileStringAsync(data, {
url: pathToFileURL(args.path),
style: 'expanded',
loadPaths: options.loadPaths,
sourceMap: options.sourcemap,
sourceMapIncludeSources: options.sourcemap,
quietDeps: true,
importers: [
{
findFileUrl: async (
url,
{ previousResolvedModules }: FileImporterWithRequestContextOptions,
): Promise<URL | null> => {
const result = await resolveUrl(url, previousResolvedModules);

// Check for package deep imports
if (!result.path) {
const parts = url.split('/');
const hasScope = parts.length >= 2 && parts[0].startsWith('@');
const [nameOrScope, nameOrFirstPath, ...pathPart] = parts;
const packageName = hasScope
? `${nameOrScope}/${nameOrFirstPath}`
: nameOrScope;

const packageResult = await resolveUrl(
packageName + '/package.json',
previousResolvedModules,
);

if (packageResult.path) {
return pathToFileURL(
join(
dirname(packageResult.path),
!hasScope ? nameOrFirstPath : '',
...pathPart,
),
);
}
}

return result.path ? pathToFileURL(result.path) : null;
},
},
],
logger: {
warn: (text, { deprecation, span }) => {
warnings.push({
text: deprecation ? 'Deprecation' : text,
location: span && {
file: span.url && fileURLToPath(span.url),
lineText: span.context,
// Sass line numbers are 0-based while esbuild's are 1-based
line: span.start.line + 1,
column: span.start.column,
},
notes: deprecation ? [{ text }] : undefined,
});
},
},
});
build.onLoad(
{ filter: /^angular:styles\/component;s[ac]ss;/, namespace: 'angular:styles/component' },
async (args) => {
const data = options.inlineComponentData?.[args.path];
assert(data, `component style name should always be found [${args.path}]`);

return {
loader: 'css',
contents: sourceMap
? `${css}\n${sourceMapToUrlComment(sourceMap, dirname(args.path))}`
: css,
watchFiles: loadedUrls.map((url) => fileURLToPath(url)),
warnings,
};
} catch (error) {
if (isSassException(error)) {
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;

return {
loader: 'css',
errors: [
{
text: error.message,
},
],
warnings,
watchFiles: file ? [file] : undefined,
};
}
const [, language, , filePath] = args.path.split(';', 4);
const syntax = language === 'sass' ? 'indented' : 'scss';

throw error;
}
return compileString(data, filePath, syntax, options, resolveUrl);
},
);

build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
const data = await readFile(args.path, 'utf-8');
const syntax = extname(args.path).toLowerCase() === '.sass' ? 'indented' : 'scss';

return compileString(data, args.path, syntax, options, resolveUrl);
});
},
};
}

async function compileString(
data: string,
filePath: string,
syntax: Syntax,
options: SassPluginOptions,
resolveUrl: (url: string, previousResolvedModules?: Set<string>) => Promise<ResolveResult>,
): Promise<OnLoadResult> {
// Lazily load Sass when a Sass file is found
sassWorkerPool ??= new SassWorkerImplementation(true);

const warnings: PartialMessage[] = [];
try {
const { css, sourceMap, loadedUrls } = await sassWorkerPool.compileStringAsync(data, {
url: pathToFileURL(filePath),
style: 'expanded',
syntax,
loadPaths: options.loadPaths,
sourceMap: options.sourcemap,
sourceMapIncludeSources: options.sourcemap,
quietDeps: true,
importers: [
{
findFileUrl: async (
url,
{ previousResolvedModules }: FileImporterWithRequestContextOptions,
): Promise<URL | null> => {
const result = await resolveUrl(url, previousResolvedModules);

// Check for package deep imports
if (!result.path) {
const parts = url.split('/');
const hasScope = parts.length >= 2 && parts[0].startsWith('@');
const [nameOrScope, nameOrFirstPath, ...pathPart] = parts;
const packageName = hasScope ? `${nameOrScope}/${nameOrFirstPath}` : nameOrScope;

const packageResult = await resolveUrl(
packageName + '/package.json',
previousResolvedModules,
);

if (packageResult.path) {
return pathToFileURL(
join(dirname(packageResult.path), !hasScope ? nameOrFirstPath : '', ...pathPart),
);
}
}

return result.path ? pathToFileURL(result.path) : null;
},
},
],
logger: {
warn: (text, { deprecation, span }) => {
warnings.push({
text: deprecation ? 'Deprecation' : text,
location: span && {
file: span.url && fileURLToPath(span.url),
lineText: span.context,
// Sass line numbers are 0-based while esbuild's are 1-based
line: span.start.line + 1,
column: span.start.column,
},
notes: deprecation ? [{ text }] : undefined,
});
},
},
});

return {
loader: 'css',
contents: sourceMap ? `${css}\n${sourceMapToUrlComment(sourceMap, dirname(filePath))}` : css,
watchFiles: loadedUrls.map((url) => fileURLToPath(url)),
warnings,
};
} catch (error) {
if (isSassException(error)) {
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;

return {
loader: 'css',
errors: [
{
text: error.message,
},
],
warnings,
watchFiles: file ? [file] : undefined,
};
}

throw error;
}
}

function sourceMapToUrlComment(
sourceMap: Exclude<CompileResult['sourceMap'], undefined>,
root: string,
Expand Down

0 comments on commit 216991b

Please sign in to comment.