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 950883ab0330..ea2019a88e91 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 @@ -32,7 +32,7 @@ import { extractLicenses } from './license-extractor'; import { BrowserEsbuildOptions, NormalizedBrowserOptions, normalizeOptions } from './options'; import { Schema as BrowserBuilderOptions } from './schema'; import { createSourcemapIngorelistPlugin } from './sourcemap-ignorelist-plugin'; -import { shutdownSassWorkerPool } from './stylesheets/sass-plugin'; +import { shutdownSassWorkerPool } from './stylesheets/sass-language'; import type { ChangedFiles } from './watcher'; const compressAsync = promisify(brotliCompress); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts index 5184444cae04..587d9e337a77 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts @@ -11,10 +11,11 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { BundlerContext } from '../esbuild'; import { LoadResultCache } from '../load-result-cache'; -import { createCssPlugin } from './css-plugin'; +import { CssStylesheetLanguage } from './css-language'; import { createCssResourcePlugin } from './css-resource-plugin'; -import { createLessPlugin } from './less-plugin'; -import { createSassPlugin } from './sass-plugin'; +import { LessStylesheetLanguage } from './less-language'; +import { SassStylesheetLanguage } from './sass-language'; +import { StylesheetPluginFactory } from './stylesheet-plugin-factory'; /** * A counter for component styles used to generate unique build-time identifiers for each stylesheet. @@ -44,6 +45,17 @@ export function createStylesheetBundleOptions( path.resolve(options.workspaceRoot, includePath), ); + const pluginFactory = new StylesheetPluginFactory( + { + sourcemap: !!options.sourcemap, + includePaths, + inlineComponentData, + browsers: options.browsers, + tailwindConfiguration: options.tailwindConfiguration, + }, + cache, + ); + return { absWorkingDir: options.workspaceRoot, bundle: true, @@ -62,31 +74,9 @@ export function createStylesheetBundleOptions( conditions: ['style', 'sass'], mainFields: ['style', 'sass'], plugins: [ - createSassPlugin( - { - sourcemap: !!options.sourcemap, - loadPaths: includePaths, - inlineComponentData, - }, - cache, - ), - createLessPlugin( - { - sourcemap: !!options.sourcemap, - includePaths, - inlineComponentData, - }, - cache, - ), - createCssPlugin( - { - sourcemap: !!options.sourcemap, - inlineComponentData, - browsers: options.browsers, - tailwindConfiguration: options.tailwindConfiguration, - }, - cache, - ), + pluginFactory.create(SassStylesheetLanguage), + pluginFactory.create(LessStylesheetLanguage), + pluginFactory.create(CssStylesheetLanguage), createCssResourcePlugin(cache), ], }; diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-language.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-language.ts new file mode 100644 index 000000000000..7b18b269bde8 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-language.ts @@ -0,0 +1,15 @@ +/** + * @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 { StylesheetLanguage } from './stylesheet-plugin-factory'; + +export const CssStylesheetLanguage = Object.freeze({ + name: 'css', + componentFilter: /^css;/, + fileFilter: /\.css$/, +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-plugin.ts deleted file mode 100644 index 9c5c1e544472..000000000000 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-plugin.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @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 createAutoPrefixerPlugin from 'autoprefixer'; -import type { OnLoadResult, Plugin, PluginBuild } from 'esbuild'; -import assert from 'node:assert'; -import { readFile } from 'node:fs/promises'; -import { LoadResultCache, createCachedLoad } from '../load-result-cache'; - -/** - * The lazy-loaded instance of the postcss stylesheet postprocessor. - * It is only imported and initialized if postcss is needed. - */ -let postcss: typeof import('postcss')['default'] | undefined; - -/** - * An object containing the plugin options to use when processing CSS stylesheets. - */ -export interface CssPluginOptions { - /** - * Controls the use and creation of sourcemaps when processing the stylesheets. - * If true, sourcemap processing is enabled; if false, disabled. - */ - sourcemap: boolean; - /** - * Optional component data for any inline styles from Component decorator `styles` fields. - * The key is an internal angular resource URI and the value is the stylesheet content. - */ - inlineComponentData?: Record; - /** - * The browsers to support in browserslist format when processing stylesheets. - * Some postcss plugins such as autoprefixer require the raw browserslist information instead - * of the esbuild formatted target. - */ - browsers: string[]; - - tailwindConfiguration?: { file: string; package: string }; -} - -/** - * Creates an esbuild plugin to process CSS stylesheets. - * @param options An object containing the plugin options. - * @returns An esbuild Plugin instance. - */ -export function createCssPlugin(options: CssPluginOptions, cache?: LoadResultCache): Plugin { - return { - name: 'angular-css', - async setup(build: PluginBuild): Promise { - const autoprefixer = createAutoPrefixerPlugin({ - overrideBrowserslist: options.browsers, - ignoreUnknownVersions: true, - }); - - // Autoprefixer currently does not contain a method to check if autoprefixer is required - // based on the provided list of browsers. However, it does contain a method that returns - // informational text that can be used as a replacement. The text "Awesome!" will be present - // when autoprefixer determines no actions are needed. - // ref: https://github.com/postcss/autoprefixer/blob/e2f5c26ff1f3eaca95a21873723ce1cdf6e59f0e/lib/info.js#L118 - const autoprefixerInfo = autoprefixer.info({ from: build.initialOptions.absWorkingDir }); - const skipAutoprefixer = autoprefixerInfo.includes('Awesome!'); - - if (skipAutoprefixer && !options.tailwindConfiguration) { - return; - } - - postcss ??= (await import('postcss')).default; - const postcssProcessor = postcss(); - if (options.tailwindConfiguration) { - const tailwind = await import(options.tailwindConfiguration.package); - postcssProcessor.use(tailwind.default({ config: options.tailwindConfiguration.file })); - } - if (!skipAutoprefixer) { - postcssProcessor.use(autoprefixer); - } - - // Add a load callback to support inline Component styles - build.onLoad( - { filter: /^css;/, namespace: 'angular:styles/component' }, - createCachedLoad(cache, async (args) => { - const data = options.inlineComponentData?.[args.path]; - assert( - typeof data === 'string', - `component style name should always be found [${args.path}]`, - ); - - const [, , filePath] = args.path.split(';', 3); - - return compileString(data, filePath, postcssProcessor, options); - }), - ); - - // Add a load callback to support files from disk - build.onLoad( - { filter: /\.css$/ }, - createCachedLoad(cache, async (args) => { - const data = await readFile(args.path, 'utf-8'); - - return compileString(data, args.path, postcssProcessor, options); - }), - ); - }, - }; -} - -/** - * Compiles the provided CSS stylesheet data using a provided postcss processor and provides an - * esbuild load result that can be used directly by an esbuild Plugin. - * @param data The stylesheet content to process. - * @param filename The name of the file that contains the data. - * @param postcssProcessor A postcss processor instance to use. - * @param options The plugin options to control the processing. - * @returns An esbuild OnLoaderResult object with the processed content, warnings, and/or errors. - */ -async function compileString( - data: string, - filename: string, - postcssProcessor: import('postcss').Processor, - options: CssPluginOptions, -): Promise { - try { - const result = await postcssProcessor.process(data, { - from: filename, - to: filename, - map: options.sourcemap && { - inline: true, - sourcesContent: true, - }, - }); - - const rawWarnings = result.warnings(); - let warnings; - if (rawWarnings.length > 0) { - const lineMappings = new Map(); - warnings = rawWarnings.map((warning) => { - const file = warning.node.source?.input.file; - if (file === undefined) { - return { text: warning.text }; - } - - let lines = lineMappings.get(file); - if (lines === undefined) { - lines = warning.node.source?.input.css.split(/\r?\n/); - lineMappings.set(file, lines ?? null); - } - - return { - text: warning.text, - location: { - file, - line: warning.line, - column: warning.column - 1, - lineText: lines?.[warning.line - 1], - }, - }; - }); - } - - return { - contents: result.css, - loader: 'css', - warnings, - watchFiles: [filename], - }; - } catch (error) { - postcss ??= (await import('postcss')).default; - if (error instanceof postcss.CssSyntaxError) { - const lines = error.source?.split(/\r?\n/); - - return { - errors: [ - { - text: error.reason, - location: { - file: error.file, - line: error.line, - column: error.column && error.column - 1, - lineText: error.line === undefined ? undefined : lines?.[error.line - 1], - }, - }, - ], - }; - } - - throw error; - } -} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-language.ts similarity index 69% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-plugin.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-language.ts index 1bf2b367c403..ce5d104d5491 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-language.ts @@ -6,10 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import type { OnLoadResult, Plugin, PluginBuild } from 'esbuild'; -import assert from 'node:assert'; +import type { OnLoadResult, PluginBuild } from 'esbuild'; import { readFile } from 'node:fs/promises'; -import { LoadResultCache, createCachedLoad } from '../load-result-cache'; +import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; /** * The lazy-loaded instance of the less stylesheet preprocessor. @@ -17,12 +16,6 @@ import { LoadResultCache, createCachedLoad } from '../load-result-cache'; */ let lessPreprocessor: typeof import('less') | undefined; -export interface LessPluginOptions { - sourcemap: boolean; - includePaths?: string[]; - inlineComponentData?: Record; -} - interface LessException extends Error { filename: string; line: number; @@ -34,43 +27,19 @@ function isLessException(error: unknown): error is LessException { return !!error && typeof error === 'object' && 'column' in error; } -export function createLessPlugin(options: LessPluginOptions, cache?: LoadResultCache): Plugin { - return { - name: 'angular-less', - setup(build: PluginBuild): void { - // Add a load callback to support inline Component styles - build.onLoad( - { filter: /^less;/, namespace: 'angular:styles/component' }, - createCachedLoad(cache, async (args) => { - const data = options.inlineComponentData?.[args.path]; - assert( - typeof data === 'string', - `component style name should always be found [${args.path}]`, - ); - - const [, , filePath] = args.path.split(';', 3); - - return compileString(data, filePath, options, build.resolve.bind(build)); - }), - ); - - // Add a load callback to support files from disk - build.onLoad( - { filter: /\.less$/ }, - createCachedLoad(cache, async (args) => { - const data = await readFile(args.path, 'utf-8'); - - return compileString(data, args.path, options, build.resolve.bind(build)); - }), - ); - }, - }; -} +export const LessStylesheetLanguage = Object.freeze({ + name: 'less', + componentFilter: /^less;/, + fileFilter: /\.less$/, + process(data, file, _, options, build) { + return compileString(data, file, options, build.resolve.bind(build)); + }, +}); async function compileString( data: string, filename: string, - options: LessPluginOptions, + options: StylesheetPluginOptions, resolver: PluginBuild['resolve'], ): Promise { const less = (lessPreprocessor ??= (await import('less')).default); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-language.ts similarity index 66% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-plugin.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-language.ts index d967b67db06d..559fdd8fe114 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-language.ts @@ -6,23 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import type { OnLoadResult, PartialMessage, Plugin, PluginBuild, ResolveResult } from 'esbuild'; -import assert from 'node:assert'; -import { readFile } from 'node:fs/promises'; -import { dirname, extname, join, relative } from 'node:path'; +import type { OnLoadResult, PartialMessage, ResolveResult } from 'esbuild'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { CompileResult, Exception, Syntax } from 'sass'; import type { FileImporterWithRequestContextOptions, SassWorkerImplementation, } from '../../../sass/sass-service'; -import { LoadResultCache, createCachedLoad } from '../load-result-cache'; - -export interface SassPluginOptions { - sourcemap: boolean; - loadPaths?: string[]; - inlineComponentData?: Record; -} +import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; let sassWorkerPool: SassWorkerImplementation | undefined; @@ -35,70 +27,45 @@ export function shutdownSassWorkerPool(): void { sassWorkerPool = undefined; } -export function createSassPlugin(options: SassPluginOptions, cache?: LoadResultCache): Plugin { - return { - name: 'angular-sass', - setup(build: PluginBuild): void { - const resolveUrl = async (url: string, previousResolvedModules?: Set) => { - let result = await build.resolve(url, { - kind: 'import-rule', - // This should ideally be the directory of the importer file from Sass - // but that is not currently available from the Sass importer API. - resolveDir: build.initialOptions.absWorkingDir, - }); - - // Workaround to support Yarn PnP without access to the importer file from Sass - if (!result.path && previousResolvedModules?.size) { - for (const previous of previousResolvedModules) { - result = await build.resolve(url, { - kind: 'import-rule', - resolveDir: previous, - }); - if (result.path) { - break; - } +export const SassStylesheetLanguage = Object.freeze({ + name: 'sass', + componentFilter: /^s[ac]ss;/, + fileFilter: /\.s[ac]ss$/, + process(data, file, format, options, build) { + const syntax = format === 'sass' ? 'indented' : 'scss'; + const resolveUrl = async (url: string, previousResolvedModules?: Set) => { + let result = await build.resolve(url, { + kind: 'import-rule', + // This should ideally be the directory of the importer file from Sass + // but that is not currently available from the Sass importer API. + resolveDir: build.initialOptions.absWorkingDir, + }); + + // Workaround to support Yarn PnP without access to the importer file from Sass + if (!result.path && previousResolvedModules?.size) { + for (const previous of previousResolvedModules) { + result = await build.resolve(url, { + kind: 'import-rule', + resolveDir: previous, + }); + if (result.path) { + break; } } + } - return result; - }; + return result; + }; - // Load inline component stylesheets - build.onLoad( - { filter: /^s[ac]ss;/, namespace: 'angular:styles/component' }, - createCachedLoad(cache, async (args) => { - const data = options.inlineComponentData?.[args.path]; - assert( - typeof data === 'string', - `component style name should always be found [${args.path}]`, - ); - - const [language, , filePath] = args.path.split(';', 3); - const syntax = language === 'sass' ? 'indented' : 'scss'; - - return compileString(data, filePath, syntax, options, resolveUrl); - }), - ); - - // Load file stylesheets - build.onLoad( - { filter: /\.s[ac]ss$/ }, - createCachedLoad(cache, 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); - }), - ); - }, - }; -} + return compileString(data, file, syntax, options, resolveUrl); + }, +}); async function compileString( data: string, filePath: string, syntax: Syntax, - options: SassPluginOptions, + options: StylesheetPluginOptions, resolveUrl: (url: string, previousResolvedModules?: Set) => Promise, ): Promise { // Lazily load Sass when a Sass file is found @@ -113,7 +80,7 @@ async function compileString( url: pathToFileURL(filePath), style: 'expanded', syntax, - loadPaths: options.loadPaths, + loadPaths: options.includePaths, sourceMap: options.sourcemap, sourceMapIncludeSources: options.sourcemap, quietDeps: true, diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/stylesheet-plugin-factory.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/stylesheet-plugin-factory.ts new file mode 100644 index 000000000000..9981dca79c27 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/stylesheet-plugin-factory.ts @@ -0,0 +1,298 @@ +/** + * @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 createAutoPrefixerPlugin from 'autoprefixer'; +import type { OnLoadResult, Plugin, PluginBuild } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +/** + * The lazy-loaded instance of the postcss stylesheet postprocessor. + * It is only imported and initialized if postcss is needed. + */ +let postcss: typeof import('postcss')['default'] | undefined; + +/** + * An object containing the plugin options to use when processing stylesheets. + */ +export interface StylesheetPluginOptions { + /** + * Controls the use and creation of sourcemaps when processing the stylesheets. + * If true, sourcemap processing is enabled; if false, disabled. + */ + sourcemap: boolean; + + includePaths?: string[]; + + /** + * Optional component data for any inline styles from Component decorator `styles` fields. + * The key is an internal angular resource URI and the value is the stylesheet content. + */ + inlineComponentData?: Record; + + /** + * The browsers to support in browserslist format when processing stylesheets. + * Some postcss plugins such as autoprefixer require the raw browserslist information instead + * of the esbuild formatted target. + */ + browsers: string[]; + + tailwindConfiguration?: { file: string; package: string }; +} + +export interface StylesheetLanguage { + name: string; + componentFilter: RegExp; + fileFilter: RegExp; + process?( + data: string, + file: string, + format: string, + options: StylesheetPluginOptions, + build: PluginBuild, + ): OnLoadResult | Promise; +} + +export class StylesheetPluginFactory { + private autoprefixer: import('postcss').Plugin | undefined; + + constructor( + private readonly options: StylesheetPluginOptions, + private readonly cache?: LoadResultCache, + ) { + const autoprefixer = createAutoPrefixerPlugin({ + overrideBrowserslist: options.browsers, + ignoreUnknownVersions: true, + }); + + // Autoprefixer currently does not contain a method to check if autoprefixer is required + // based on the provided list of browsers. However, it does contain a method that returns + // informational text that can be used as a replacement. The text "Awesome!" will be present + // when autoprefixer determines no actions are needed. + // ref: https://github.com/postcss/autoprefixer/blob/e2f5c26ff1f3eaca95a21873723ce1cdf6e59f0e/lib/info.js#L118 + const autoprefixerInfo = autoprefixer.info(); + const skipAutoprefixer = autoprefixerInfo.includes('Awesome!'); + + if (!skipAutoprefixer) { + this.autoprefixer = autoprefixer; + } + } + + create(language: Readonly): Plugin { + // Return a noop plugin if no load actions are required + if (!language.process && !this.autoprefixer && !this.options.tailwindConfiguration) { + return { + name: 'angular-' + language.name, + setup() {}, + }; + } + + const { autoprefixer, cache, options } = this; + + return { + name: 'angular-' + language.name, + async setup(build) { + // Setup postcss if needed by either autoprefixer or tailwind + // TODO: Move this into the plugin factory to avoid repeat setup per created plugin + let postcssProcessor: import('postcss').Processor | undefined; + if (autoprefixer || options.tailwindConfiguration) { + postcss ??= (await import('postcss')).default; + postcssProcessor = postcss(); + if (options.tailwindConfiguration) { + const tailwind = await import(options.tailwindConfiguration.package); + postcssProcessor.use(tailwind.default({ config: options.tailwindConfiguration.file })); + } + if (autoprefixer) { + postcssProcessor.use(autoprefixer); + } + } + + // Add a load callback to support inline Component styles + build.onLoad( + { filter: language.componentFilter, namespace: 'angular:styles/component' }, + createCachedLoad(cache, async (args) => { + const data = options.inlineComponentData?.[args.path]; + assert( + typeof data === 'string', + `component style name should always be found [${args.path}]`, + ); + + const [format, , filename] = args.path.split(';', 3); + + return processStylesheet( + language, + data, + filename, + format, + options, + build, + postcssProcessor, + ); + }), + ); + + // Add a load callback to support files from disk + build.onLoad( + { filter: language.fileFilter }, + createCachedLoad(cache, async (args) => { + const data = await readFile(args.path, 'utf-8'); + + return processStylesheet( + language, + data, + args.path, + extname(args.path).toLowerCase().slice(1), + options, + build, + postcssProcessor, + ); + }), + ); + }, + }; + } +} + +async function processStylesheet( + language: Readonly, + data: string, + filename: string, + format: string, + options: StylesheetPluginOptions, + build: PluginBuild, + postcssProcessor: import('postcss').Processor | undefined, +) { + let result: OnLoadResult; + + // Process the input data if the language requires preprocessing + if (language.process) { + result = await language.process(data, filename, format, options, build); + } else { + result = { + contents: data, + loader: 'css', + watchFiles: [filename], + }; + } + + // Transform with postcss if needed and there are no errors + if (postcssProcessor && result.contents && !result.errors?.length) { + const postcssResult = await compileString( + typeof result.contents === 'string' + ? result.contents + : Buffer.from(result.contents).toString('utf-8'), + filename, + postcssProcessor, + options, + ); + + // Merge results + if (postcssResult.errors?.length) { + delete result.contents; + } + if (result.warnings && postcssResult.warnings) { + postcssResult.warnings.unshift(...result.warnings); + } + if (result.watchFiles && postcssResult.watchFiles) { + postcssResult.watchFiles.unshift(...result.watchFiles); + } + if (result.watchDirs && postcssResult.watchDirs) { + postcssResult.watchDirs.unshift(...result.watchDirs); + } + result = { + ...result, + ...postcssResult, + }; + } + + return result; +} + +/** + * Compiles the provided CSS stylesheet data using a provided postcss processor and provides an + * esbuild load result that can be used directly by an esbuild Plugin. + * @param data The stylesheet content to process. + * @param filename The name of the file that contains the data. + * @param postcssProcessor A postcss processor instance to use. + * @param options The plugin options to control the processing. + * @returns An esbuild OnLoaderResult object with the processed content, warnings, and/or errors. + */ +async function compileString( + data: string, + filename: string, + postcssProcessor: import('postcss').Processor, + options: StylesheetPluginOptions, +): Promise { + try { + const result = await postcssProcessor.process(data, { + from: filename, + to: filename, + map: options.sourcemap && { + inline: true, + sourcesContent: true, + }, + }); + + const rawWarnings = result.warnings(); + let warnings; + if (rawWarnings.length > 0) { + const lineMappings = new Map(); + warnings = rawWarnings.map((warning) => { + const file = warning.node.source?.input.file; + if (file === undefined) { + return { text: warning.text }; + } + + let lines = lineMappings.get(file); + if (lines === undefined) { + lines = warning.node.source?.input.css.split(/\r?\n/); + lineMappings.set(file, lines ?? null); + } + + return { + text: warning.text, + location: { + file, + line: warning.line, + column: warning.column - 1, + lineText: lines?.[warning.line - 1], + }, + }; + }); + } + + return { + contents: result.css, + loader: 'css', + warnings, + }; + } catch (error) { + postcss ??= (await import('postcss')).default; + if (error instanceof postcss.CssSyntaxError) { + const lines = error.source?.split(/\r?\n/); + + return { + errors: [ + { + text: error.reason, + location: { + file: error.file, + line: error.line, + column: error.column && error.column - 1, + lineText: error.line === undefined ? undefined : lines?.[error.line - 1], + }, + }, + ], + }; + } + + throw error; + } +}