Skip to content

Commit 0e6d4a5

Browse files
authoredOct 7, 2024··
fix(assets): make srcset parsing HTML spec compliant (#16323) (#18242)
1 parent 007773b commit 0e6d4a5

File tree

2 files changed

+95
-42
lines changed

2 files changed

+95
-42
lines changed
 

‎packages/vite/src/node/__tests__/utils.spec.ts

+64
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,70 @@ describe('processSrcSetSync', () => {
365365
processSrcSetSync('https://anydomain/image.jpg', ({ url }) => url),
366366
).toBe(source)
367367
})
368+
369+
test('should not break URLs with commas in srcSet', async () => {
370+
const source = `
371+
\thttps://example.com/dpr_1,f_auto,fl_progressive,q_auto,w_100/v1/img 1x,
372+
\thttps://example.com/dpr_2,f_auto,fl_progressive,q_auto,w_100/v1/img\t\t2x
373+
`
374+
const result =
375+
'https://example.com/dpr_1,f_auto,fl_progressive,q_auto,w_100/v1/img 1x, https://example.com/dpr_2,f_auto,fl_progressive,q_auto,w_100/v1/img 2x'
376+
expect(processSrcSetSync(source, ({ url }) => url)).toBe(result)
377+
})
378+
379+
test('should not break URLs with commas in image-set-options', async () => {
380+
const source = `url(https://example.com/dpr_1,f_auto,fl_progressive,q_auto,w_100/v1/img) 1x,
381+
url("https://example.com/dpr_2,f_auto,fl_progressive,q_auto,w_100/v1/img")\t\t2x
382+
`
383+
const result =
384+
'url(https://example.com/dpr_1,f_auto,fl_progressive,q_auto,w_100/v1/img) 1x, url("https://example.com/dpr_2,f_auto,fl_progressive,q_auto,w_100/v1/img") 2x'
385+
expect(processSrcSetSync(source, ({ url }) => url)).toBe(result)
386+
})
387+
388+
test('should parse image-set-options with resolution', async () => {
389+
const source = ` "foo.png" 1x,
390+
"foo-2x.png" 2x,
391+
"foo-print.png" 600dpi`
392+
const result = '"foo.png" 1x, "foo-2x.png" 2x, "foo-print.png" 600dpi'
393+
expect(processSrcSetSync(source, ({ url }) => url)).toBe(result)
394+
})
395+
396+
test('should parse image-set-options with type', async () => {
397+
const source = ` "foo.avif" type("image/avif"),
398+
"foo.jpg" type("image/jpeg") `
399+
const result = '"foo.avif" type("image/avif"), "foo.jpg" type("image/jpeg")'
400+
expect(processSrcSetSync(source, ({ url }) => url)).toBe(result)
401+
})
402+
403+
test('should parse image-set-options with linear-gradient', async () => {
404+
const source = `linear-gradient(cornflowerblue, white) 1x,
405+
url("detailed-gradient.png") 3x`
406+
const result =
407+
'linear-gradient(cornflowerblue, white) 1x, url("detailed-gradient.png") 3x'
408+
expect(processSrcSetSync(source, ({ url }) => url)).toBe(result)
409+
})
410+
411+
test('should parse image-set-options with resolution and type specified', async () => {
412+
const source = `url("picture.png")\t1x\t type("image/jpeg"), url("picture.png")\t type("image/jpeg")\t2x`
413+
const result =
414+
'url("picture.png") 1x type("image/jpeg"), url("picture.png") type("image/jpeg") 2x'
415+
expect(processSrcSetSync(source, ({ url }) => url)).toBe(result)
416+
})
417+
418+
test('should capture whole image set options', async () => {
419+
const source = `linear-gradient(cornflowerblue, white) 1x,
420+
url("detailed-gradient.png") 3x`
421+
const expected = [
422+
'linear-gradient(cornflowerblue, white)',
423+
'url("detailed-gradient.png")',
424+
]
425+
const result: string[] = []
426+
processSrcSetSync(source, ({ url }) => {
427+
result.push(url)
428+
return url
429+
})
430+
expect(result).toEqual(expected)
431+
})
368432
})
369433

370434
describe('flattenId', () => {

‎packages/vite/src/node/utils.ts

+31-42
Original file line numberDiff line numberDiff line change
@@ -716,36 +716,50 @@ interface ImageCandidate {
716716
url: string
717717
descriptor: string
718718
}
719-
const escapedSpaceCharacters = /(?: |\\t|\\n|\\f|\\r)+/g
720-
const imageSetUrlRE = /^(?:[\w-]+\(.*?\)|'.*?'|".*?"|\S*)/
719+
721720
function joinSrcset(ret: ImageCandidate[]) {
722721
return ret
723722
.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : ''))
724723
.join(', ')
725724
}
726725

