diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts index 5c4875acc4fb..921b328d283f 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts @@ -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. @@ -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', @@ -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); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts index dac13da1e40e..637b1b1d3029 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts @@ -23,11 +23,6 @@ const UNSUPPORTED_OPTIONS: Array = [ // 'i18nDuplicateTranslation', // 'i18nMissingTranslation', - // * Stylesheet preprocessor support - 'inlineStyleLanguage', - // The following option has no effect until preprocessors are supported - // 'stylePreprocessorOptions', - // * Deprecated 'deployUrl', @@ -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.'); + } } diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index 2508c1279cff..14931978b4ff 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -242,6 +242,7 @@ function createCodeBundleOptions( preserveSymlinks, stylePreprocessorOptions, advancedOptimizations, + inlineStyleLanguage, } = options; return { @@ -292,6 +293,7 @@ function createCodeBundleOptions( includePaths: stylePreprocessorOptions?.includePaths, externalDependencies, target, + inlineStyleLanguage, }, ), ], diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts index 649b970a048c..ec6351ceca53 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts @@ -136,6 +136,7 @@ export async function normalizeOptions( buildOptimizer, crossOrigin, externalDependencies, + inlineStyleLanguage = 'css', poll, preserveSymlinks, stylePreprocessorOptions, @@ -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'), diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts index 1ef2613e1302..50928d3341bb 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts @@ -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; +} + let sassWorkerPool: SassWorkerImplementation | undefined; function isSassException(error: unknown): error is Exception { @@ -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 { @@ -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 => { - 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) => Promise, +): Promise { + // 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 => { + 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, root: string, diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts index a34a624cea76..c4390e80fcfd 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts @@ -7,7 +7,7 @@ */ import type { BuildOptions, OutputFile } from 'esbuild'; -import * as path from 'path'; +import * as path from 'node:path'; import { createCssResourcePlugin } from './css-resource-plugin'; import { bundle } from './esbuild'; import { createSassPlugin } from './sass-plugin'; @@ -25,6 +25,7 @@ export interface BundleStylesheetOptions { export function createStylesheetBundleOptions( options: BundleStylesheetOptions, + inlineComponentData?: Record, ): BuildOptions & { plugins: NonNullable } { return { absWorkingDir: options.workspaceRoot, @@ -43,22 +44,75 @@ export function createStylesheetBundleOptions( conditions: ['style', 'sass'], mainFields: ['style', 'sass'], plugins: [ - createSassPlugin({ sourcemap: !!options.sourcemap, loadPaths: options.includePaths }), + createSassPlugin({ + sourcemap: !!options.sourcemap, + loadPaths: options.includePaths, + inlineComponentData, + }), createCssResourcePlugin(), ], }; } -async function bundleStylesheet( - entry: Required | Pick>, +/** + * Bundles a component stylesheet. The stylesheet can be either an inline stylesheet that + * is contained within the Component's metadata definition or an external file referenced + * from the Component's metadata definition. + * + * @param identifier A unique string identifier for the component stylesheet. + * @param language The language of the stylesheet such as `css` or `scss`. + * @param data The string content of the stylesheet. + * @param filename The filename representing the source of the stylesheet content. + * @param inline If true, the stylesheet source is within the component metadata; + * if false, the source is a stylesheet file. + * @param options An object containing the stylesheet bundling options. + * @returns An object containing the output of the bundling operation. + */ +export async function bundleComponentStylesheet( + identifier: string, + language: string, + data: string, + filename: string, + inline: boolean, options: BundleStylesheetOptions, ) { - // Execute esbuild - const result = await bundle(options.workspaceRoot, { - ...createStylesheetBundleOptions(options), - ...entry, + const namespace = 'angular:styles/component'; + const entry = [namespace, language, identifier, filename].join(';'); + + const buildOptions = createStylesheetBundleOptions(options, { [entry]: data }); + buildOptions.entryPoints = [entry]; + buildOptions.plugins.push({ + name: 'angular-component-styles', + setup(build) { + build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { + if (args.kind !== 'entry-point') { + return null; + } + + if (inline) { + return { + path: args.path, + namespace, + }; + } else { + return { + path: filename, + }; + } + }); + build.onLoad({ filter: /^angular:styles\/component;css;/, namespace }, async () => { + return { + contents: data, + loader: 'css', + resolveDir: path.dirname(filename), + }; + }); + }, }); + // Execute esbuild + const result = await bundle(options.workspaceRoot, buildOptions); + // Extract the result of the bundling from the output files let contents = ''; let map; @@ -88,42 +142,3 @@ async function bundleStylesheet( resourceFiles, }; } - -/** - * Bundle a stylesheet that exists as a file on the filesystem. - * - * @param filename The path to the file to bundle. - * @param options The stylesheet bundling options to use. - * @returns The bundle result object. - */ -export async function bundleStylesheetFile(filename: string, options: BundleStylesheetOptions) { - return bundleStylesheet({ entryPoints: [filename] }, options); -} - -/** - * Bundle stylesheet text data from a string. - * - * @param data The string content of a stylesheet to bundle. - * @param dataOptions The options to use to resolve references and name output of the stylesheet data. - * @param bundleOptions The stylesheet bundling options to use. - * @returns The bundle result object. - */ -export async function bundleStylesheetText( - data: string, - dataOptions: { resolvePath: string; virtualName?: string }, - bundleOptions: BundleStylesheetOptions, -) { - const result = bundleStylesheet( - { - stdin: { - contents: data, - sourcefile: dataOptions.virtualName, - resolveDir: dataOptions.resolvePath, - loader: 'css', - }, - }, - bundleOptions, - ); - - return result; -}