Skip to content

Commit

Permalink
perf(@angular-devkit/build-angular): inject Sass import/use directive…
Browse files Browse the repository at this point in the history
… importer information when resolving

To correctly resolve a package based import reference in a Sass file with pnpm or Yarn PnP, the importer
file path must be known. Unfortunately, the Sass compiler does not provided the importer file to import plugins.
Previously to workaround this issue, all previously resolved stylesheets were tried as the importer path. This
allowed the stylesheets to be resolved but it also could cause a potentially large increase in build time due
to the amount of previous stylesheets that would need to be tried. To avoid the performance impact and to also
provide more accurate information regarding the importer file, a lexer is now used to extract import information
for a stylesheet and inject the importer file path into the specifier. This information is then extracted from the
import specifier during the Sass resolution process and allows the underlying package resolution access to a viable
location to resolve the package for all package managers. This information is currently limited to specifiers
referencing the `@angular` and `@material` package scopes but a comprehensive pre-resolution process may be added
in the future.
  • Loading branch information
clydin committed Jul 14, 2023
1 parent 6375270 commit 61a652d
Show file tree
Hide file tree
Showing 4 changed files with 355 additions and 196 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,21 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
fileFilter: /\.s[ac]ss$/,
process(data, file, format, options, build) {
const syntax = format === 'sass' ? 'indented' : 'scss';
const resolveUrl = async (url: string, previousResolvedModules?: Set<string>) => {
const resolveUrl = async (url: string, options: FileImporterWithRequestContextOptions) => {
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,
// Use the provided resolve directory from the custom Sass service if available
resolveDir: options.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) {
// 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
if (!result.path && options.previousResolvedModules?.size) {
for (const previous of options.previousResolvedModules) {
result = await build.resolve(url, {
kind: 'import-rule',
resolveDir: previous,
Expand All @@ -66,7 +70,10 @@ async function compileString(
filePath: string,
syntax: Syntax,
options: StylesheetPluginOptions,
resolveUrl: (url: string, previousResolvedModules?: Set<string>) => Promise<ResolveResult>,
resolveUrl: (
url: string,
options: FileImporterWithRequestContextOptions,
) => Promise<ResolveResult>,
): Promise<OnLoadResult> {
// Lazily load Sass when a Sass file is found
if (sassWorkerPool === undefined) {
Expand All @@ -88,9 +95,9 @@ async function compileString(
{
findFileUrl: async (
url,
{ previousResolvedModules }: FileImporterWithRequestContextOptions,
options: FileImporterWithRequestContextOptions,
): Promise<URL | null> => {
let result = await resolveUrl(url);
const result = await resolveUrl(url, options);
if (result.path) {
return pathToFileURL(result.path);
}
Expand All @@ -101,30 +108,7 @@ async function compileString(
const [nameOrScope, nameOrFirstPath, ...pathPart] = parts;
const packageName = hasScope ? `${nameOrScope}/${nameOrFirstPath}` : nameOrScope;

let packageResult = await resolveUrl(packageName + '/package.json');

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

// Check with Yarn PnP workaround using previous resolved modules.
// This is done last to avoid a performance penalty for common cases.

result = await resolveUrl(url, previousResolvedModules);
if (result.path) {
return pathToFileURL(result.path);
}

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

if (packageResult.path) {
return pathToFileURL(
Expand Down
267 changes: 267 additions & 0 deletions packages/angular_devkit/build_angular/src/tools/sass/lexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* @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
*/

// TODO: Combine everything into a single pass lexer

/**
* Determines if a unicode code point is a CSS whitespace character.
* @param code The unicode code point to test.
* @returns true, if the code point is CSS whitespace; false, otherwise.
*/
function isWhitespace(code: number): boolean {
// Based on https://www.w3.org/TR/css-syntax-3/#whitespace
switch (code) {
case 0x0009: // tab
case 0x0020: // space
case 0x000a: // line feed
case 0x000c: // form feed
case 0x000d: // carriage return
return true;
default:
return false;
}
}

/**
* Scans a CSS or Sass file and locates all valid url function values as defined by the
* syntax specification.
* @param contents A string containing a CSS or Sass file to scan.
* @returns An iterable that yields each CSS url function value found.
*/
export function* findUrls(
contents: string,
): Iterable<{ start: number; end: number; value: string }> {
let pos = 0;
let width = 1;
let current = -1;
const next = () => {
pos += width;
current = contents.codePointAt(pos) ?? -1;
width = current > 0xffff ? 2 : 1;

return current;
};

// Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
while ((pos = contents.indexOf('url(', pos)) !== -1) {
// Set to position of the (
pos += 3;
width = 1;

// Consume all leading whitespace
while (isWhitespace(next())) {
/* empty */
}

// Initialize URL state
const url = { start: pos, end: -1, value: '' };
let complete = false;

// If " or ', then consume the value as a string
if (current === 0x0022 || current === 0x0027) {
const ending = current;
// Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token
while (!complete) {
switch (next()) {
case -1: // EOF
return;
case 0x000a: // line feed
case 0x000c: // form feed
case 0x000d: // carriage return
// Invalid
complete = true;
break;
case 0x005c: // \ -- character escape
// If not EOF or newline, add the character after the escape
switch (next()) {
case -1:
return;
case 0x000a: // line feed
case 0x000c: // form feed
case 0x000d: // carriage return
// Skip when inside a string
break;
default:
// TODO: Handle hex escape codes
url.value += String.fromCodePoint(current);
break;
}
break;
case ending:
// Full string position should include the quotes for replacement
url.end = pos + 1;
complete = true;
yield url;
break;
default:
url.value += String.fromCodePoint(current);
break;
}
}

next();
continue;
}

// Based on https://www.w3.org/TR/css-syntax-3/#consume-url-token
while (!complete) {
switch (current) {
case -1: // EOF
return;
case 0x0022: // "
case 0x0027: // '
case 0x0028: // (
// Invalid
complete = true;
break;
case 0x0029: // )
// URL is valid and complete
url.end = pos;
complete = true;
break;
case 0x005c: // \ -- character escape
// If not EOF or newline, add the character after the escape
switch (next()) {
case -1: // EOF
return;
case 0x000a: // line feed
case 0x000c: // form feed
case 0x000d: // carriage return
// Invalid
complete = true;
break;
default:
// TODO: Handle hex escape codes
url.value += String.fromCodePoint(current);
break;
}
break;
default:
if (isWhitespace(current)) {
while (isWhitespace(next())) {
/* empty */
}
// Unescaped whitespace is only valid before the closing )
if (current === 0x0029) {
// URL is valid
url.end = pos;
}
complete = true;
} else {
// Add the character to the url value
url.value += String.fromCodePoint(current);
}
break;
}
next();
}

// An end position indicates a URL was found
if (url.end !== -1) {
yield url;
}
}
}

/**
* Scans a CSS or Sass file and locates all valid import/use directive values as defined by the
* syntax specification.
* @param contents A string containing a CSS or Sass file to scan.
* @returns An iterable that yields each CSS directive value found.
*/
export function* findImports(
contents: string,
): Iterable<{ start: number; end: number; specifier: string }> {
yield* find(contents, '@import ');
yield* find(contents, '@use ');
}

/**
* Scans a CSS or Sass file and locates all valid function/directive values as defined by the
* syntax specification.
* @param contents A string containing a CSS or Sass file to scan.
* @param prefix The prefix to start a valid segment.
* @returns An iterable that yields each CSS url function value found.
*/
function* find(
contents: string,
prefix: string,
): Iterable<{ start: number; end: number; specifier: string }> {
let pos = 0;
let width = 1;
let current = -1;
const next = () => {
pos += width;
current = contents.codePointAt(pos) ?? -1;
width = current > 0xffff ? 2 : 1;

return current;
};

// Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
while ((pos = contents.indexOf(prefix, pos)) !== -1) {
// Set to position of the last character in prefix
pos += prefix.length - 1;
width = 1;

// Consume all leading whitespace
while (isWhitespace(next())) {
/* empty */
}

// Initialize URL state
const url = { start: pos, end: -1, specifier: '' };
let complete = false;

// If " or ', then consume the value as a string
if (current === 0x0022 || current === 0x0027) {
const ending = current;
// Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token
while (!complete) {
switch (next()) {
case -1: // EOF
return;
case 0x000a: // line feed
case 0x000c: // form feed
case 0x000d: // carriage return
// Invalid
complete = true;
break;
case 0x005c: // \ -- character escape
// If not EOF or newline, add the character after the escape
switch (next()) {
case -1:
return;
case 0x000a: // line feed
case 0x000c: // form feed
case 0x000d: // carriage return
// Skip when inside a string
break;
default:
// TODO: Handle hex escape codes
url.specifier += String.fromCodePoint(current);
break;
}
break;
case ending:
// Full string position should include the quotes for replacement
url.end = pos + 1;
complete = true;
yield url;
break;
default:
url.specifier += String.fromCodePoint(current);
break;
}
}

next();
continue;
}
}
}
Loading

0 comments on commit 61a652d

Please sign in to comment.