Skip to content

Commit 84dc05d

Browse files
clydinangular-robot[bot]
authored andcommitted
fix(@angular-devkit/build-angular): use url function lexer to rebase Sass URLs
When rebasing URLs found within Sass files (sass/scss), the previous regular expression based searching has been replaced with a lexer that scans the Sass files for CSS url() functions and extracts URL values. This change allows for more accurate discovery of URLs as well as reducing the amount of content traversals per file. The lexer logic is based on CSS Syntax Module Level 3 (https://www.w3.org/TR/css-syntax-3/).
1 parent 5fee7c5 commit 84dc05d

File tree

1 file changed

+186
-36
lines changed

1 file changed

+186
-36
lines changed

packages/angular_devkit/build_angular/src/sass/rebasing-importer.ts

+186-36
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,6 @@ import { basename, dirname, extname, join, relative } from 'node:path';
1313
import { fileURLToPath, pathToFileURL } from 'node:url';
1414
import type { FileImporter, Importer, ImporterResult, Syntax } from 'sass';
1515

16-
/**
17-
* A Regular expression used to find all `url()` functions within a stylesheet.
18-
* From packages/angular_devkit/build_angular/src/webpack/plugins/postcss-cli-resources.ts
19-
*/
20-
const URL_REGEXP = /url(?:\(\s*(['"]?))(.*?)(?:\1\s*\))/g;
21-
2216
/**
2317
* A preprocessed cache entry for the files and directories within a previously searched
2418
* directory when performing Sass import resolution.
@@ -54,44 +48,42 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
5448

5549
load(canonicalUrl: URL): ImporterResult | null {
5650
const stylesheetPath = fileURLToPath(canonicalUrl);
51+
const stylesheetDirectory = dirname(stylesheetPath);
5752
let contents = readFileSync(stylesheetPath, 'utf-8');
5853

5954
// Rebase any URLs that are found
60-
if (contents.includes('url(')) {
61-
const stylesheetDirectory = dirname(stylesheetPath);
62-
63-
let match;
64-
URL_REGEXP.lastIndex = 0;
65-
let updatedContents;
66-
while ((match = URL_REGEXP.exec(contents))) {
67-
const originalUrl = match[2];
55+
let updatedContents;
56+
for (const { start, end, value } of findUrls(contents)) {
57+
// Skip if value is empty or a Sass variable
58+
if (value.length === 0 || value.startsWith('$')) {
59+
continue;
60+
}
6861

69-
// If root-relative, absolute or protocol relative url, leave as-is
70-
if (/^((?:\w+:)?\/\/|data:|chrome:|#|\/)/.test(originalUrl)) {
71-
continue;
72-
}
62+
// Skip if root-relative, absolute or protocol relative url
63+
if (/^((?:\w+:)?\/\/|data:|chrome:|#|\/)/.test(value)) {
64+
continue;
65+
}
7366

74-
const rebasedPath = relative(this.entryDirectory, join(stylesheetDirectory, originalUrl));
67+
const rebasedPath = relative(this.entryDirectory, join(stylesheetDirectory, value));
7568

76-
// Normalize path separators and escape characters
77-
// https://developer.mozilla.org/en-US/docs/Web/CSS/url#syntax
78-
const rebasedUrl = './' + rebasedPath.replace(/\\/g, '/').replace(/[()\s'"]/g, '\\$&');
69+
// Normalize path separators and escape characters
70+
// https://developer.mozilla.org/en-US/docs/Web/CSS/url#syntax
71+
const rebasedUrl = './' + rebasedPath.replace(/\\/g, '/').replace(/[()\s'"]/g, '\\$&');
7972

80-
updatedContents ??= new MagicString(contents);
81-
updatedContents.update(match.index, match.index + match[0].length, `url(${rebasedUrl})`);
82-
}
73+
updatedContents ??= new MagicString(contents);
74+
updatedContents.update(start, end, rebasedUrl);
75+
}
8376

84-
if (updatedContents) {
85-
contents = updatedContents.toString();
86-
if (this.rebaseSourceMaps) {
87-
// Generate an intermediate source map for the rebasing changes
88-
const map = updatedContents.generateMap({
89-
hires: true,
90-
includeContent: true,
91-
source: canonicalUrl.href,
92-
});
93-
this.rebaseSourceMaps.set(canonicalUrl.href, map as RawSourceMap);
94-
}
77+
if (updatedContents) {
78+
contents = updatedContents.toString();
79+
if (this.rebaseSourceMaps) {
80+
// Generate an intermediate source map for the rebasing changes
81+
const map = updatedContents.generateMap({
82+
hires: true,
83+
includeContent: true,
84+
source: canonicalUrl.href,
85+
});
86+
this.rebaseSourceMaps.set(canonicalUrl.href, map as RawSourceMap);
9587
}
9688
}
9789

@@ -116,6 +108,164 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
116108
}
117109
}
118110

111+
/**
112+
* Determines if a unicode code point is a CSS whitespace character.
113+
* @param code The unicode code point to test.
114+
* @returns true, if the code point is CSS whitespace; false, otherwise.
115+
*/
116+
function isWhitespace(code: number): boolean {
117+
// Based on https://www.w3.org/TR/css-syntax-3/#whitespace
118+
switch (code) {
119+
case 0x0009: // tab
120+
case 0x0020: // space
121+
case 0x000a: // line feed
122+
case 0x000c: // form feed
123+
case 0x000d: // carriage return
124+
return true;
125+
default:
126+
return false;
127+
}
128+
}
129+
130+
/**
131+
* Scans a CSS or Sass file and locates all valid url function values as defined by the CSS
132+
* syntax specification.
133+
* @param contents A string containing a CSS or Sass file to scan.
134+
* @returns An iterable that yields each CSS url function value found.
135+
*/
136+
function* findUrls(contents: string): Iterable<{ start: number; end: number; value: string }> {
137+
let pos = 0;
138+
let width = 1;
139+
let current = -1;
140+
const next = () => {
141+
pos += width;
142+
current = contents.codePointAt(pos) ?? -1;
143+
width = current > 0xffff ? 2 : 1;
144+
145+
return current;
146+
};
147+
148+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
149+
while ((pos = contents.indexOf('url(', pos)) !== -1) {
150+
// Set to position of the (
151+
pos += 3;
152+
width = 1;
153+
154+
// Consume all leading whitespace
155+
while (isWhitespace(next())) {
156+
/* empty */
157+
}
158+
159+
// Initialize URL state
160+
const url = { start: pos, end: -1, value: '' };
161+
let complete = false;
162+
163+
// If " or ', then consume the value as a string
164+
if (current === 0x0022 || current === 0x0027) {
165+
const ending = current;
166+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token
167+
while (!complete) {
168+
switch (next()) {
169+
case -1: // EOF
170+
return;
171+
case 0x000a: // line feed
172+
case 0x000c: // form feed
173+
case 0x000d: // carriage return
174+
// Invalid
175+
complete = true;
176+
break;
177+
case 0x005c: // \ -- character escape
178+
// If not EOF or newline, add the character after the escape
179+
switch (next()) {
180+
case -1:
181+
return;
182+
case 0x000a: // line feed
183+
case 0x000c: // form feed
184+
case 0x000d: // carriage return
185+
// Skip when inside a string
186+
break;
187+
default:
188+
// TODO: Handle hex escape codes
189+
url.value += String.fromCodePoint(current);
190+
break;
191+
}
192+
break;
193+
case ending:
194+
// Full string position should include the quotes for replacement
195+
url.end = pos + 1;
196+
complete = true;
197+
yield url;
198+
break;
199+
default:
200+
url.value += String.fromCodePoint(current);
201+
break;
202+
}
203+
}
204+
205+
next();
206+
continue;
207+
}
208+
209+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-url-token
210+
while (!complete) {
211+
switch (current) {
212+
case -1: // EOF
213+
return;
214+
case 0x0022: // "
215+
case 0x0027: // '
216+
case 0x0028: // (
217+
// Invalid
218+
complete = true;
219+
break;
220+
case 0x0029: // )
221+
// URL is valid and complete
222+
url.end = pos;
223+
complete = true;
224+
break;
225+
case 0x005c: // \ -- character escape
226+
// If not EOF or newline, add the character after the escape
227+
switch (next()) {
228+
case -1: // EOF
229+
return;
230+
case 0x000a: // line feed
231+
case 0x000c: // form feed
232+
case 0x000d: // carriage return
233+
// Invalid
234+
complete = true;
235+
break;
236+
default:
237+
// TODO: Handle hex escape codes
238+
url.value += String.fromCodePoint(current);
239+
break;
240+
}
241+
break;
242+
default:
243+
if (isWhitespace(current)) {
244+
while (isWhitespace(next())) {
245+
/* empty */
246+
}
247+
// Unescaped whitespace is only valid before the closing )
248+
if (current === 0x0029) {
249+
// URL is valid
250+
url.end = pos;
251+
}
252+
complete = true;
253+
} else {
254+
// Add the character to the url value
255+
url.value += String.fromCodePoint(current);
256+
}
257+
break;
258+
}
259+
next();
260+
}
261+
262+
// An end position indicates a URL was found
263+
if (url.end !== -1) {
264+
yield url;
265+
}
266+
}
267+
}
268+
119269
/**
120270
* Provides the Sass importer logic to resolve relative stylesheet imports via both import and use rules
121271
* and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that

0 commit comments

Comments
 (0)