Skip to content

Commit e82b316

Browse files
Rewrite urls in CSS files when using Vite (#14877)
Fixes #14784 This is an alternative to #14850 in which we actually perform url rewriting / rebasing ourselves. We ported a large portion of the URL-rewriting code from Vite (with attribution) to use here with some minor modifications. We've added test cases for the url rewriting so verifying individual cases is easy. We also wrote integration tests for Vite that use PostCSS and Lightning CSS that verify that files are found and inlined or relocated/renamed as necessary. We also did some manual testing in the Playground to verify that this works as expected across several CSS files and directories which you can see a screenshot from here: <img width="1344" alt="Screenshot 2024-11-05 at 10 25 16" src="https://github.com/user-attachments/assets/ff0b3ac8-cdc9-4e26-af79-36396a5b77b9"> --------- Co-authored-by: Philipp Spiess <hello@philippspiess.com>
1 parent 75eeed8 commit e82b316

File tree

9 files changed

+479
-18
lines changed

9 files changed

+479
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Ensure `--inset-ring=*` and `--inset-shadow-*` variables are ignored by `inset-*` utilities ([#14855](https://github.com/tailwindlabs/tailwindcss/pull/14855))
2828
- Ensure `url(…)` containing special characters such as `;` or `{}` end up in one declaration ([#14879](https://github.com/tailwindlabs/tailwindcss/pull/14879))
2929
- Ensure adjacent rules are merged together after handling nesting when generating optimized CSS ([#14873](https://github.com/tailwindlabs/tailwindcss/pull/14873))
30+
- Rebase `url()` inside imported CSS files when using Vite ([#14877](https://github.com/tailwindlabs/tailwindcss/pull/14877))
3031
- _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830))
3132
- _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838))
3233
- _Upgrade (experimental)_: Fix crash during upgrade when content globs escape root of project ([#14896](https://github.com/tailwindlabs/tailwindcss/pull/14896))

integrations/utils.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ interface ExecOptions {
2929

3030
interface TestConfig {
3131
fs: {
32-
[filePath: string]: string
32+
[filePath: string]: string | Uint8Array
3333
}
3434
}
3535
interface TestContext {
@@ -280,8 +280,14 @@ export function test(
280280
})
281281
},
282282
fs: {
283-
async write(filename: string, content: string): Promise<void> {
283+
async write(filename: string, content: string | Uint8Array): Promise<void> {
284284
let full = path.join(root, filename)
285+
let dir = path.dirname(full)
286+
await fs.mkdir(dir, { recursive: true })
287+
288+
if (typeof content !== 'string') {
289+
return await fs.writeFile(full, content)
290+
}
285291

286292
if (filename.endsWith('package.json')) {
287293
content = await overwriteVersionsInPackageJson(content)
@@ -292,8 +298,6 @@ export function test(
292298
content = content.replace(/\n/g, '\r\n')
293299
}
294300

295-
let dir = path.dirname(full)
296-
await fs.mkdir(dir, { recursive: true })
297301
await fs.writeFile(full, content, 'utf-8')
298302
},
299303

@@ -487,6 +491,7 @@ function testIfPortTaken(port: number): Promise<boolean> {
487491
})
488492
}
489493

494+
export let svg = dedent
490495
export let css = dedent
491496
export let html = dedent
492497
export let ts = dedent
@@ -495,6 +500,12 @@ export let json = dedent
495500
export let yaml = dedent
496501
export let txt = dedent
497502

503+
export function binary(str: string | TemplateStringsArray, ...values: unknown[]): Uint8Array {
504+
let base64 = typeof str === 'string' ? str : String.raw(str, ...values)
505+
506+
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
507+
}
508+
498509
export function candidate(strings: TemplateStringsArray, ...values: any[]) {
499510
let output: string[] = []
500511
for (let i = 0; i < strings.length; i++) {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect } from 'vitest'
2+
import { binary, css, html, svg, test, ts, txt } from '../utils'
3+
4+
const SIMPLE_IMAGE = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAlAQAAAAAsYlcCAAAACklEQVR4AWMYBQABAwABRUEDtQAAAABJRU5ErkJggg==`
5+
6+
for (let transformer of ['postcss', 'lightningcss']) {
7+
describe(transformer, () => {
8+
test(
9+
'can rewrite urls in production builds',
10+
{
11+
fs: {
12+
'package.json': txt`
13+
{
14+
"type": "module",
15+
"dependencies": {
16+
"tailwindcss": "workspace:^"
17+
},
18+
"devDependencies": {
19+
${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''}
20+
"@tailwindcss/vite": "workspace:^",
21+
"vite": "^5.3.5"
22+
}
23+
}
24+
`,
25+
'vite.config.ts': ts`
26+
import tailwindcss from '@tailwindcss/vite'
27+
import { defineConfig } from 'vite'
28+
29+
export default defineConfig({
30+
plugins: [tailwindcss()],
31+
build: {
32+
assetsInlineLimit: 256,
33+
cssMinify: false,
34+
},
35+
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
36+
})
37+
`,
38+
'index.html': html`
39+
<!doctype html>
40+
<html>
41+
<head>
42+
<link rel="stylesheet" href="./src/app.css" />
43+
</head>
44+
<body>
45+
<div id="app"></div>
46+
<script type="module" src="./src/main.ts"></script>
47+
</body>
48+
</html>
49+
`,
50+
'src/main.ts': ts``,
51+
'src/app.css': css`
52+
@import './dir-1/bar.css';
53+
@import './dir-1/dir-2/baz.css';
54+
@import './dir-1/dir-2/vector.css';
55+
`,
56+
'src/dir-1/bar.css': css`
57+
.bar {
58+
background-image: url('../../resources/image.png');
59+
}
60+
`,
61+
'src/dir-1/dir-2/baz.css': css`
62+
.baz {
63+
background-image: url('../../../resources/image.png');
64+
}
65+
`,
66+
'src/dir-1/dir-2/vector.css': css`
67+
.baz {
68+
background-image: url('../../../resources/vector.svg');
69+
}
70+
`,
71+
'resources/image.png': binary(SIMPLE_IMAGE),
72+
'resources/vector.svg': svg`
73+
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
74+
<rect width="100%" height="100%" fill="red" />
75+
<circle cx="200" cy="100" r="80" fill="green" />
76+
<rect width="100%" height="100%" fill="red" />
77+
<circle cx="200" cy="100" r="80" fill="green" />
78+
</svg>
79+
`,
80+
},
81+
},
82+
async ({ fs, exec }) => {
83+
await exec('pnpm vite build')
84+
85+
let files = await fs.glob('dist/**/*.css')
86+
expect(files).toHaveLength(1)
87+
88+
await fs.expectFileToContain(files[0][0], [SIMPLE_IMAGE])
89+
90+
let images = await fs.glob('dist/**/*.svg')
91+
expect(images).toHaveLength(1)
92+
93+
await fs.expectFileToContain(files[0][0], [/\/assets\/vector-.*?\.svg/])
94+
},
95+
)
96+
})
97+
}

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+
})

0 commit comments

Comments
 (0)