-
Notifications
You must be signed in to change notification settings - Fork 4.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
4c627c4
commit 2c978a3
Showing
5 changed files
with
354 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import { expect, test } from 'vitest' | ||
import { rewriteUrls } from './urls' | ||
|
||
const css = String.raw | ||
|
||
test('URLs can be rewritten', async () => { | ||
let root = '/root' | ||
|
||
let result = await rewriteUrls({ | ||
root, | ||
base: '/root/foo/bar', | ||
// prettier-ignore | ||
css: css` | ||
.foo { | ||
/* Relative URLs: replaced */ | ||
background: url(./image.jpg); | ||
background: url(../image.jpg); | ||
background: url('./image.jpg'); | ||
background: url("./image.jpg"); | ||
/* External URL: ignored */ | ||
background: url(http://example.com/image.jpg); | ||
background: url('http://example.com/image.jpg'); | ||
background: url("http://example.com/image.jpg"); | ||
/* Data URI: ignored */ | ||
/* background: url(data:image/png;base64,abc==); */ | ||
background: url('data:image/png;base64,abc=='); | ||
background: url("data:image/png;base64,abc=="); | ||
/* Function calls: ignored */ | ||
background: url(var(--foo)); | ||
background: url(var(--foo, './image.jpg')); | ||
background: url(var(--foo, "./image.jpg")); | ||
/* Fragments: ignored */ | ||
background: url(#dont-touch-this); | ||
/* Image Sets - Raw URL: replaced */ | ||
background: image-set( | ||
image1.jpg 1x, | ||
image2.jpg 2x | ||
); | ||
background: image-set( | ||
'image1.jpg' 1x, | ||
'image2.jpg' 2x | ||
); | ||
background: image-set( | ||
"image1.jpg" 1x, | ||
"image2.jpg" 2x | ||
); | ||
/* Image Sets - Relative URLs: replaced */ | ||
background: image-set( | ||
url('image1.jpg') 1x, | ||
url('image2.jpg') 2x | ||
); | ||
background: image-set( | ||
url("image1.jpg") 1x, | ||
url("image2.jpg") 2x | ||
); | ||
background: image-set( | ||
url('image1.avif') type('image/avif'), | ||
url('image2.jpg') type('image/jpeg') | ||
); | ||
background: image-set( | ||
url("image1.avif") type('image/avif'), | ||
url("image2.jpg") type('image/jpeg') | ||
); | ||
/* Image Sets - Function calls: ignored */ | ||
background: image-set( | ||
linear-gradient(blue, white) 1x, | ||
linear-gradient(blue, green) 2x | ||
); | ||
/* Image Sets - Mixed: replaced */ | ||
background: image-set( | ||
linear-gradient(blue, white) 1x, | ||
url("image2.jpg") 2x | ||
); | ||
} | ||
/* Fonts - Multiple URLs: replaced */ | ||
@font-face { | ||
font-family: "Newman"; | ||
src: | ||
local("Newman"), | ||
url("newman-COLRv1.otf") format("opentype") tech(color-COLRv1), | ||
url("newman-outline.otf") format("opentype"), | ||
url("newman-outline.woff") format("woff"); | ||
} | ||
`, | ||
}) | ||
|
||
expect(result).toMatchInlineSnapshot(` | ||
".foo { | ||
background: url(./foo/bar/image.jpg); | ||
background: url(./foo/image.jpg); | ||
background: url('./foo/bar/image.jpg'); | ||
background: url("./foo/bar/image.jpg"); | ||
background: url(http://example.com/image.jpg); | ||
background: url('http://example.com/image.jpg'); | ||
background: url("http://example.com/image.jpg"); | ||
background: url('data:image/png;base64,abc=='); | ||
background: url("data:image/png;base64,abc=="); | ||
background: url(var(--foo)); | ||
background: url(var(--foo, './image.jpg')); | ||
background: url(var(--foo, "./image.jpg")); | ||
background: url(#dont-touch-this); | ||
background: image-set(url(./foo/bar/image1.jpg) 1x, url(./foo/bar/image2.jpg) 2x); | ||
background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x); | ||
background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x); | ||
background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x); | ||
background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x); | ||
background: image-set(url('./foo/bar/image1.avif') type('image/avif'), url('./foo/bar/image2.jpg') type('image/jpeg')); | ||
background: image-set(url("./foo/bar/image1.avif") type('image/avif'), url("./foo/bar/image2.jpg") type('image/jpeg')); | ||
background: image-set(linear-gradient(blue, white) 1x, linear-gradient(blue, green) 2x); | ||
background: image-set(linear-gradient(blue, white) 1x, url("./foo/bar/image2.jpg") 2x); | ||
} | ||
@font-face { | ||
font-family: "Newman"; | ||
src: local("Newman"), url("./foo/bar/newman-COLRv1.otf") format("opentype") tech(color-COLRv1), url("./foo/bar/newman-outline.otf") format("opentype"), url("./foo/bar/newman-outline.woff") format("woff"); | ||
} | ||
" | ||
`) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
// Inlined version of code from Vite <https://github.com/vitejs/vite> | ||
// Copyright (c) 2019-present, VoidZero Inc. and Vite contributors | ||
// Released under the MIT License. | ||
// | ||
// Minor modifications have been made to work with the Tailwind CSS codebase | ||
|
||
import * as path from 'node:path' | ||
import { toCss, walk } from '../../tailwindcss/src/ast' | ||
import { parse } from '../../tailwindcss/src/css-parser' | ||
import { normalizePath } from './normalize-path' | ||
|
||
const cssUrlRE = | ||
/(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/ | ||
const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/ | ||
const cssNotProcessedRE = /(?:gradient|element|cross-fade|image)\(/ | ||
|
||
const dataUrlRE = /^\s*data:/i | ||
const externalRE = /^([a-z]+:)?\/\// | ||
const functionCallRE = /^[A-Z_][.\w-]*\(/i | ||
|
||
const imageCandidateRE = | ||
/(?:^|\s)(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>\w[^,]+))?(?:,|$)/g | ||
const nonEscapedDoubleQuoteRE = /(?<!\\)"/g | ||
const escapedSpaceCharactersRE = /(?: |\\t|\\n|\\f|\\r)+/g | ||
|
||
const isDataUrl = (url: string): boolean => dataUrlRE.test(url) | ||
const isExternalUrl = (url: string): boolean => externalRE.test(url) | ||
|
||
type CssUrlReplacer = (url: string, importer?: string) => string | Promise<string> | ||
|
||
interface ImageCandidate { | ||
url: string | ||
descriptor: string | ||
} | ||
|
||
export async function rewriteUrls({ | ||
css, | ||
base, | ||
root, | ||
}: { | ||
css: string | ||
base: string | ||
root: string | ||
}) { | ||
if (!css.includes('url(') && !css.includes('image-set(')) { | ||
return css | ||
} | ||
|
||
let ast = parse(css) | ||
|
||
let promises: Promise<void>[] = [] | ||
|
||
function replacerForDeclaration(url: string) { | ||
let absoluteUrl = path.posix.join(normalizePath(base), url) | ||
let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl) | ||
|
||
// If the path points to a file in the same directory, `path.relative` will | ||
// remove the leading `./` and we need to add it back in order to still | ||
// consider the path relative | ||
if (!relativeUrl.startsWith('.')) { | ||
relativeUrl = './' + relativeUrl | ||
} | ||
|
||
return relativeUrl | ||
} | ||
|
||
walk(ast, (node) => { | ||
if (node.kind !== 'declaration') return | ||
if (!node.value) return | ||
|
||
let isCssUrl = cssUrlRE.test(node.value) | ||
let isCssImageSet = cssImageSetRE.test(node.value) | ||
|
||
if (isCssUrl || isCssImageSet) { | ||
let rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls | ||
|
||
promises.push( | ||
rewriterToUse(node.value, replacerForDeclaration).then((url) => { | ||
node.value = url | ||
}), | ||
) | ||
} | ||
}) | ||
|
||
if (promises.length) { | ||
await Promise.all(promises) | ||
} | ||
|
||
return toCss(ast, { | ||
printUtilitiesNode: true, | ||
}) | ||
} | ||
|
||
function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise<string> { | ||
return asyncReplace(css, cssUrlRE, async (match) => { | ||
const [matched, rawUrl] = match | ||
return await doUrlReplace(rawUrl.trim(), matched, replacer) | ||
}) | ||
} | ||
|
||
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) | ||
} | ||
if (!cssNotProcessedRE.test(url)) { | ||
return await doUrlReplace(url, url, replacer) | ||
} | ||
return url | ||
}) | ||
return url | ||
}) | ||
} | ||
|
||
async function doUrlReplace( | ||
rawUrl: string, | ||
matched: string, | ||
replacer: CssUrlReplacer, | ||
funcName: string = 'url', | ||
) { | ||
let wrap = '' | ||
const first = rawUrl[0] | ||
if (first === `"` || first === `'`) { | ||
wrap = first | ||
rawUrl = rawUrl.slice(1, -1) | ||
} | ||
|
||
if (skipUrlReplacer(rawUrl)) { | ||
return matched | ||
} | ||
|
||
let newUrl = await replacer(rawUrl) | ||
// The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement | ||
if (wrap === '' && newUrl !== encodeURI(newUrl)) { | ||
wrap = '"' | ||
} | ||
// If wrapping in single quotes and newUrl also contains single quotes, switch to double quotes. | ||
// Give preference to double quotes since SVG inlining converts double quotes to single quotes. | ||
if (wrap === "'" && newUrl.includes("'")) { | ||
wrap = '"' | ||
} | ||
// Escape double quotes if they exist (they also tend to be rarer than single quotes) | ||
if (wrap === '"' && newUrl.includes('"')) { | ||
newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"') | ||
} | ||
return `${funcName}(${wrap}${newUrl}${wrap})` | ||
} | ||
|
||
function skipUrlReplacer(rawUrl: string) { | ||
return ( | ||
isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl[0] === '#' || functionCallRE.test(rawUrl) | ||
) | ||
} | ||
|
||
function processSrcSet( | ||
srcs: string, | ||
replacer: (arg: ImageCandidate) => Promise<string>, | ||
): Promise<string> { | ||
return Promise.all( | ||
parseSrcset(srcs).map(async ({ url, descriptor }) => ({ | ||
url: await replacer({ url, descriptor }), | ||
descriptor, | ||
})), | ||
).then(joinSrcset) | ||
} | ||
|
||
function parseSrcset(string: string): ImageCandidate[] { | ||
const matches = string | ||
.trim() | ||
.replace(escapedSpaceCharactersRE, ' ') | ||
.replace(/\r?\n/, '') | ||
.replace(/,\s+/, ', ') | ||
.replaceAll(/\s+/g, ' ') | ||
.matchAll(imageCandidateRE) | ||
return Array.from(matches, ({ groups }) => ({ | ||
url: groups?.url?.trim() ?? '', | ||
descriptor: groups?.descriptor?.trim() ?? '', | ||
})).filter(({ url }) => !!url) | ||
} | ||
|
||
function joinSrcset(ret: ImageCandidate[]) { | ||
return ret.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : '')).join(', ') | ||
} | ||
|
||
async function asyncReplace( | ||
input: string, | ||
re: RegExp, | ||
replacer: (match: RegExpExecArray) => string | Promise<string>, | ||
): Promise<string> { | ||
let match: RegExpExecArray | null | ||
let remaining = input | ||
let rewritten = '' | ||
while ((match = re.exec(remaining))) { | ||
rewritten += remaining.slice(0, match.index) | ||
rewritten += await replacer(match) | ||
remaining = remaining.slice(match.index + match[0].length) | ||
} | ||
rewritten += remaining | ||
return rewritten | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters