Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(@angular-devkit/build-angular): inject Sass import/use directive importer information when resolving #25534

Merged
merged 1 commit into from Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
@@ -0,0 +1,267 @@
/**
clydin marked this conversation as resolved.
Show resolved Hide resolved
* @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;
}
}
}