Skip to content

Commit fc52f68

Browse files
thecrypticacephilipp-spiess
authored andcommitted
Rewrite urls in CSS files in Vite
Co-authored-by: Philipp Spiess <[email protected]>
1 parent b1d41e7 commit fc52f68

File tree

5 files changed

+354
-3
lines changed

5 files changed

+354
-3
lines changed

packages/@tailwindcss-node/src/compile.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,37 @@ import {
99
compile as _compile,
1010
} from 'tailwindcss'
1111
import { getModuleDependencies } from './get-module-dependencies'
12+
import { rewriteUrls } from './urls'
1213

1314
export async function compile(
1415
css: string,
15-
{ base, onDependency }: { base: string; onDependency: (path: string) => void },
16+
{
17+
base,
18+
onDependency,
19+
shouldRewriteUrls,
20+
}: {
21+
base: string
22+
onDependency: (path: string) => void
23+
shouldRewriteUrls?: boolean
24+
},
1625
) {
1726
let compiler = await _compile(css, {
1827
base,
1928
async loadModule(id, base) {
2029
return loadModule(id, base, onDependency)
2130
},
2231
async loadStylesheet(id, base) {
23-
return loadStylesheet(id, base, onDependency)
32+
let sheet = await loadStylesheet(id, base, onDependency)
33+
34+
if (shouldRewriteUrls) {
35+
sheet.content = await rewriteUrls({
36+
css: sheet.content,
37+
root: base,
38+
base: sheet.base,
39+
})
40+
}
41+
42+
return sheet
2443
},
2544
})
2645

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { expect, test } from 'vitest'
2+
import { rewriteUrls } from './urls'
3+
4+
const css = String.raw
5+
6+
test('URLs can be rewritten', async () => {
7+
let root = '/root'
8+
9+
let result = await rewriteUrls({
10+
root,
11+
base: '/root/foo/bar',
12+
// prettier-ignore
13+
css: css`
14+
.foo {
15+
/* Relative URLs: replaced */
16+
background: url(./image.jpg);
17+
background: url(../image.jpg);
18+
background: url('./image.jpg');
19+
background: url("./image.jpg");
20+
21+
/* External URL: ignored */
22+
background: url(http://example.com/image.jpg);
23+
background: url('http://example.com/image.jpg');
24+
background: url("http://example.com/image.jpg");
25+
26+
/* Data URI: ignored */
27+
/* background: url(data:image/png;base64,abc==); */
28+
background: url('data:image/png;base64,abc==');
29+
background: url("data:image/png;base64,abc==");
30+
31+
/* Function calls: ignored */
32+
background: url(var(--foo));
33+
background: url(var(--foo, './image.jpg'));
34+
background: url(var(--foo, "./image.jpg"));
35+
36+
/* Fragments: ignored */
37+
background: url(#dont-touch-this);
38+
39+
/* Image Sets - Raw URL: replaced */
40+
background: image-set(
41+
image1.jpg 1x,
42+
image2.jpg 2x
43+
);
44+
background: image-set(
45+
'image1.jpg' 1x,
46+
'image2.jpg' 2x
47+
);
48+
background: image-set(
49+
"image1.jpg" 1x,
50+
"image2.jpg" 2x
51+
);
52+
53+
/* Image Sets - Relative URLs: replaced */
54+
background: image-set(
55+
url('image1.jpg') 1x,
56+
url('image2.jpg') 2x
57+
);
58+
background: image-set(
59+
url("image1.jpg") 1x,
60+
url("image2.jpg") 2x
61+
);
62+
background: image-set(
63+
url('image1.avif') type('image/avif'),
64+
url('image2.jpg') type('image/jpeg')
65+
);
66+
background: image-set(
67+
url("image1.avif") type('image/avif'),
68+
url("image2.jpg") type('image/jpeg')
69+
);
70+
71+
/* Image Sets - Function calls: ignored */
72+
background: image-set(
73+
linear-gradient(blue, white) 1x,
74+
linear-gradient(blue, green) 2x
75+
);
76+
77+
/* Image Sets - Mixed: replaced */
78+
background: image-set(
79+
linear-gradient(blue, white) 1x,
80+
url("image2.jpg") 2x
81+
);
82+
}
83+
84+
/* Fonts - Multiple URLs: replaced */
85+
@font-face {
86+
font-family: "Newman";
87+
src:
88+
local("Newman"),
89+
url("newman-COLRv1.otf") format("opentype") tech(color-COLRv1),
90+
url("newman-outline.otf") format("opentype"),
91+
url("newman-outline.woff") format("woff");
92+
}
93+
`,
94+
})
95+
96+
expect(result).toMatchInlineSnapshot(`
97+
".foo {
98+
background: url(./foo/bar/image.jpg);
99+
background: url(./foo/image.jpg);
100+
background: url('./foo/bar/image.jpg');
101+
background: url("./foo/bar/image.jpg");
102+
background: url(http://example.com/image.jpg);
103+
background: url('http://example.com/image.jpg');
104+
background: url("http://example.com/image.jpg");
105+
background: url('data:image/png;base64,abc==');
106+
background: url("data:image/png;base64,abc==");
107+
background: url(var(--foo));
108+
background: url(var(--foo, './image.jpg'));
109+
background: url(var(--foo, "./image.jpg"));
110+
background: url(#dont-touch-this);
111+
background: image-set(url(./foo/bar/image1.jpg) 1x, url(./foo/bar/image2.jpg) 2x);
112+
background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x);
113+
background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x);
114+
background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x);
115+
background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x);
116+
background: image-set(url('./foo/bar/image1.avif') type('image/avif'), url('./foo/bar/image2.jpg') type('image/jpeg'));
117+
background: image-set(url("./foo/bar/image1.avif") type('image/avif'), url("./foo/bar/image2.jpg") type('image/jpeg'));
118+
background: image-set(linear-gradient(blue, white) 1x, linear-gradient(blue, green) 2x);
119+
background: image-set(linear-gradient(blue, white) 1x, url("./foo/bar/image2.jpg") 2x);
120+
}
121+
@font-face {
122+
font-family: "Newman";
123+
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");
124+
}
125+
"
126+
`)
127+
})
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Inlined version of code from Vite <https://github.com/vitejs/vite>
2+
// Copyright (c) 2019-present, VoidZero Inc. and Vite contributors
3+
// Released under the MIT License.
4+
//
5+
// Minor modifications have been made to work with the Tailwind CSS codebase
6+
7+
import * as path from 'node:path'
8+
import { toCss, walk } from '../../tailwindcss/src/ast'
9+
import { parse } from '../../tailwindcss/src/css-parser'
10+
import { normalizePath } from './normalize-path'
11+
12+
const cssUrlRE =
13+
/(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/
14+
const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/
15+
const cssNotProcessedRE = /(?:gradient|element|cross-fade|image)\(/
16+
17+
const dataUrlRE = /^\s*data:/i
18+
const externalRE = /^([a-z]+:)?\/\//
19+
const functionCallRE = /^[A-Z_][.\w-]*\(/i
20+
21+
const imageCandidateRE =
22+
/(?:^|\s)(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>\w[^,]+))?(?:,|$)/g
23+
const nonEscapedDoubleQuoteRE = /(?<!\\)"/g
24+
const escapedSpaceCharactersRE = /(?: |\\t|\\n|\\f|\\r)+/g
25+
26+
const isDataUrl = (url: string): boolean => dataUrlRE.test(url)
27+
const isExternalUrl = (url: string): boolean => externalRE.test(url)
28+
29+
type CssUrlReplacer = (url: string, importer?: string) => string | Promise<string>
30+
31+
interface ImageCandidate {
32+
url: string
33+
descriptor: string
34+
}
35+
36+
export async function rewriteUrls({
37+
css,
38+
base,
39+
root,
40+
}: {
41+
css: string
42+
base: string
43+
root: string
44+
}) {
45+
if (!css.includes('url(') && !css.includes('image-set(')) {
46+
return css
47+
}
48+
49+
let ast = parse(css)
50+
51+
let promises: Promise<void>[] = []
52+
53+
function replacerForDeclaration(url: string) {
54+
let absoluteUrl = path.posix.join(normalizePath(base), url)
55+
let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl)
56+
57+
// If the path points to a file in the same directory, `path.relative` will
58+
// remove the leading `./` and we need to add it back in order to still
59+
// consider the path relative
60+
if (!relativeUrl.startsWith('.')) {
61+
relativeUrl = './' + relativeUrl
62+
}
63+
64+
return relativeUrl
65+
}
66+
67+
walk(ast, (node) => {
68+
if (node.kind !== 'declaration') return
69+
if (!node.value) return
70+
71+
let isCssUrl = cssUrlRE.test(node.value)
72+
let isCssImageSet = cssImageSetRE.test(node.value)
73+
74+
if (isCssUrl || isCssImageSet) {
75+
let rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls
76+
77+
promises.push(
78+
rewriterToUse(node.value, replacerForDeclaration).then((url) => {
79+
node.value = url
80+
}),
81+
)
82+
}
83+
})
84+
85+
if (promises.length) {
86+
await Promise.all(promises)
87+
}
88+
89+
return toCss(ast, {
90+
printUtilitiesNode: true,
91+
})
92+
}
93+
94+
function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise<string> {
95+
return asyncReplace(css, cssUrlRE, async (match) => {
96+
const [matched, rawUrl] = match
97+
return await doUrlReplace(rawUrl.trim(), matched, replacer)
98+
})
99+
}
100+
101+
async function rewriteCssImageSet(css: string, replacer: CssUrlReplacer): Promise<string> {
102+
return await asyncReplace(css, cssImageSetRE, async (match) => {
103+
const [, rawUrl] = match
104+
const url = await processSrcSet(rawUrl, async ({ url }) => {
105+
// the url maybe url(...)
106+
if (cssUrlRE.test(url)) {
107+
return await rewriteCssUrls(url, replacer)
108+
}
109+
if (!cssNotProcessedRE.test(url)) {
110+
return await doUrlReplace(url, url, replacer)
111+
}
112+
return url
113+
})
114+
return url
115+
})
116+
}
117+
118+
async function doUrlReplace(
119+
rawUrl: string,
120+
matched: string,
121+
replacer: CssUrlReplacer,
122+
funcName: string = 'url',
123+
) {
124+
let wrap = ''
125+
const first = rawUrl[0]
126+
if (first === `"` || first === `'`) {
127+
wrap = first
128+
rawUrl = rawUrl.slice(1, -1)
129+
}
130+
131+
if (skipUrlReplacer(rawUrl)) {
132+
return matched
133+
}
134+
135+
let newUrl = await replacer(rawUrl)
136+
// The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement
137+
if (wrap === '' && newUrl !== encodeURI(newUrl)) {
138+
wrap = '"'
139+
}
140+
// If wrapping in single quotes and newUrl also contains single quotes, switch to double quotes.
141+
// Give preference to double quotes since SVG inlining converts double quotes to single quotes.
142+
if (wrap === "'" && newUrl.includes("'")) {
143+
wrap = '"'
144+
}
145+
// Escape double quotes if they exist (they also tend to be rarer than single quotes)
146+
if (wrap === '"' && newUrl.includes('"')) {
147+
newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"')
148+
}
149+
return `${funcName}(${wrap}${newUrl}${wrap})`
150+
}
151+
152+
function skipUrlReplacer(rawUrl: string) {
153+
return (
154+
isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl[0] === '#' || functionCallRE.test(rawUrl)
155+
)
156+
}
157+
158+
function processSrcSet(
159+
srcs: string,
160+
replacer: (arg: ImageCandidate) => Promise<string>,
161+
): Promise<string> {
162+
return Promise.all(
163+
parseSrcset(srcs).map(async ({ url, descriptor }) => ({
164+
url: await replacer({ url, descriptor }),
165+
descriptor,
166+
})),
167+
).then(joinSrcset)
168+
}
169+
170+
function parseSrcset(string: string): ImageCandidate[] {
171+
const matches = string
172+
.trim()
173+
.replace(escapedSpaceCharactersRE, ' ')
174+
.replace(/\r?\n/, '')
175+
.replace(/,\s+/, ', ')
176+
.replaceAll(/\s+/g, ' ')
177+
.matchAll(imageCandidateRE)
178+
return Array.from(matches, ({ groups }) => ({
179+
url: groups?.url?.trim() ?? '',
180+
descriptor: groups?.descriptor?.trim() ?? '',
181+
})).filter(({ url }) => !!url)
182+
}
183+
184+
function joinSrcset(ret: ImageCandidate[]) {
185+
return ret.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : '')).join(', ')
186+
}
187+
188+
async function asyncReplace(
189+
input: string,
190+
re: RegExp,
191+
replacer: (match: RegExpExecArray) => string | Promise<string>,
192+
): Promise<string> {
193+
let match: RegExpExecArray | null
194+
let remaining = input
195+
let rewritten = ''
196+
while ((match = re.exec(remaining))) {
197+
rewritten += remaining.slice(0, match.index)
198+
rewritten += await replacer(match)
199+
remaining = remaining.slice(match.index + match[0].length)
200+
}
201+
rewritten += remaining
202+
return rewritten
203+
}

packages/@tailwindcss-vite/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ class Root {
375375
env.DEBUG && console.time('[@tailwindcss/vite] Setup compiler')
376376
this.compiler = await compile(content, {
377377
base: inputBase,
378+
shouldRewriteUrls: true,
378379
onDependency: (path) => {
379380
addWatchFile(path)
380381
this.dependencies.add(path)

packages/tailwindcss/src/ast.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export function walkDepth(
202202
}
203203
}
204204

205-
export function toCss(ast: AstNode[]) {
205+
export function toCss(ast: AstNode[], { printUtilitiesNode = false } = {}) {
206206
let atRoots: string = ''
207207
let seenAtProperties = new Set<string>()
208208
let propertyFallbacksRoot: Declaration[] = []
@@ -224,6 +224,7 @@ export function toCss(ast: AstNode[]) {
224224
// AtRule
225225
else if (node.kind === 'at-rule') {
226226
if (
227+
!printUtilitiesNode &&
227228
node.name === '@tailwind' &&
228229
(node.params === 'utilities' || node.params.startsWith('utilities'))
229230
) {

0 commit comments

Comments
 (0)