diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 1a228d1723ecca..5ba7c8b055e457 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -1901,10 +1901,15 @@ type CssUrlResolver = ( ) => | [url: string, id: string | undefined] | Promise<[url: string, id: string | undefined]> +/** + * replace URL references + * + * When returning `false`, it keeps the content as-is + */ type CssUrlReplacer = ( - url: string, - importer?: string, -) => string | Promise + unquotedUrl: string, + rawUrl: string, +) => string | false | Promise // https://drafts.csswg.org/css-syntax-3/#identifier-code-point export const cssUrlRE = /(? { + const isQuoted = rawUrl[0] === '"' || rawUrl[0] === "'" + // matches `url($foo)` + if (!isQuoted && unquotedUrl[0] === '$') { + return true + } + // matches `url(#{foo})` and `url('#{foo}')` + return unquotedUrl.startsWith('#{') + } + const internalLoad = async (file: string, rootFile: string) => { const result = await rebaseUrls( environment, file, rootFile, alias, - '$', resolvers.sass, + skipRebaseUrls, ) if (result.contents) { return result.contents @@ -2529,6 +2554,16 @@ const makeModernCompilerScssWorker = ( sassOptions.url = pathToFileURL(options.filename) sassOptions.sourceMap = options.enableSourcemap + const skipRebaseUrls = (unquotedUrl: string, rawUrl: string) => { + const isQuoted = rawUrl[0] === '"' || rawUrl[0] === "'" + // matches `url($foo)` + if (!isQuoted && unquotedUrl[0] === '$') { + return true + } + // matches `url(#{foo})` and `url('#{foo}')` + return unquotedUrl.startsWith('#{') + } + const internalImporter: Sass.Importer<'async'> = { async canonicalize(url, context) { const importer = context.containingUrl @@ -2562,8 +2597,8 @@ const makeModernCompilerScssWorker = ( fileURLToPath(canonicalUrl), options.filename, alias, - '$', resolvers.sass, + skipRebaseUrls, ) const contents = result.contents ?? (await fsp.readFile(result.file, 'utf-8')) @@ -2709,8 +2744,8 @@ async function rebaseUrls( file: string, rootFile: string, alias: Alias[], - variablePrefix: string, resolver: ResolveIdFn, + ignoreUrl?: (unquotedUrl: string, rawUrl: string) => boolean, ): Promise<{ file: string; contents?: string }> { file = path.resolve(file) // ensure os-specific flashes // in the same dir, no need to rebase @@ -2733,20 +2768,22 @@ async function rebaseUrls( } let rebased - const rebaseFn = async (url: string) => { - if (url[0] === '/') return url - // ignore url's starting with variable - if (url.startsWith(variablePrefix)) return url + const rebaseFn = async (unquotedUrl: string, rawUrl: string) => { + if (ignoreUrl?.(unquotedUrl, rawUrl)) return false + if (unquotedUrl[0] === '/') return unquotedUrl // match alias, no need to rewrite for (const { find } of alias) { const matches = - typeof find === 'string' ? url.startsWith(find) : find.test(url) + typeof find === 'string' + ? unquotedUrl.startsWith(find) + : find.test(unquotedUrl) if (matches) { - return url + return unquotedUrl } } const absolute = - (await resolver(environment, url, file)) || path.resolve(fileDir, url) + (await resolver(environment, unquotedUrl, file)) || + path.resolve(fileDir, unquotedUrl) const relative = path.relative(rootDir, absolute) return normalizePath(relative) } @@ -2778,6 +2815,13 @@ const makeLessWorker = ( alias: Alias[], maxWorkers: number | undefined, ) => { + const skipRebaseUrls = (unquotedUrl: string, _rawUrl: string) => { + // matches both + // - interpolation: `url('@{foo}')` + // - variable: `url(@foo)` + return unquotedUrl[0] === '@' + } + const viteLessResolve = async ( filename: string, dir: string, @@ -2802,8 +2846,8 @@ const makeLessWorker = ( resolved, rootFile, alias, - '@', resolvers.less, + skipRebaseUrls, ) return { resolved, diff --git a/playground/css/__tests__/sass-tests.ts b/playground/css/__tests__/sass-tests.ts index c9a3c9a5d42d07..1c0dcf7b90b17f 100644 --- a/playground/css/__tests__/sass-tests.ts +++ b/playground/css/__tests__/sass-tests.ts @@ -15,6 +15,15 @@ export const sassTest = () => { const atImport = await page.$('.sass-at-import') const atImportAlias = await page.$('.sass-at-import-alias') const urlStartsWithVariable = await page.$('.sass-url-starts-with-variable') + const urlStartsWithVariableInterpolation1 = await page.$( + '.sass-url-starts-with-interpolation1', + ) + const urlStartsWithVariableInterpolation2 = await page.$( + '.sass-url-starts-with-interpolation2', + ) + const urlStartsWithVariableConcat = await page.$( + '.sass-url-starts-with-variable-concat', + ) const urlStartsWithFunctionCall = await page.$( '.sass-url-starts-with-function-call', ) @@ -32,6 +41,15 @@ export const sassTest = () => { expect(await getBg(urlStartsWithVariable)).toMatch( isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, ) + expect(await getBg(urlStartsWithVariableInterpolation1)).toMatch( + isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, + ) + expect(await getBg(urlStartsWithVariableInterpolation2)).toMatch( + isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, + ) + expect(await getBg(urlStartsWithVariableConcat)).toMatch( + isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, + ) expect(await getBg(urlStartsWithFunctionCall)).toMatch( isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, ) diff --git a/playground/css/__tests__/tests.ts b/playground/css/__tests__/tests.ts index 086180b9a64b44..bab808c06f378d 100644 --- a/playground/css/__tests__/tests.ts +++ b/playground/css/__tests__/tests.ts @@ -98,6 +98,9 @@ export const tests = (isLightningCSS: boolean) => { const atImportAlias = await page.$('.less-at-import-alias') const atImportUrlOmmer = await page.$('.less-at-import-url-ommer') const urlStartsWithVariable = await page.$('.less-url-starts-with-variable') + const urlStartsWithInterpolation = await page.$( + '.less-url-starts-with-interpolation', + ) expect(await getColor(imported)).toBe('blue') expect(await getColor(atImport)).toBe('darkslateblue') @@ -112,6 +115,9 @@ export const tests = (isLightningCSS: boolean) => { expect(await getBg(urlStartsWithVariable)).toMatch( isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, ) + expect(await getBg(urlStartsWithInterpolation)).toMatch( + isBuild ? /ok-[-\w]+\.png/ : `${viteTestUrl}/ok.png`, + ) if (isBuild) return diff --git a/playground/css/index.html b/playground/css/index.html index 17e680d349a3dc..cc30e89693d920 100644 --- a/playground/css/index.html +++ b/playground/css/index.html @@ -35,6 +35,15 @@

CSS

@import from SASS _partial: This should be orchid

url starts with variable

+

+ url starts with interpolation 1 +

+

+ url starts with interpolation 2 +

+

+ url starts with variable and contains concat +

url starts with function call

@@ -62,6 +71,9 @@

CSS

@import url() from Less: This should be darkorange

url starts with variable

+

+ url starts with interpolation +

tests Less's `data-uri()` function with relative image paths diff --git a/playground/css/nested/_index.scss b/playground/css/nested/_index.scss index c0767a3f4431c6..193828696a1004 100644 --- a/playground/css/nested/_index.scss +++ b/playground/css/nested/_index.scss @@ -20,6 +20,23 @@ $var: '/ok.png'; background-position: center; } +.sass-url-starts-with-interpolation1 { + background: url(#{$var}); + background-position: center; +} + +.sass-url-starts-with-interpolation2 { + background: url('#{$var}'); + background-position: center; +} + +$var-c1: '/ok'; +$var-c2: '.png'; +.sass-url-starts-with-variable-concat { + background: url($var-c1 + $var-c2); + background-position: center; +} + $var2: '/OK.PNG'; .sass-url-starts-with-function-call { background: url(string.to-lower-case($var2)); diff --git a/playground/css/nested/nested.less b/playground/css/nested/nested.less index 25aa1944d32c14..ecd1b9bff4203a 100644 --- a/playground/css/nested/nested.less +++ b/playground/css/nested/nested.less @@ -10,6 +10,11 @@ @var: '/ok.png'; .less-url-starts-with-variable { + background: url(@var); + background-position: center; +} + +.less-url-starts-with-interpolation { background: url('@{var}'); background-position: center; }