refactor(@angular-devkit/build-angular): remove Sass module resolve workarounds

The recent version of the Sass compiler (`dart-sass@1.68.0`) now provides
additional information within an importer that allows more accurate resolution of node
modules packages without several existing workarounds. Previously, the Sass files needed
to be pre-processed to extract all `@import` and `@use` paths so that information regarding
the containing Sass file could be used to fully resolve the paths. The Sass compiler now
provides this information directly.
This commit is contained in:
Charles Lyding 2023-10-23 09:53:43 -04:00 committed by Charles
parent babe4674f8
commit 0862a38441
5 changed files with 40 additions and 149 deletions

View File

@ -9,11 +9,8 @@
import type { OnLoadResult, PartialMessage, ResolveResult } from 'esbuild'; import type { OnLoadResult, PartialMessage, ResolveResult } from 'esbuild';
import { dirname, join, relative } from 'node:path'; import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import type { CompileResult, Exception, Syntax } from 'sass'; import type { CanonicalizeContext, CompileResult, Exception, Syntax } from 'sass';
import type { import type { SassWorkerImplementation } from '../../sass/sass-service';
FileImporterWithRequestContextOptions,
SassWorkerImplementation,
} from '../../sass/sass-service';
import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory';
let sassWorkerPool: SassWorkerImplementation | undefined; let sassWorkerPool: SassWorkerImplementation | undefined;
@ -39,30 +36,16 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
fileFilter: /\.s[ac]ss$/, fileFilter: /\.s[ac]ss$/,
process(data, file, format, options, build) { process(data, file, format, options, build) {
const syntax = format === 'sass' ? 'indented' : 'scss'; const syntax = format === 'sass' ? 'indented' : 'scss';
const resolveUrl = async (url: string, options: FileImporterWithRequestContextOptions) => { const resolveUrl = async (url: string, options: CanonicalizeContext) => {
let result = await build.resolve(url, { let resolveDir = build.initialOptions.absWorkingDir;
kind: 'import-rule', if (options.containingUrl) {
// Use the provided resolve directory from the custom Sass service if available resolveDir = dirname(fileURLToPath(options.containingUrl));
resolveDir: options.resolveDir ?? build.initialOptions.absWorkingDir,
});
// If a resolve directory is provided, no additional speculative resolutions are required
if (options.resolveDir) {
return result;
} }
// Workaround to support Yarn PnP and pnpm without access to the importer file from Sass const result = await build.resolve(url, {
if (!result.path && options.previousResolvedModules?.size) {
for (const previous of options.previousResolvedModules) {
result = await build.resolve(url, {
kind: 'import-rule', kind: 'import-rule',
resolveDir: previous, resolveDir,
}); });
if (result.path) {
break;
}
}
}
return result; return result;
}; };
@ -103,10 +86,7 @@ async function compileString(
filePath: string, filePath: string,
syntax: Syntax, syntax: Syntax,
options: StylesheetPluginOptions, options: StylesheetPluginOptions,
resolveUrl: ( resolveUrl: (url: string, options: CanonicalizeContext) => Promise<ResolveResult>,
url: string,
options: FileImporterWithRequestContextOptions,
) => Promise<ResolveResult>,
): Promise<OnLoadResult> { ): Promise<OnLoadResult> {
// Lazily load Sass when a Sass file is found // Lazily load Sass when a Sass file is found
if (sassWorkerPool === undefined) { if (sassWorkerPool === undefined) {
@ -139,7 +119,7 @@ async function compileString(
quietDeps: true, quietDeps: true,
importers: [ importers: [
{ {
findFileUrl: (url, options: FileImporterWithRequestContextOptions) => findFileUrl: (url, options) =>
resolutionCache.getOrCreate(url, async () => { resolutionCache.getOrCreate(url, async () => {
const result = await resolveUrl(url, options); const result = await resolveUrl(url, options);
if (result.path) { if (result.path) {

View File

@ -12,7 +12,7 @@ import { readFileSync, readdirSync } from 'node:fs';
import { basename, dirname, extname, join, relative } from 'node:path'; import { basename, dirname, extname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import type { CanonicalizeContext, Importer, ImporterResult, Syntax } from 'sass'; import type { CanonicalizeContext, Importer, ImporterResult, Syntax } from 'sass';
import { findImports, findUrls } from './lexer'; import { findUrls } from './lexer';
/** /**
* A preprocessed cache entry for the files and directories within a previously searched * A preprocessed cache entry for the files and directories within a previously searched
@ -23,45 +23,6 @@ export interface DirectoryEntry {
directories: Set<string>; directories: Set<string>;
} }
/**
* A prefix that is added to import and use directive specifiers that should be resolved
* as modules and that will contain added resolve directory information.
*
* This functionality is used to workaround the Sass limitation that it does not provide the
* importer file to custom resolution plugins.
*/
const MODULE_RESOLUTION_PREFIX = '__NG_PACKAGE__';
function packModuleSpecifier(specifier: string, resolveDir: string): string {
const packed =
MODULE_RESOLUTION_PREFIX +
';' +
// Encode the resolve directory to prevent unsupported characters from being present when
// Sass processes the URL. This is important on Windows which can contain drive letters
// and colons which would otherwise be interpreted as a URL scheme.
encodeURIComponent(resolveDir) +
';' +
// Escape characters instead of encoding to provide more friendly not found error messages.
// Unescaping is automatically handled by Sass.
// https://developer.mozilla.org/en-US/docs/Web/CSS/url#syntax
specifier.replace(/[()\s'"]/g, '\\$&');
return packed;
}
function unpackModuleSpecifier(specifier: string): { specifier: string; resolveDir?: string } {
if (!specifier.startsWith(`${MODULE_RESOLUTION_PREFIX};`)) {
return { specifier };
}
const values = specifier.split(';', 3);
return {
specifier: values[2],
resolveDir: decodeURIComponent(values[1]),
};
}
/** /**
* A Sass Importer base class that provides the load logic to rebase all `url()` functions * A Sass Importer base class that provides the load logic to rebase all `url()` functions
* within a stylesheet. The rebasing will ensure that the URLs in the output of the Sass compiler * within a stylesheet. The rebasing will ensure that the URLs in the output of the Sass compiler
@ -114,27 +75,6 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
updatedContents.update(start, end, rebasedUrl); updatedContents.update(start, end, rebasedUrl);
} }
// Add resolution directory information to module specifiers to facilitate resolution
for (const { start, end, specifier } of findImports(contents)) {
// Currently only provide directory information for known/common packages:
// * `@material/`
// * `@angular/`
//
// Comprehensive pre-resolution support may be added in the future. This is complicated by CSS/Sass not
// requiring a `./` or `../` prefix to signify relative paths. A bare specifier could be either relative
// or a module specifier. To differentiate, a relative resolution would need to be attempted first.
if (!specifier.startsWith('@angular/') && !specifier.startsWith('@material/')) {
continue;
}
updatedContents ??= new MagicString(contents);
updatedContents.update(
start,
end,
`"${packModuleSpecifier(specifier, stylesheetDirectory)}"`,
);
}
if (updatedContents) { if (updatedContents) {
contents = updatedContents.toString(); contents = updatedContents.toString();
if (this.rebaseSourceMaps) { if (this.rebaseSourceMaps) {
@ -348,10 +288,7 @@ export class ModuleUrlRebasingImporter extends RelativeUrlRebasingImporter {
entryDirectory: string, entryDirectory: string,
directoryCache: Map<string, DirectoryEntry>, directoryCache: Map<string, DirectoryEntry>,
rebaseSourceMaps: Map<string, RawSourceMap> | undefined, rebaseSourceMaps: Map<string, RawSourceMap> | undefined,
private finder: ( private finder: (specifier: string, options: CanonicalizeContext) => URL | null,
specifier: string,
options: CanonicalizeContext & { resolveDir?: string },
) => URL | null,
) { ) {
super(entryDirectory, directoryCache, rebaseSourceMaps); super(entryDirectory, directoryCache, rebaseSourceMaps);
} }
@ -361,9 +298,7 @@ export class ModuleUrlRebasingImporter extends RelativeUrlRebasingImporter {
return super.canonicalize(url, options); return super.canonicalize(url, options);
} }
const { specifier, resolveDir } = unpackModuleSpecifier(url); let result = this.finder(url, options);
let result = this.finder(specifier, { ...options, resolveDir });
result &&= super.canonicalize(result.href, options); result &&= super.canonicalize(result.href, options);
return result; return result;

View File

@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import { dirname, join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { MessageChannel, Worker } from 'node:worker_threads'; import { MessageChannel, Worker } from 'node:worker_threads';
import { import {
CanonicalizeContext,
CompileResult, CompileResult,
Exception, Exception,
FileImporter, FileImporter,
@ -31,24 +32,6 @@ const MAX_RENDER_WORKERS = maxWorkers;
*/ */
type RenderCallback = (error?: Exception, result?: CompileResult) => void; type RenderCallback = (error?: Exception, result?: CompileResult) => void;
type FileImporterOptions = Parameters<FileImporter['findFileUrl']>[1];
export interface FileImporterWithRequestContextOptions extends FileImporterOptions {
/**
* This is a custom option and is required as SASS does not provide context from which the file is being resolved.
* This breaks Yarn PNP as transitive deps cannot be resolved from the workspace root.
*
* Workaround until https://github.com/sass/sass/issues/3247 is addressed.
*/
previousResolvedModules?: Set<string>;
/**
* The base directory to use when resolving the request.
* This value is only set if using the rebasing importers.
*/
resolveDir?: string;
}
/** /**
* An object containing the contextual information for a specific render request. * An object containing the contextual information for a specific render request.
*/ */
@ -58,7 +41,6 @@ interface RenderRequest {
callback: RenderCallback; callback: RenderCallback;
logger?: Logger; logger?: Logger;
importers?: Importers[]; importers?: Importers[];
previousResolvedModules?: Set<string>;
} }
/** /**
@ -244,7 +226,7 @@ export class SassWorkerImplementation {
mainImporterPort.on( mainImporterPort.on(
'message', 'message',
({ id, url, options }: { id: number; url: string; options: FileImporterOptions }) => { ({ id, url, options }: { id: number; url: string; options: CanonicalizeContext }) => {
const request = this.requests.get(id); const request = this.requests.get(id);
if (!request?.importers) { if (!request?.importers) {
mainImporterPort.postMessage(null); mainImporterPort.postMessage(null);
@ -256,14 +238,12 @@ export class SassWorkerImplementation {
this.processImporters(request.importers, url, { this.processImporters(request.importers, url, {
...options, ...options,
previousResolvedModules: request.previousResolvedModules, // URL is not serializable so in the worker we convert to string and here back to URL.
containingUrl: options.containingUrl
? pathToFileURL(options.containingUrl as unknown as string)
: null,
}) })
.then((result) => { .then((result) => {
if (result) {
request.previousResolvedModules ??= new Set();
request.previousResolvedModules.add(dirname(result));
}
mainImporterPort.postMessage(result); mainImporterPort.postMessage(result);
}) })
.catch((error) => { .catch((error) => {
@ -284,7 +264,7 @@ export class SassWorkerImplementation {
private async processImporters( private async processImporters(
importers: Iterable<Importers>, importers: Iterable<Importers>,
url: string, url: string,
options: FileImporterWithRequestContextOptions, options: CanonicalizeContext,
): Promise<string | null> { ): Promise<string | null> {
for (const importer of importers) { for (const importer of importers) {
if (this.isImporter(importer)) { if (this.isImporter(importer)) {

View File

@ -93,7 +93,14 @@ parentPort.on('message', (message: RenderRequestMessage) => {
const proxyImporter: FileImporter<'sync'> = { const proxyImporter: FileImporter<'sync'> = {
findFileUrl: (url, options) => { findFileUrl: (url, options) => {
Atomics.store(importerSignal, 0, 0); Atomics.store(importerSignal, 0, 0);
workerImporterPort.postMessage({ id, url, options }); workerImporterPort.postMessage({
id,
url,
options: {
...options,
containingUrl: options.containingUrl ? fileURLToPath(options.containingUrl) : null,
},
});
Atomics.wait(importerSignal, 0, 0); Atomics.wait(importerSignal, 0, 0);
const result = receiveMessageOnPort(workerImporterPort)?.message as string | null; const result = receiveMessageOnPort(workerImporterPort)?.message as string | null;

View File

@ -8,16 +8,13 @@
import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import * as path from 'node:path'; import * as path from 'node:path';
import { pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import type { FileImporter } from 'sass'; import type { FileImporter } from 'sass';
import type { Configuration, LoaderContext, RuleSetUseItem } from 'webpack'; import type { Configuration, LoaderContext, RuleSetUseItem } from 'webpack';
import { WebpackConfigOptions } from '../../../utils/build-options'; import { WebpackConfigOptions } from '../../../utils/build-options';
import { useLegacySass } from '../../../utils/environment-options'; import { useLegacySass } from '../../../utils/environment-options';
import { findTailwindConfigurationFile } from '../../../utils/tailwind'; import { findTailwindConfigurationFile } from '../../../utils/tailwind';
import { import { SassWorkerImplementation } from '../../sass/sass-service';
FileImporterWithRequestContextOptions,
SassWorkerImplementation,
} from '../../sass/sass-service';
import { SassLegacyWorkerImplementation } from '../../sass/sass-service-legacy'; import { SassLegacyWorkerImplementation } from '../../sass/sass-service-legacy';
import { import {
AnyComponentStyleBudgetChecker, AnyComponentStyleBudgetChecker,
@ -405,28 +402,20 @@ function getSassResolutionImporter(
}); });
return { return {
findFileUrl: async ( findFileUrl: async (url, { fromImport, containingUrl }): Promise<URL | null> => {
url,
{ fromImport, previousResolvedModules }: FileImporterWithRequestContextOptions,
): Promise<URL | null> => {
if (url.charAt(0) === '.') { if (url.charAt(0) === '.') {
// Let Sass handle relative imports. // Let Sass handle relative imports.
return null; return null;
} }
let resolveDir = root;
if (containingUrl) {
resolveDir = path.dirname(fileURLToPath(containingUrl));
}
const resolve = fromImport ? resolveImport : resolveModule; const resolve = fromImport ? resolveImport : resolveModule;
// Try to resolve from root of workspace // Try to resolve from root of workspace
let result = await tryResolve(resolve, root, url); const result = await tryResolve(resolve, resolveDir, url);
// Try to resolve from previously resolved modules.
if (!result && previousResolvedModules) {
for (const path of previousResolvedModules) {
result = await tryResolve(resolve, path, url);
if (result) {
break;
}
}
}
return result ? pathToFileURL(result) : null; return result ? pathToFileURL(result) : null;
}, },