Skip to content

Commit 3249777

Browse files
Process Tailwind output with Vite CSS plugins (#13218)
Transform Tailwind-generated CSS with Vite CSS plugins. vite:css does useful things like transforming url() paths and inlining images. vite:css-post generates bundle hashes. Before this change, the CSS bundle hash wasn't changing when the generated CSS changed. Also adds Vite 5.2 peerDependancy. --------- Co-authored-by: Jordan Pittman <[email protected]>
1 parent fcdb327 commit 3249777

File tree

3 files changed

+89
-39
lines changed

3 files changed

+89
-39
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Make `rotate-x/y/z-*` utilities composable ([#13319](https://github.com/tailwindlabs/tailwindcss/pull/13319))
13+
- `@tailwind/vite` applies the Vite CSS plugin to transform Tailwind-generated CSS (e.g. inlining images) ([#13218](https://github.com/tailwindlabs/tailwindcss/pull/13218))
1314

1415
### Fixed
1516

1617
- Remove percentage values for `translate-z` utilities ([#13321](https://github.com/tailwindlabs/tailwindcss/pull/13321), [#13327](https://github.com/tailwindlabs/tailwindcss/pull/13327))
18+
- `@tailwind/vite` now generates unique CSS bundle hashes when the Tailwind-generated CSS changes ([#13218](https://github.com/tailwindlabs/tailwindcss/pull/13218))
1719

1820
## [4.0.0-alpha.10] - 2024-03-19
1921

packages/@tailwindcss-vite/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,8 @@
3535
"devDependencies": {
3636
"@types/node": "^20.11.17",
3737
"vite": "^5.2.0"
38+
},
39+
"peerDependencies": {
40+
"vite": "^5.2.0"
3841
}
3942
}

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

Lines changed: 84 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,29 @@ import { IO, Parsing, scanFiles } from '@tailwindcss/oxide'
22
import { Features, transform } from 'lightningcss'
33
import path from 'path'
44
import { compile } from 'tailwindcss'
5-
import type { Plugin, Update, ViteDevServer } from 'vite'
5+
import type { Plugin, Rollup, Update, ViteDevServer } from 'vite'
66

77
export default function tailwindcss(): Plugin[] {
88
let server: ViteDevServer | null = null
99
let candidates = new Set<string>()
10-
let cssModules = new Set<string>()
10+
// In serve mode, we treat this as a set, storing storing empty strings.
11+
// In build mode, we store file contents to use them in renderChunk.
12+
let cssModules: Record<string, string> = {}
1113
let minify = false
14+
let cssPlugins: readonly Plugin[] = []
1215

13-
function isCssFile(id: string) {
14-
let [filename] = id.split('?', 2)
15-
let extension = path.extname(filename).slice(1)
16-
return extension === 'css'
17-
}
18-
19-
// Trigger update to all css modules
16+
// Trigger update to all CSS modules
2017
function updateCssModules() {
2118
// If we're building then we don't need to update anything
2219
if (!server) return
2320

2421
let updates: Update[] = []
25-
for (let id of cssModules.values()) {
22+
for (let id of Object.keys(cssModules)) {
2623
let cssModule = server.moduleGraph.getModuleById(id)
2724
if (!cssModule) {
2825
// It is safe to remove the item here since we're iterating on a copy of
29-
// the values.
30-
cssModules.delete(id)
26+
// the keys.
27+
delete cssModules[id]
3128
continue
3229
}
3330

@@ -71,6 +68,39 @@ export default function tailwindcss(): Plugin[] {
7168
return optimizeCss(generateCss(css), { minify })
7269
}
7370

71+
// Manually run the transform functions of non-Tailwind plugins on the given CSS
72+
async function transformWithPlugins(context: Rollup.PluginContext, id: string, css: string) {
73+
let transformPluginContext = {
74+
...context,
75+
getCombinedSourcemap: () => {
76+
throw new Error('getCombinedSourcemap not implemented')
77+
},
78+
}
79+
80+
for (let plugin of cssPlugins) {
81+
if (!plugin.transform) continue
82+
const transformHandler =
83+
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
84+
85+
try {
86+
// Directly call the plugin's transform function to process the
87+
// generated CSS. In build mode, this updates the chunks later used to
88+
// generate the bundle. In serve mode, the transformed souce should be
89+
// applied in transform.
90+
let result = await transformHandler.call(transformPluginContext, css, id)
91+
if (!result) continue
92+
if (typeof result === 'string') {
93+
css = result
94+
} else if (result.code) {
95+
css = result.code
96+
}
97+
} catch (e) {
98+
console.error(`Error running ${plugin.name} on Tailwind CSS output. Skipping.`)
99+
}
100+
}
101+
return css
102+
}
103+
74104
return [
75105
{
76106
// Step 1: Scan source files for candidates
@@ -83,77 +113,92 @@ export default function tailwindcss(): Plugin[] {
83113

84114
async configResolved(config) {
85115
minify = config.build.cssMinify !== false
116+
cssPlugins = config.plugins.filter((plugin) =>
117+
['vite:css', 'vite:css-post'].includes(plugin.name),
118+
)
86119
},
87120

88121
// Scan index.html for candidates
89122
transformIndexHtml(html) {
90123
let updated = scan(html, 'html')
91124

92-
// In dev mode, if the generated CSS contains a URL that causes the
125+
// In serve mode, if the generated CSS contains a URL that causes the
93126
// browser to load a page (e.g. an URL to a missing image), triggering a
94127
// CSS update will cause an infinite loop. We only trigger if the
95128
// candidates have been updated.
96-
if (server && updated) {
129+
if (updated) {
97130
updateCssModules()
98131
}
99132
},
100133

101-
// Scan all other files for candidates
134+
// Scan all non-CSS files for candidates
102135
transform(src, id) {
103136
if (id.includes('/.vite/')) return
104-
let [filename] = id.split('?', 2)
105-
let extension = path.extname(filename).slice(1)
137+
let extension = getExtension(id)
106138
if (extension === '' || extension === 'css') return
107139

108140
scan(src, extension)
109-
110-
if (server) {
111-
updateCssModules()
112-
}
141+
updateCssModules()
113142
},
114143
},
115144

145+
/*
146+
* The plugins that generate CSS must run after 'enforce: pre' so @imports
147+
* are expanded in transform.
148+
*/
149+
116150
{
117-
// Step 2 (dev mode): Generate CSS
151+
// Step 2 (serve mode): Generate CSS
118152
name: '@tailwindcss/vite:generate:serve',
119153
apply: 'serve',
120154

121155
async transform(src, id) {
122-
if (!isCssFile(id) || !src.includes('@tailwind')) return
156+
if (!isTailwindCssFile(id, src)) return
123157

124-
cssModules.add(id)
158+
// In serve mode, we treat cssModules as a set, ignoring the value.
159+
cssModules[id] = ''
125160

126161
// Wait until all other files have been processed, so we can extract all
127162
// candidates before generating CSS.
128-
await server?.waitForRequestsIdle(id)
163+
await server?.waitForRequestsIdle?.(id)
129164

130-
return { code: generateCss(src) }
165+
let code = await transformWithPlugins(this, id, generateCss(src))
166+
return { code }
131167
},
132168
},
133169

134170
{
135171
// Step 2 (full build): Generate CSS
136172
name: '@tailwindcss/vite:generate:build',
137-
enforce: 'post',
138173
apply: 'build',
139-
generateBundle(_options, bundle) {
140-
for (let id in bundle) {
141-
let item = bundle[id]
142-
if (item.type !== 'asset') continue
143-
if (!isCssFile(id)) continue
144-
let rawSource = item.source
145-
let source =
146-
rawSource instanceof Uint8Array ? new TextDecoder().decode(rawSource) : rawSource
147-
148-
if (source.includes('@tailwind')) {
149-
item.source = generateOptimizedCss(source)
150-
}
174+
175+
transform(src, id) {
176+
if (!isTailwindCssFile(id, src)) return
177+
cssModules[id] = src
178+
},
179+
180+
// renderChunk runs in the bundle generation stage after all transforms.
181+
// We must run before `enforce: post` so the updated chunks are picked up
182+
// by vite:css-post.
183+
async renderChunk(_code, _chunk) {
184+
for (let [cssFile, css] of Object.entries(cssModules)) {
185+
await transformWithPlugins(this, cssFile, generateOptimizedCss(css))
151186
}
152187
},
153188
},
154189
] satisfies Plugin[]
155190
}
156191

192+
function getExtension(id: string) {
193+
let [filename] = id.split('?', 2)
194+
return path.extname(filename).slice(1)
195+
}
196+
197+
function isTailwindCssFile(id: string, src: string) {
198+
if (id.includes('/.vite/')) return
199+
return getExtension(id) === 'css' && src.includes('@tailwind')
200+
}
201+
157202
function optimizeCss(
158203
input: string,
159204
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},

0 commit comments

Comments
 (0)