From 9f4b8ae19fb693ffb161c894601d25fc4a01214e Mon Sep 17 00:00:00 2001 From: Vinicius <33708588+Comicao1@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:09:47 -0300 Subject: [PATCH] fix(css): don't truncate image-set() candidates with nested parentheses --- .../src/node/__tests__/plugins/css.spec.ts | 33 ++++ .../vite/src/node/__tests__/utils.spec.ts | 23 +++ packages/vite/src/node/plugins/css.ts | 78 +++++++--- packages/vite/src/node/utils.ts | 141 ++++++++++++++++-- 4 files changed, 246 insertions(+), 29 deletions(-) diff --git a/packages/vite/src/node/__tests__/plugins/css.spec.ts b/packages/vite/src/node/__tests__/plugins/css.spec.ts index 242ddab87362ee..289b27cb8a7aed 100644 --- a/packages/vite/src/node/__tests__/plugins/css.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/css.spec.ts @@ -15,6 +15,7 @@ import { injectInlinedCSS, preprocessCSS, resolveLibCssFilename, + rewriteCssImageSet, } from '../../plugins/css' import { normalizePath } from '../../utils' @@ -68,6 +69,38 @@ describe('search css url function', () => { }) }) +describe('rewriteCssImageSet', () => { + const replacer = async (url: string) => `/rewritten/${url}` + + test('rewrites urls in image-set candidates', async () => { + const css = 'background: image-set(url("a.png") 1x, "b.png" 2x)' + expect(await rewriteCssImageSet(css, replacer)).toBe( + 'background: image-set(url("/rewritten/a.png") 1x, url("/rewritten/b.png") 2x)', + ) + }) + + test('does not truncate candidates containing nested functions', async () => { + const css = + 'background: image-set(url("a.png") 1x, linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)) 2x)' + expect(await rewriteCssImageSet(css, replacer)).toBe( + 'background: image-set(url("/rewritten/a.png") 1x, linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)) 2x)', + ) + }) + + test('handles -webkit-image-set and multiple occurrences', async () => { + const css = + 'background: -webkit-image-set("a.png" 1x); background: image-set("b.png" 2x)' + expect(await rewriteCssImageSet(css, replacer)).toBe( + 'background: -webkit-image-set(url("/rewritten/a.png") 1x); background: image-set(url("/rewritten/b.png") 2x)', + ) + }) + + test('keeps image-set with unbalanced parentheses as-is', async () => { + const css = 'background: image-set(url("a.png") 1x' + expect(await rewriteCssImageSet(css, replacer)).toBe(css) + }) +}) + describe('css modules', () => { test('css module compose/from path resolutions', async () => { const { transform } = await createCssPluginTransform({ diff --git a/packages/vite/src/node/__tests__/utils.spec.ts b/packages/vite/src/node/__tests__/utils.spec.ts index a5abea866b0c7f..14b87203d44f9a 100644 --- a/packages/vite/src/node/__tests__/utils.spec.ts +++ b/packages/vite/src/node/__tests__/utils.spec.ts @@ -768,6 +768,29 @@ describe('processSrcSetSync', () => { expect(processSrcSetSync(source, ({ url }) => url)).toBe(result) }) + test('should parse image-set-options with nested functions', async () => { + const source = `url("a.png") 1x, + linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)) 2x` + const result = + 'url("a.png") 1x, linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)) 2x' + expect(processSrcSetSync(source, ({ url }) => url)).toBe(result) + }) + + test('should capture image candidates with nested functions whole', async () => { + const source = `url("a.png") 1x, + linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)) 2x` + const expected = [ + 'url("a.png")', + 'linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1))', + ] + const result: string[] = [] + processSrcSetSync(source, ({ url }) => { + result.push(url) + return url + }) + expect(result).toEqual(expected) + }) + test('should parse image-set-options with resolution and type specified', async () => { const source = `url("picture.png")\t1x\t type("image/jpeg"), url("picture.png")\t type("image/jpeg")\t2x` const result = diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 6830215563f5fa..a51e6f5e9bdd5c 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -1600,7 +1600,7 @@ async function compilePostCSS( // although at serve time it can work without processing, we do need to // crawl them in order to register watch dependencies. const needInlineImport = code.includes('@import') - const hasUrl = cssUrlRE.test(code) || cssImageSetRE.test(code) + const hasUrl = cssUrlRE.test(code) || code.includes('image-set(') const postcssConfig = await resolvePostcssConfig( environment.getTopLevelConfig(), ) @@ -2089,9 +2089,6 @@ export const cssDataUriRE: RegExp = /(?<=^|[^\w\-\u0080-\uffff])data-uri\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/ export const importCssRE: RegExp = /@import\s+(?:url\()?('[^']+\.css'|"[^"]+\.css"|[^'"\s)]+\.css)/ -// Assuming a function name won't be longer than 256 chars -// eslint-disable-next-line regexp/no-unused-capturing-group -- doesn't detect asyncReplace usage -const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/ const UrlRewritePostcssPlugin: PostCSS.PluginCreator<{ resolver: CssUrlResolver @@ -2117,7 +2114,7 @@ const UrlRewritePostcssPlugin: PostCSS.PluginCreator<{ ) } const isCssUrl = cssUrlRE.test(declaration.value) - const isCssImageSet = cssImageSetRE.test(declaration.value) + const isCssImageSet = declaration.value.includes('image-set(') if (isCssUrl || isCssImageSet) { const replacerForDeclaration = async (rawUrl: string) => { const [newUrl, resolvedId] = await opts.resolver(rawUrl, importer) @@ -2191,24 +2188,69 @@ function rewriteImportCss( // https://drafts.csswg.org/css-images-4/#cross-fade-function const cssNotProcessedRE = /(?:gradient|element|cross-fade|image)\(/ -async function rewriteCssImageSet( +// `image-set()` contents can't be extracted with a regex because candidates +// can be nested functions with their own parentheses, e.g. a +// `linear-gradient()` containing `rgba()`. Scan for the parenthesis that +// balances the opening one instead. This also matches `-webkit-image-set()`, +// whose name ends with the same suffix. +export async function rewriteCssImageSet( css: string, replacer: CssUrlReplacer, ): Promise { - return await asyncReplace(css, cssImageSetRE, async (match) => { - const [, rawUrl] = match - const url = await processSrcSet(rawUrl, async ({ url }) => { - // the url maybe url(...) - if (cssUrlRE.test(url)) { - return await rewriteCssUrls(url, replacer) + const functionName = 'image-set(' + let rewritten = '' + let remaining = css + let startIndex: number + while ((startIndex = remaining.indexOf(functionName)) !== -1) { + const contentsStart = startIndex + functionName.length + const contentsEnd = findClosingParenIndex(remaining, contentsStart) + if (contentsEnd === -1) { + break + } + const processed = await processSrcSet( + remaining.slice(contentsStart, contentsEnd), + async ({ url }) => { + // the url maybe url(...) + if (cssUrlRE.test(url)) { + return await rewriteCssUrls(url, replacer) + } + if (!cssNotProcessedRE.test(url)) { + return await doUrlReplace(url, url, replacer) + } + return url + }, + ) + rewritten += remaining.slice(0, contentsStart) + processed + remaining = remaining.slice(contentsEnd) + } + return rewritten + remaining +} + +function findClosingParenIndex(input: string, fromIndex: number): number { + let depth = 0 + let quote: string | undefined + for (let i = fromIndex; i < input.length; i++) { + const char = input[i] + if (char === '\\') { + // skip escaped characters, e.g. `\"` in a quoted string or `\)` in an + // unquoted url + i++ + } else if (quote !== undefined) { + if (char === quote) { + quote = undefined } - if (!cssNotProcessedRE.test(url)) { - return await doUrlReplace(url, url, replacer) + } else if (char === '"' || char === "'") { + quote = char + } else if (char === '(') { + depth++ + } else if (char === ')') { + if (depth === 0) { + return i } - return url - }) - return url - }) + depth-- + } + } + return -1 } function skipUrlReplacer(unquotedUrl: string) { return ( diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index cc064f8f655561..7ce9edb2da61b1 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -812,33 +812,152 @@ function joinSrcset(ret: ImageCandidate[]) { } /** - This regex represents a loose rule of an “image candidate string” and "image set options". + Parses a list of “image candidate strings” (srcset / image-set options). @see https://html.spec.whatwg.org/multipage/images.html#srcset-attribute @see https://drafts.csswg.org/css-images-4/#image-set-notation - The Regex has named capturing groups `url` and `descriptor`. - The `url` group can be: + A candidate is a `url` followed by an optional `descriptor`. + The `url` can be: * any CSS function * CSS string (single or double-quoted) * URL string (unquoted) The `descriptor` is anything after the space and before the comma. + + Commas and spaces inside functions (e.g. a `linear-gradient()` containing + `rgba()`) or quoted strings don't act as separators, so candidates are + split with a parenthesis-aware scan instead of a regex. */ -const imageCandidateRegex = - /(?:^|\s|(?<=,))(?[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?[\w.][^,]+))?(?:,|$)/g const escapedSpaceCharacters = /(?: |\\t|\\n|\\f|\\r)+/g export function parseSrcset(string: string): ImageCandidate[] { - const matches = string + const input = string .trim() .replace(escapedSpaceCharacters, ' ') .replace(/,\s+/, ', ') .replaceAll(/\s+/g, ' ') - .matchAll(imageCandidateRegex) - return Array.from(matches, ({ groups }) => ({ - url: groups?.url?.trim() ?? '', - descriptor: groups?.descriptor?.trim() ?? '', - })).filter(({ url }) => !!url) + + const candidates: ImageCandidate[] = [] + let i = 0 + while (i < input.length) { + const char = input[i] + if (char === ' ' || char === ',') { + i++ + continue + } + + let url: string + if (char === '"' || char === "'") { + // CSS string (single or double-quoted) + const end = scanQuotedString(input, i) + url = input.slice(i, end) + i = end + } else if (/[\w-]/.test(char) && input.includes('(', i)) { + // maybe a CSS function, e.g. `url(...)` or `linear-gradient(...)` + const openParen = input.indexOf('(', i) + const beforeParen = input.slice(i, openParen) + if (/^[\w-]+$/.test(beforeParen)) { + const end = scanBalancedParens(input, openParen) + url = input.slice(i, end) + i = end + } else { + url = scanUnquotedUrl(input, i) + i += url.length + } + } else { + // URL string (unquoted) + url = scanUnquotedUrl(input, i) + i += url.length + } + + // The descriptor is anything after the space and before the comma + let descriptor = '' + i = skipSpaces(input, i) + if (i < input.length && input[i] !== ',') { + const end = findTopLevelComma(input, i) + descriptor = input.slice(i, end).trim() + i = end + } + + if (url) { + candidates.push({ url, descriptor }) + } + } + return candidates +} + +function skipSpaces(input: string, i: number): number { + while (input[i] === ' ') { + i++ + } + return i +} + +function scanQuotedString(input: string, start: number): number { + const quote = input[start] + for (let i = start + 1; i < input.length; i++) { + if (input[i] === '\\') { + i++ // skip escaped characters, e.g. `\"` + } else if (input[i] === quote) { + return i + 1 + } + } + return input.length +} + +function scanBalancedParens(input: string, openParen: number): number { + let depth = 0 + for (let i = openParen; i < input.length; i++) { + const char = input[i] + if (char === '\\') { + i++ // skip escaped characters, e.g. `\)` in an unquoted url + } else if (char === '"' || char === "'") { + i = scanQuotedString(input, i) - 1 + } else if (char === '(') { + depth++ + } else if (char === ')') { + depth-- + if (depth === 0) { + return i + 1 + } + } + } + return input.length +} + +// An unquoted url ends at whitespace. Commas inside it are kept (e.g. +// `https://example.com/dpr_1,f_auto`), except for trailing ones, which are +// candidate separators. +function scanUnquotedUrl(input: string, start: number): string { + let end = start + while (end < input.length && input[end] !== ' ') { + end++ + } + let url = input.slice(start, end) + while (url.endsWith(',')) { + url = url.slice(0, -1) + } + return url +} + +// Finds the next comma that is not inside parentheses or a quoted string +function findTopLevelComma(input: string, start: number): number { + let depth = 0 + for (let i = start; i < input.length; i++) { + const char = input[i] + if (char === '\\') { + i++ + } else if (char === '"' || char === "'") { + i = scanQuotedString(input, i) - 1 + } else if (char === '(') { + depth++ + } else if (char === ')') { + depth-- + } else if (char === ',' && depth === 0) { + return i + } + } + return input.length } export function processSrcSet(