727-
// NOTE: The returned `url` should perhaps be decoded so all handled URLs within Vite are consistently decoded.
728-
// However, this may also require a refactor for `cssReplacer` to accept decoded URLs instead.
729-
function splitSrcSetDescriptor(srcs: string): ImageCandidate[] {
730-
return splitSrcSet(srcs)
731-
.map((s) => {
732-
const src = s.replace(escapedSpaceCharacters, ' ').trim()
733-
const url = imageSetUrlRE.exec(src)?.[0] ?? ''
726+
/**
727+
This regex represents a loose rule of an “image candidate string” and "image set options".
734728
735-
return {
736-
url,
737-
descriptor: src.slice(url.length).trim(),
738-
}
739-
})
740-
.filter(({ url }) => !!url)
729+
@see https://html.spec.whatwg.org/multipage/images.html#srcset-attribute
730+
@see https://drafts.csswg.org/css-images-4/#image-set-notation
731+
732+
The Regex has named capturing groups `url` and `descriptor`.
733+
The `url` group can be:
734+
* any CSS function
735+
* CSS string (single or double-quoted)
736+
* URL string (unquoted)
737+
The `descriptor` is anything after the space and before the comma.
738+
*/
739+
const imageCandidateRegex =
740+
/(?:^|\s)(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>\w[^,]+))?(?:,|$)/g
741+
const escapedSpaceCharacters = /(?: |\\t|\\n|\\f|\\r)+/g
742+
743+
export function parseSrcset(string: string): ImageCandidate[] {
744+
const matches = string
745+
.trim()
746+
.replace(escapedSpaceCharacters, ' ')
747+
.replace(/\r?\n/, '')
748+
.replace(/,\s+/, ', ')
749+
.replaceAll(/\s+/g, ' ')
750+
.matchAll(imageCandidateRegex)
751+
return Array.from(matches, ({ groups }) => ({
752+
url: groups?.url?.trim() ?? '',
753+
descriptor: groups?.descriptor?.trim() ?? '',
754+
})).filter(({ url }) => !!url)
741755
}
742756

743757
export function processSrcSet(
744758
srcs: string,
745759
replacer: (arg: ImageCandidate) => Promise<string>,
746760
): Promise<string> {
747761
return Promise.all(
748-
splitSrcSetDescriptor(srcs).map(async ({ url, descriptor }) => ({
762+
parseSrcset(srcs).map(async ({ url, descriptor }) => ({
749763
url: await replacer({ url, descriptor }),
750764
descriptor,
751765
})),
@@ -757,38 +771,13 @@ export function processSrcSetSync(
757771
replacer: (arg: ImageCandidate) => string,
758772
): string {
759773
return joinSrcset(
760-
splitSrcSetDescriptor(srcs).map(({ url, descriptor }) => ({
774+
parseSrcset(srcs).map(({ url, descriptor }) => ({
761775
url: replacer({ url, descriptor }),
762776
descriptor,
763777
})),
764778
)
765779
}
766780

767-
const cleanSrcSetRE =
768-
/(?:url|image|gradient|cross-fade)\([^)]*\)|"([^"]|(?<=\\)")*"|'([^']|(?<=\\)')*'|data:\w+\/[\w.+-]+;base64,[\w+/=]+|\?\S+,/g
769-
function splitSrcSet(srcs: string) {
770-
const parts: string[] = []
771-
/**
772-
* There could be a ',' inside of:
773-
* - url(data:...)
774-
* - linear-gradient(...)
775-
* - "data:..."
776-
* - data:...
777-
* - query parameter ?...
778-
*/
779-
const cleanedSrcs = srcs.replace(cleanSrcSetRE, blankReplacer)
780-
let startIndex = 0
781-
let splitIndex: number
782-
do {
783-
splitIndex = cleanedSrcs.indexOf(',', startIndex)
784-
parts.push(
785-
srcs.slice(startIndex, splitIndex !== -1 ? splitIndex : undefined),
786-
)
787-
startIndex = splitIndex + 1
788-
} while (splitIndex !== -1)
789-
return parts
790-
}
791-
792781
const windowsDriveRE = /^[A-Z]:/
793782
const replaceWindowsDriveRE = /^([A-Z]):\//
794783
const linuxAbsolutePathRE = /^\/[^/]/

0 commit comments

Comments
 (0)
Please sign in to comment.