Skip to content

Commit

Permalink
Rewrite urls in CSS files when using Vite (#14877)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
thecrypticace and philipp-spiess authored Nov 7, 2024
1 parent 75eeed8 commit e82b316
Show file tree
Hide file tree
Showing 9 changed files with 479 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `--inset-ring=*` and `--inset-shadow-*` variables are ignored by `inset-*` utilities ([#14855](https://github.com/tailwindlabs/tailwindcss/pull/14855))
- Ensure `url(…)` containing special characters such as `;` or `{}` end up in one declaration ([#14879](https://github.com/tailwindlabs/tailwindcss/pull/14879))
- Ensure adjacent rules are merged together after handling nesting when generating optimized CSS ([#14873](https://github.com/tailwindlabs/tailwindcss/pull/14873))
- Rebase `url()` inside imported CSS files when using Vite ([#14877](https://github.com/tailwindlabs/tailwindcss/pull/14877))
- _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830))
- _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838))
- _Upgrade (experimental)_: Fix crash during upgrade when content globs escape root of project ([#14896](https://github.com/tailwindlabs/tailwindcss/pull/14896))
Expand Down
19 changes: 15 additions & 4 deletions integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface ExecOptions {

interface TestConfig {
fs: {
[filePath: string]: string
[filePath: string]: string | Uint8Array
}
}
interface TestContext {
Expand Down Expand Up @@ -280,8 +280,14 @@ export function test(
})
},
fs: {
async write(filename: string, content: string): Promise<void> {
async write(filename: string, content: string | Uint8Array): Promise<void> {
let full = path.join(root, filename)
let dir = path.dirname(full)
await fs.mkdir(dir, { recursive: true })

if (typeof content !== 'string') {
return await fs.writeFile(full, content)
}

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

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

Expand Down Expand Up @@ -487,6 +491,7 @@ function testIfPortTaken(port: number): Promise<boolean> {
})
}

export let svg = dedent
export let css = dedent
export let html = dedent
export let ts = dedent
Expand All @@ -495,6 +500,12 @@ export let json = dedent
export let yaml = dedent
export let txt = dedent

export function binary(str: string | TemplateStringsArray, ...values: unknown[]): Uint8Array {
let base64 = typeof str === 'string' ? str : String.raw(str, ...values)

return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
}

export function candidate(strings: TemplateStringsArray, ...values: any[]) {
let output: string[] = []
for (let i = 0; i < strings.length; i++) {
Expand Down
97 changes: 97 additions & 0 deletions integrations/vite/url-rewriting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect } from 'vitest'
import { binary, css, html, svg, test, ts, txt } from '../utils'

const SIMPLE_IMAGE = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAlAQAAAAAsYlcCAAAACklEQVR4AWMYBQABAwABRUEDtQAAAABJRU5ErkJggg==`

for (let transformer of ['postcss', 'lightningcss']) {
describe(transformer, () => {
test(
'can rewrite urls in production builds',
{
fs: {
'package.json': txt`
{
"type": "module",
"dependencies": {
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''}
"@tailwindcss/vite": "workspace:^",
"vite": "^5.3.5"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss()],
build: {
assetsInlineLimit: 256,
cssMinify: false,
},
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
})
`,
'index.html': html`
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./src/app.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.ts': ts``,
'src/app.css': css`
@import './dir-1/bar.css';
@import './dir-1/dir-2/baz.css';
@import './dir-1/dir-2/vector.css';
`,
'src/dir-1/bar.css': css`
.bar {
background-image: url('../../resources/image.png');
}
`,
'src/dir-1/dir-2/baz.css': css`
.baz {
background-image: url('../../../resources/image.png');
}
`,
'src/dir-1/dir-2/vector.css': css`
.baz {
background-image: url('../../../resources/vector.svg');
}
`,
'resources/image.png': binary(SIMPLE_IMAGE),
'resources/vector.svg': svg`
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="red" />
<circle cx="200" cy="100" r="80" fill="green" />
<rect width="100%" height="100%" fill="red" />
<circle cx="200" cy="100" r="80" fill="green" />
</svg>
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm vite build')

let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)

await fs.expectFileToContain(files[0][0], [SIMPLE_IMAGE])

let images = await fs.glob('dist/**/*.svg')
expect(images).toHaveLength(1)

await fs.expectFileToContain(files[0][0], [/\/assets\/vector-.*?\.svg/])
},
)
})
}
23 changes: 21 additions & 2 deletions packages/@tailwindcss-node/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,37 @@ import {
compile as _compile,
} from 'tailwindcss'
import { getModuleDependencies } from './get-module-dependencies'
import { rewriteUrls } from './urls'

export async function compile(
css: string,
{ base, onDependency }: { base: string; onDependency: (path: string) => void },
{
base,
onDependency,
shouldRewriteUrls,
}: {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean
},
) {
let compiler = await _compile(css, {
base,
async loadModule(id, base) {
return loadModule(id, base, onDependency)
},
async loadStylesheet(id, base) {
return loadStylesheet(id, base, onDependency)
let sheet = await loadStylesheet(id, base, onDependency)

if (shouldRewriteUrls) {
sheet.content = await rewriteUrls({
css: sheet.content,
root: base,
base: sheet.base,
})
}

return sheet
},
})

Expand Down
127 changes: 127 additions & 0 deletions packages/@tailwindcss-node/src/urls.test.ts
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");
}
"
`)
})
Loading

0 comments on commit e82b316

Please sign in to comment.