Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/vite/src/node/__tests__/plugins/css.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
injectInlinedCSS,
preprocessCSS,
resolveLibCssFilename,
rewriteCssImageSet,
} from '../../plugins/css'
import { normalizePath } from '../../utils'

Expand Down Expand Up @@ -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({
Expand Down
23 changes: 23 additions & 0 deletions packages/vite/src/node/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
78 changes: 60 additions & 18 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<string> {
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 (
Expand Down
141 changes: 130 additions & 11 deletions packages/vite/src/node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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|(?<=,))(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>[\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(
Expand Down
Loading