Skip to content

Commit 61a652d

Browse files
committed
perf(@angular-devkit/build-angular): inject Sass import/use directive 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.
1 parent 6375270 commit 61a652d

File tree

4 files changed

+355
-196
lines changed

4 files changed

+355
-196
lines changed

Diff for: packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/sass-language.ts

+18-34
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,21 @@ export const SassStylesheetLanguage = Object.freeze<StylesheetLanguage>({
3333
fileFilter: /\.s[ac]ss$/,
3434
process(data, file, format, options, build) {
3535
const syntax = format === 'sass' ? 'indented' : 'scss';
36-
const resolveUrl = async (url: string, previousResolvedModules?: Set<string>) => {
36+
const resolveUrl = async (url: string, options: FileImporterWithRequestContextOptions) => {
3737
let result = await build.resolve(url, {
3838
kind: 'import-rule',
39-
// This should ideally be the directory of the importer file from Sass
40-
// but that is not currently available from the Sass importer API.
41-
resolveDir: build.initialOptions.absWorkingDir,
39+
// Use the provided resolve directory from the custom Sass service if available
40+
resolveDir: options.resolveDir ?? build.initialOptions.absWorkingDir,
4241
});
4342

44-
// Workaround to support Yarn PnP without access to the importer file from Sass
45-
if (!result.path && previousResolvedModules?.size) {
46-
for (const previous of previousResolvedModules) {
43+
// If a resolve directory is provided, no additional speculative resolutions are required
44+
if (options.resolveDir) {
45+
return result;
46+
}
47+
48+
// Workaround to support Yarn PnP and pnpm without access to the importer file from Sass
49+
if (!result.path && options.previousResolvedModules?.size) {
50+
for (const previous of options.previousResolvedModules) {
4751
result = await build.resolve(url, {
4852
kind: 'import-rule',
4953
resolveDir: previous,
@@ -66,7 +70,10 @@ async function compileString(
6670
filePath: string,
6771
syntax: Syntax,
6872
options: StylesheetPluginOptions,
69-
resolveUrl: (url: string, previousResolvedModules?: Set<string>) => Promise<ResolveResult>,
73+
resolveUrl: (
74+
url: string,
75+
options: FileImporterWithRequestContextOptions,
76+
) => Promise<ResolveResult>,
7077
): Promise<OnLoadResult> {
7178
// Lazily load Sass when a Sass file is found
7279
if (sassWorkerPool === undefined) {
@@ -88,9 +95,9 @@ async function compileString(
8895
{
8996
findFileUrl: async (
9097
url,
91-
{ previousResolvedModules }: FileImporterWithRequestContextOptions,
98+
options: FileImporterWithRequestContextOptions,
9299
): Promise<URL | null> => {
93-
let result = await resolveUrl(url);
100+
const result = await resolveUrl(url, options);
94101
if (result.path) {
95102
return pathToFileURL(result.path);
96103
}
@@ -101,30 +108,7 @@ async function compileString(
101108
const [nameOrScope, nameOrFirstPath, ...pathPart] = parts;
102109
const packageName = hasScope ? `${nameOrScope}/${nameOrFirstPath}` : nameOrScope;
103110

104-
let packageResult = await resolveUrl(packageName + '/package.json');
105-
106-
if (packageResult.path) {
107-
return pathToFileURL(
108-
join(
109-
dirname(packageResult.path),
110-
!hasScope && nameOrFirstPath ? nameOrFirstPath : '',
111-
...pathPart,
112-
),
113-
);
114-
}
115-
116-
// Check with Yarn PnP workaround using previous resolved modules.
117-
// This is done last to avoid a performance penalty for common cases.
118-
119-
result = await resolveUrl(url, previousResolvedModules);
120-
if (result.path) {
121-
return pathToFileURL(result.path);
122-
}
123-
124-
packageResult = await resolveUrl(
125-
packageName + '/package.json',
126-
previousResolvedModules,
127-
);
111+
const packageResult = await resolveUrl(packageName + '/package.json', options);
128112

129113
if (packageResult.path) {
130114
return pathToFileURL(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// TODO: Combine everything into a single pass lexer
10+
11+
/**
12+
* Determines if a unicode code point is a CSS whitespace character.
13+
* @param code The unicode code point to test.
14+
* @returns true, if the code point is CSS whitespace; false, otherwise.
15+
*/
16+
function isWhitespace(code: number): boolean {
17+
// Based on https://www.w3.org/TR/css-syntax-3/#whitespace
18+
switch (code) {
19+
case 0x0009: // tab
20+
case 0x0020: // space
21+
case 0x000a: // line feed
22+
case 0x000c: // form feed
23+
case 0x000d: // carriage return
24+
return true;
25+
default:
26+
return false;
27+
}
28+
}
29+
30+
/**
31+
* Scans a CSS or Sass file and locates all valid url function values as defined by the
32+
* syntax specification.
33+
* @param contents A string containing a CSS or Sass file to scan.
34+
* @returns An iterable that yields each CSS url function value found.
35+
*/
36+
export function* findUrls(
37+
contents: string,
38+
): Iterable<{ start: number; end: number; value: string }> {
39+
let pos = 0;
40+
let width = 1;
41+
let current = -1;
42+
const next = () => {
43+
pos += width;
44+
current = contents.codePointAt(pos) ?? -1;
45+
width = current > 0xffff ? 2 : 1;
46+
47+
return current;
48+
};
49+
50+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
51+
while ((pos = contents.indexOf('url(', pos)) !== -1) {
52+
// Set to position of the (
53+
pos += 3;
54+
width = 1;
55+
56+
// Consume all leading whitespace
57+
while (isWhitespace(next())) {
58+
/* empty */
59+
}
60+
61+
// Initialize URL state
62+
const url = { start: pos, end: -1, value: '' };
63+
let complete = false;
64+
65+
// If " or ', then consume the value as a string
66+
if (current === 0x0022 || current === 0x0027) {
67+
const ending = current;
68+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token
69+
while (!complete) {
70+
switch (next()) {
71+
case -1: // EOF
72+
return;
73+
case 0x000a: // line feed
74+
case 0x000c: // form feed
75+
case 0x000d: // carriage return
76+
// Invalid
77+
complete = true;
78+
break;
79+
case 0x005c: // \ -- character escape
80+
// If not EOF or newline, add the character after the escape
81+
switch (next()) {
82+
case -1:
83+
return;
84+
case 0x000a: // line feed
85+
case 0x000c: // form feed
86+
case 0x000d: // carriage return
87+
// Skip when inside a string
88+
break;
89+
default:
90+
// TODO: Handle hex escape codes
91+
url.value += String.fromCodePoint(current);
92+
break;
93+
}
94+
break;
95+
case ending:
96+
// Full string position should include the quotes for replacement
97+
url.end = pos + 1;
98+
complete = true;
99+
yield url;
100+
break;
101+
default:
102+
url.value += String.fromCodePoint(current);
103+
break;
104+
}
105+
}
106+
107+
next();
108+
continue;
109+
}
110+
111+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-url-token
112+
while (!complete) {
113+
switch (current) {
114+
case -1: // EOF
115+
return;
116+
case 0x0022: // "
117+
case 0x0027: // '
118+
case 0x0028: // (
119+
// Invalid
120+
complete = true;
121+
break;
122+
case 0x0029: // )
123+
// URL is valid and complete
124+
url.end = pos;
125+
complete = true;
126+
break;
127+
case 0x005c: // \ -- character escape
128+
// If not EOF or newline, add the character after the escape
129+
switch (next()) {
130+
case -1: // EOF
131+
return;
132+
case 0x000a: // line feed
133+
case 0x000c: // form feed
134+
case 0x000d: // carriage return
135+
// Invalid
136+
complete = true;
137+
break;
138+
default:
139+
// TODO: Handle hex escape codes
140+
url.value += String.fromCodePoint(current);
141+
break;
142+
}
143+
break;
144+
default:
145+
if (isWhitespace(current)) {
146+
while (isWhitespace(next())) {
147+
/* empty */
148+
}
149+
// Unescaped whitespace is only valid before the closing )
150+
if (current === 0x0029) {
151+
// URL is valid
152+
url.end = pos;
153+
}
154+
complete = true;
155+
} else {
156+
// Add the character to the url value
157+
url.value += String.fromCodePoint(current);
158+
}
159+
break;
160+
}
161+
next();
162+
}
163+
164+
// An end position indicates a URL was found
165+
if (url.end !== -1) {
166+
yield url;
167+
}
168+
}
169+
}
170+
171+
/**
172+
* Scans a CSS or Sass file and locates all valid import/use directive values as defined by the
173+
* syntax specification.
174+
* @param contents A string containing a CSS or Sass file to scan.
175+
* @returns An iterable that yields each CSS directive value found.
176+
*/
177+
export function* findImports(
178+
contents: string,
179+
): Iterable<{ start: number; end: number; specifier: string }> {
180+
yield* find(contents, '@import ');
181+
yield* find(contents, '@use ');
182+
}
183+
184+
/**
185+
* Scans a CSS or Sass file and locates all valid function/directive values as defined by the
186+
* syntax specification.
187+
* @param contents A string containing a CSS or Sass file to scan.
188+
* @param prefix The prefix to start a valid segment.
189+
* @returns An iterable that yields each CSS url function value found.
190+
*/
191+
function* find(
192+
contents: string,
193+
prefix: string,
194+
): Iterable<{ start: number; end: number; specifier: string }> {
195+
let pos = 0;
196+
let width = 1;
197+
let current = -1;
198+
const next = () => {
199+
pos += width;
200+
current = contents.codePointAt(pos) ?? -1;
201+
width = current > 0xffff ? 2 : 1;
202+
203+
return current;
204+
};
205+
206+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
207+
while ((pos = contents.indexOf(prefix, pos)) !== -1) {
208+
// Set to position of the last character in prefix
209+
pos += prefix.length - 1;
210+
width = 1;
211+
212+
// Consume all leading whitespace
213+
while (isWhitespace(next())) {
214+
/* empty */
215+
}
216+
217+
// Initialize URL state
218+
const url = { start: pos, end: -1, specifier: '' };
219+
let complete = false;
220+
221+
// If " or ', then consume the value as a string
222+
if (current === 0x0022 || current === 0x0027) {
223+
const ending = current;
224+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token
225+
while (!complete) {
226+
switch (next()) {
227+
case -1: // EOF
228+
return;
229+
case 0x000a: // line feed
230+
case 0x000c: // form feed
231+
case 0x000d: // carriage return
232+
// Invalid
233+
complete = true;
234+
break;
235+
case 0x005c: // \ -- character escape
236+
// If not EOF or newline, add the character after the escape
237+
switch (next()) {
238+
case -1:
239+
return;
240+
case 0x000a: // line feed
241+
case 0x000c: // form feed
242+
case 0x000d: // carriage return
243+
// Skip when inside a string
244+
break;
245+
default:
246+
// TODO: Handle hex escape codes
247+
url.specifier += String.fromCodePoint(current);
248+
break;
249+
}
250+
break;
251+
case ending:
252+
// Full string position should include the quotes for replacement
253+
url.end = pos + 1;
254+
complete = true;
255+
yield url;
256+
break;
257+
default:
258+
url.specifier += String.fromCodePoint(current);
259+
break;
260+
}
261+
}
262+
263+
next();
264+
continue;
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)