Skip to content

Commit a33d0c7

Browse files
authored
fix(css): should not wrap with double quote when the url rebase feature bailed out (#20068)
1 parent d11ae6b commit a33d0c7

File tree

6 files changed

+127
-25
lines changed

6 files changed

+127
-25
lines changed

packages/vite/src/node/plugins/css.ts

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,10 +1901,15 @@ type CssUrlResolver = (
19011901
) =>
19021902
| [url: string, id: string | undefined]
19031903
| Promise<[url: string, id: string | undefined]>
1904+
/**
1905+
* replace URL references
1906+
*
1907+
* When returning `false`, it keeps the content as-is
1908+
*/
19041909
type CssUrlReplacer = (
1905-
url: string,
1906-
importer?: string,
1907-
) => string | Promise<string>
1910+
unquotedUrl: string,
1911+
rawUrl: string,
1912+
) => string | false | Promise<string | false>
19081913
// https://drafts.csswg.org/css-syntax-3/#identifier-code-point
19091914
export const cssUrlRE =
19101915
/(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/
@@ -2034,12 +2039,12 @@ async function rewriteCssImageSet(
20342039
return url
20352040
})
20362041
}
2037-
function skipUrlReplacer(rawUrl: string) {
2042+
function skipUrlReplacer(unquotedUrl: string) {
20382043
return (
2039-
isExternalUrl(rawUrl) ||
2040-
isDataUrl(rawUrl) ||
2041-
rawUrl[0] === '#' ||
2042-
functionCallRE.test(rawUrl)
2044+
isExternalUrl(unquotedUrl) ||
2045+
isDataUrl(unquotedUrl) ||
2046+
unquotedUrl[0] === '#' ||
2047+
functionCallRE.test(unquotedUrl)
20432048
)
20442049
}
20452050
async function doUrlReplace(
@@ -2050,16 +2055,20 @@ async function doUrlReplace(
20502055
) {
20512056
let wrap = ''
20522057
const first = rawUrl[0]
2058+
let unquotedUrl = rawUrl
20532059
if (first === `"` || first === `'`) {
20542060
wrap = first
2055-
rawUrl = rawUrl.slice(1, -1)
2061+
unquotedUrl = rawUrl.slice(1, -1)
2062+
}
2063+
if (skipUrlReplacer(unquotedUrl)) {
2064+
return matched
20562065
}
20572066

2058-
if (skipUrlReplacer(rawUrl)) {
2067+
let newUrl = await replacer(unquotedUrl, rawUrl)
2068+
if (newUrl === false) {
20592069
return matched
20602070
}
20612071

2062-
let newUrl = await replacer(rawUrl)
20632072
// The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement
20642073
if (wrap === '' && newUrl !== encodeURI(newUrl)) {
20652074
wrap = '"'
@@ -2083,16 +2092,22 @@ async function doImportCSSReplace(
20832092
) {
20842093
let wrap = ''
20852094
const first = rawUrl[0]
2095+
let unquotedUrl = rawUrl
20862096
if (first === `"` || first === `'`) {
20872097
wrap = first
2088-
rawUrl = rawUrl.slice(1, -1)
2098+
unquotedUrl = rawUrl.slice(1, -1)
2099+
}
2100+
if (skipUrlReplacer(unquotedUrl)) {
2101+
return matched
20892102
}
2090-
if (isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl[0] === '#') {
2103+
2104+
const newUrl = await replacer(unquotedUrl, rawUrl)
2105+
if (newUrl === false) {
20912106
return matched
20922107
}
20932108

20942109
const prefix = matched.includes('url(') ? 'url(' : ''
2095-
return `@import ${prefix}${wrap}${await replacer(rawUrl)}${wrap}`
2110+
return `@import ${prefix}${wrap}${newUrl}${wrap}`
20962111
}
20972112

20982113
async function minifyCSS(
@@ -2392,14 +2407,24 @@ const makeModernScssWorker = (
23922407
return resolved ?? null
23932408
}
23942409

2410+
const skipRebaseUrls = (unquotedUrl: string, rawUrl: string) => {
2411+
const isQuoted = rawUrl[0] === '"' || rawUrl[0] === "'"
2412+
// matches `url($foo)`
2413+
if (!isQuoted && unquotedUrl[0] === '$') {
2414+
return true
2415+
}
2416+
// matches `url(#{foo})` and `url('#{foo}')`
2417+
return unquotedUrl.startsWith('#{')
2418+
}
2419+
23952420
const internalLoad = async (file: string, rootFile: string) => {
23962421
const result = await rebaseUrls(
23972422
environment,
23982423
file,
23992424
rootFile,
24002425
alias,
2401-
'$',
24022426
resolvers.sass,
2427+
skipRebaseUrls,
24032428
)
24042429
if (result.contents) {
24052430
return result.contents
@@ -2529,6 +2554,16 @@ const makeModernCompilerScssWorker = (
25292554
sassOptions.url = pathToFileURL(options.filename)
25302555
sassOptions.sourceMap = options.enableSourcemap
25312556

2557+
const skipRebaseUrls = (unquotedUrl: string, rawUrl: string) => {
2558+
const isQuoted = rawUrl[0] === '"' || rawUrl[0] === "'"
2559+
// matches `url($foo)`
2560+
if (!isQuoted && unquotedUrl[0] === '$') {
2561+
return true
2562+
}
2563+
// matches `url(#{foo})` and `url('#{foo}')`
2564+
return unquotedUrl.startsWith('#{')
2565+
}
2566+
25322567
const internalImporter: Sass.Importer<'async'> = {
25332568
async canonicalize(url, context) {
25342569
const importer = context.containingUrl
@@ -2562,8 +2597,8 @@ const makeModernCompilerScssWorker = (
25622597
fileURLToPath(canonicalUrl),
25632598
options.filename,
25642599
alias,
2565-
'$',
25662600
resolvers.sass,
2601+
skipRebaseUrls,
25672602
)
25682603
const contents =
25692604
result.contents ?? (await fsp.readFile(result.file, 'utf-8'))
@@ -2709,8 +2744,8 @@ async function rebaseUrls(
27092744
file: string,
27102745
rootFile: string,
27112746
alias: Alias[],
2712-
variablePrefix: string,
27132747
resolver: ResolveIdFn,
2748+
ignoreUrl?: (unquotedUrl: string, rawUrl: string) => boolean,
27142749
): Promise<{ file: string; contents?: string }> {
27152750
file = path.resolve(file) // ensure os-specific flashes
27162751
// in the same dir, no need to rebase
@@ -2733,20 +2768,22 @@ async function rebaseUrls(
27332768
}
27342769

27352770
let rebased
2736-
const rebaseFn = async (url: string) => {
2737-
if (url[0] === '/') return url
2738-
// ignore url's starting with variable
2739-
if (url.startsWith(variablePrefix)) return url
2771+
const rebaseFn = async (unquotedUrl: string, rawUrl: string) => {
2772+
if (ignoreUrl?.(unquotedUrl, rawUrl)) return false
2773+
if (unquotedUrl[0] === '/') return unquotedUrl
27402774
// match alias, no need to rewrite
27412775
for (const { find } of alias) {
27422776
const matches =
2743-
typeof find === 'string' ? url.startsWith(find) : find.test(url)
2777+
typeof find === 'string'
2778+
? unquotedUrl.startsWith(find)
2779+
: find.test(unquotedUrl)
27442780
if (matches) {
2745-
return url
2781+
return unquotedUrl
27462782
}
27472783
}
27482784
const absolute =
2749-
(await resolver(environment, url, file)) || path.resolve(fileDir, url)
2785+
(await resolver(environment, unquotedUrl, file)) ||
2786+
path.resolve(fileDir, unquotedUrl)
27502787
const relative = path.relative(rootDir, absolute)
27512788
return normalizePath(relative)
27522789
}
@@ -2778,6 +2815,13 @@ const makeLessWorker = (
27782815
alias: Alias[],
27792816
maxWorkers: number | undefined,
27802817
) => {
2818+
const skipRebaseUrls = (unquotedUrl: string, _rawUrl: string) => {
2819+
// matches both
2820+
// - interpolation: `url('@{foo}')`
2821+
// - variable: `url(@foo)`
2822+
return unquotedUrl[0] === '@'
2823+
}
2824+
27812825
const viteLessResolve = async (
27822826
filename: string,
27832827
dir: string,
@@ -2802,8 +2846,8 @@ const makeLessWorker = (
28022846
resolved,
28032847
rootFile,
28042848
alias,
2805-
'@',
28062849
resolvers.less,
2850+
skipRebaseUrls,
28072851
)
28082852
return {
28092853
resolved,

playground/css/__tests__/sass-tests.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ export const sassTest = () => {
1515
const atImport = await page.$('.sass-at-import')
1616
const atImportAlias = await page.$('.sass-at-import-alias')
1717
const urlStartsWithVariable = await page.$('.sass-url-starts-with-variable')
18+
const urlStartsWithVariableInterpolation1 = await page.$(
19+
'.sass-url-starts-with-interpolation1',
20+
)
21+
const urlStartsWithVariableInterpolation2 = await page.$(
22+
'.sass-url-starts-with-interpolation2',
23+
)
24+
const urlStartsWithVariableConcat = await page.$(
25+
'.sass-url-starts-with-variable-concat',
26+
)
1827
const urlStartsWithFunctionCall = await page.$(
1928
'.sass-url-starts-with-function-call',
2029
)
@@ -32,6 +41,15 @@ export const sassTest = () => {
3241
expect(await getBg(urlStartsWithVariable)).toMatch(
3342
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
3443
)
44+
expect(await getBg(urlStartsWithVariableInterpolation1)).toMatch(
45+
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
46+
)
47+
expect(await getBg(urlStartsWithVariableInterpolation2)).toMatch(
48+
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
49+
)
50+
expect(await getBg(urlStartsWithVariableConcat)).toMatch(
51+
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
52+
)
3553
expect(await getBg(urlStartsWithFunctionCall)).toMatch(
3654
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
3755
)

playground/css/__tests__/tests.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export const tests = (isLightningCSS: boolean) => {
9898
const atImportAlias = await page.$('.less-at-import-alias')
9999
const atImportUrlOmmer = await page.$('.less-at-import-url-ommer')
100100
const urlStartsWithVariable = await page.$('.less-url-starts-with-variable')
101+
const urlStartsWithInterpolation = await page.$(
102+
'.less-url-starts-with-interpolation',
103+
)
101104

102105
expect(await getColor(imported)).toBe('blue')
103106
expect(await getColor(atImport)).toBe('darkslateblue')
@@ -112,6 +115,9 @@ export const tests = (isLightningCSS: boolean) => {
112115
expect(await getBg(urlStartsWithVariable)).toMatch(
113116
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
114117
)
118+
expect(await getBg(urlStartsWithInterpolation)).toMatch(
119+
isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`,
120+
)
115121

116122
if (isBuild) return
117123

playground/css/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ <h1>CSS</h1>
3535
</p>
3636
<p class="sass-partial">@import from SASS _partial: This should be orchid</p>
3737
<p class="sass-url-starts-with-variable">url starts with variable</p>
38+
<p class="sass-url-starts-with-interpolation1">
39+
url starts with interpolation 1
40+
</p>
41+
<p class="sass-url-starts-with-interpolation2">
42+
url starts with interpolation 2
43+
</p>
44+
<p class="sass-url-starts-with-variable-concat">
45+
url starts with variable and contains concat
46+
</p>
3847
<p class="sass-url-starts-with-function-call">
3948
url starts with function call
4049
</p>
@@ -62,6 +71,9 @@ <h1>CSS</h1>
6271
@import url() from Less: This should be darkorange
6372
</p>
6473
<p class="less-url-starts-with-variable">url starts with variable</p>
74+
<p class="less-url-starts-with-interpolation">
75+
url starts with interpolation
76+
</p>
6577

6678
<div class="form-box-data-uri">
6779
tests Less's `data-uri()` function with relative image paths

playground/css/nested/_index.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ $var: '/ok.png';
2020
background-position: center;
2121
}
2222

23+
.sass-url-starts-with-interpolation1 {
24+
background: url(#{$var});
25+
background-position: center;
26+
}
27+
28+
.sass-url-starts-with-interpolation2 {
29+
background: url('#{$var}');
30+
background-position: center;
31+
}
32+
33+
$var-c1: '/ok';
34+
$var-c2: '.png';
35+
.sass-url-starts-with-variable-concat {
36+
background: url($var-c1 + $var-c2);
37+
background-position: center;
38+
}
39+
2340
$var2: '/OK.PNG';
2441
.sass-url-starts-with-function-call {
2542
background: url(string.to-lower-case($var2));

playground/css/nested/nested.less

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010

1111
@var: '/ok.png';
1212
.less-url-starts-with-variable {
13+
background: url(@var);
14+
background-position: center;
15+
}
16+
17+
.less-url-starts-with-interpolation {
1318
background: url('@{var}');
1419
background-position: center;
1520
}

0 commit comments

Comments
 (0)