Skip to content

Generate CSS after Vite handles imports during prod builds #14850

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ interface ExecOptions {
}

interface TestConfig {
todo?: boolean
fs: {
[filePath: string]: string
[filePath: string]: string | Uint8Array
}
}
interface TestContext {
Expand Down Expand Up @@ -74,6 +75,10 @@ export function test(
testCallback: TestCallback,
{ only = false, debug = false }: TestFlags = {},
) {
if (config.todo) {
return defaultTest.todo(name)
}

return (only || (!process.env.CI && debug) ? defaultTest.only : defaultTest)(
name,
{ timeout: TEST_TIMEOUT, retry: process.env.CI ? 2 : 0 },
Expand Down Expand Up @@ -279,8 +284,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 @@ -291,8 +302,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)
},

Expand Down Expand Up @@ -494,6 +503,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
76 changes: 76 additions & 0 deletions integrations/vite/url-rewriting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect } from 'vitest'
import { binary, css, html, test, ts, txt } from '../utils'

const SIMPLE_IMAGE = `iVBORw0KGgoAAAANSUhEUgAAADAAAAAlAQAAAAAsYlcCAAAACklEQVR4AWMYBQABAwABRUEDtQAAAABJRU5ErkJggg==`

for (let transformer of ['postcss', 'lightningcss']) {
describe(transformer, () => {
test(
'can rewrite urls in production builds',
{
todo: transformer === 'lightningcss',
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: { 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';
`,
'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');
}
`,
'resources/image.png': binary(SIMPLE_IMAGE),
},
},
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])
},
)
})
}
12 changes: 10 additions & 2 deletions integrations/vite/vue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ test(
@apply text-red-500;
}
</style>

<style scoped>
@import 'tailwindcss/utilities';
@import 'tailwindcss/theme' theme(reference);
:deep(.bar) {
color: red;
}
</style>
<template>
<div class="underline foo">Hello Vue!</div>
<div class="underline foo bar">Hello Vue!</div>
</template>
`,
},
Expand All @@ -65,5 +71,7 @@ test(
expect(files).toHaveLength(1)

await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`])

await fs.expectFileNotToContain(files[0][0], [':deep(.bar)'])
},
)
78 changes: 78 additions & 0 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ export default function tailwindcss(): Plugin[] {
return css
}

function isUsingLightningCSS() {
return config?.css.transformer === 'lightningcss'
}

return [
{
// Step 1: Scan source files for candidates
Expand Down Expand Up @@ -206,12 +210,84 @@ export default function tailwindcss(): Plugin[] {
},

{
// Step 2 (full build): Generate CSS
name: '@tailwindcss/vite:generate:build',
apply: 'build',

// NOTE:
// We used to use `enforce: 'pre'` here because Tailwind CSS can handle
// imports itself. However, this caused two problems:
//
// - Relative asset URL rewriting for was not happening so things like
// `background-image: url(../image.png)` could break if done in an
// imported CSS file.
//
// - Processing of Vue scoped style blocks didn't happen at the right time
// which caused `:deep(…)` to end up in the generated CSS rather than
// appropriately handled by Vue.
//
// This does mean that Tailwind is no longer handling the imports itself
// which is not ideal but it's a reasonable tradeoff until we can resolve
// both of these issues with Tailwind's own import handling.

async transform(src, id) {
if (isUsingLightningCSS()) return
if (!isPotentialCssRootFile(id)) return

let root = roots.get(id)

// We do a first pass to generate valid CSS for the downstream plugins.
// However, since not all candidates are guaranteed to be extracted by
// this time, we have to re-run a transform for the root later.
let generated = await root.generate(src, (file) => this.addWatchFile(file))
if (!generated) {
roots.delete(id)
return src
}
return { code: generated }
},

// `renderStart` runs in the bundle generation stage after all transforms.
// We must run before `enforce: post` so the updated chunks are picked up
// by vite:css-post.
async renderStart() {
if (isUsingLightningCSS()) return

for (let [id, root] of roots.entries()) {
let generated = await regenerateOptimizedCss(
root,
// During the renderStart phase, we can not add watch files since
// those would not be causing a refresh of the right CSS file. This
// should not be an issue since we did already process the CSS file
// before and the dependencies should not be changed (only the
// candidate list might have)
() => {},
)
if (!generated) {
roots.delete(id)
continue
}

// These plugins have side effects which, during build, results in CSS
// being written to the output dir. We need to run them here to ensure
// the CSS is written before the bundle is generated.
await transformWithPlugins(this, id, generated)
}
},
},

{
// NOTE: This is an exact copy of the above plugin but with `enforce: pre`
// when using Lightning CSS.

// Step 2 (full build): Generate CSS
name: '@tailwindcss/vite:generate:build',
apply: 'build',
enforce: 'pre',

async transform(src, id) {
if (!isUsingLightningCSS()) return

if (!isPotentialCssRootFile(id)) return

let root = roots.get(id)
Expand All @@ -231,6 +307,8 @@ export default function tailwindcss(): Plugin[] {
// We must run before `enforce: post` so the updated chunks are picked up
// by vite:css-post.
async renderStart() {
if (!isUsingLightningCSS()) return

for (let [id, root] of roots.entries()) {
let generated = await regenerateOptimizedCss(
root,
Expand Down