Skip to content

Commit 6dae4d0

Browse files
Vite: Support Tailwind in Svelte <style> blocks
1 parent 27cced0 commit 6dae4d0

File tree

4 files changed

+409
-2
lines changed

4 files changed

+409
-2
lines changed

integrations/vite/svelte.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { expect } from 'vitest'
2+
import { candidate, css, html, json, retryAssertion, test, ts } from '../utils'
3+
4+
test(
5+
'production build',
6+
{
7+
fs: {
8+
'package.json': json`
9+
{
10+
"type": "module",
11+
"dependencies": {
12+
"svelte": "^4.2.18",
13+
"tailwindcss": "workspace:^"
14+
},
15+
"devDependencies": {
16+
"@sveltejs/vite-plugin-svelte": "^3.1.1",
17+
"@tailwindcss/vite": "workspace:^",
18+
"vite": "^5.3.5"
19+
}
20+
}
21+
`,
22+
'vite.config.ts': ts`
23+
import { defineConfig } from 'vite'
24+
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'
25+
import tailwindcss from '@tailwindcss/vite'
26+
27+
export default defineConfig({
28+
plugins: [
29+
svelte({
30+
preprocess: [vitePreprocess()],
31+
}),
32+
tailwindcss(),
33+
],
34+
})
35+
`,
36+
'index.html': html`
37+
<!doctype html>
38+
<html>
39+
<body>
40+
<div id="app"></div>
41+
<script type="module" src="./src/main.ts"></script>
42+
</body>
43+
</html>
44+
`,
45+
'src/main.ts': ts`
46+
import App from './App.svelte'
47+
const app = new App({
48+
target: document.body,
49+
})
50+
`,
51+
'src/App.svelte': html`
52+
<script>
53+
let name = 'world'
54+
</script>
55+
56+
<h1 class="foo underline">Hello {name}!</h1>
57+
58+
<style global>
59+
@import 'tailwindcss/utilities';
60+
@import 'tailwindcss/theme' theme(reference);
61+
@import './components.css';
62+
</style>
63+
`,
64+
'src/components.css': css`
65+
.foo {
66+
@apply text-red-500;
67+
}
68+
`,
69+
},
70+
},
71+
async ({ fs, exec }) => {
72+
await exec('pnpm vite build')
73+
74+
let files = await fs.glob('dist/**/*.css')
75+
expect(files).toHaveLength(1)
76+
77+
await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`])
78+
},
79+
)
80+
81+
test(
82+
'watch mode',
83+
{
84+
fs: {
85+
'package.json': json`
86+
{
87+
"type": "module",
88+
"dependencies": {
89+
"svelte": "^4.2.18",
90+
"tailwindcss": "workspace:^"
91+
},
92+
"devDependencies": {
93+
"@sveltejs/vite-plugin-svelte": "^3.1.1",
94+
"@tailwindcss/vite": "workspace:^",
95+
"vite": "^5.3.5"
96+
}
97+
}
98+
`,
99+
'vite.config.ts': ts`
100+
import { defineConfig } from 'vite'
101+
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'
102+
import tailwindcss from '@tailwindcss/vite'
103+
104+
export default defineConfig({
105+
plugins: [
106+
svelte({
107+
preprocess: [vitePreprocess()],
108+
}),
109+
tailwindcss(),
110+
],
111+
})
112+
`,
113+
'index.html': html`
114+
<!doctype html>
115+
<html>
116+
<body>
117+
<div id="app"></div>
118+
<script type="module" src="./src/main.ts"></script>
119+
</body>
120+
</html>
121+
`,
122+
'src/main.ts': ts`
123+
import App from './App.svelte'
124+
const app = new App({
125+
target: document.body,
126+
})
127+
`,
128+
'src/App.svelte': html`
129+
<script>
130+
let name = 'world'
131+
</script>
132+
133+
<h1 class="foo underline">Hello {name}!</h1>
134+
135+
<style global>
136+
@import 'tailwindcss/utilities';
137+
@import 'tailwindcss/theme' theme(reference);
138+
@import './components.css';
139+
</style>
140+
`,
141+
'src/components.css': css`
142+
.foo {
143+
@apply text-red-500;
144+
}
145+
`,
146+
},
147+
},
148+
async ({ fs, spawn }) => {
149+
await spawn(`pnpm vite build --watch`)
150+
151+
let filename = ''
152+
await retryAssertion(async () => {
153+
let files = await fs.glob('dist/**/*.css')
154+
expect(files).toHaveLength(1)
155+
filename = files[0][0]
156+
})
157+
158+
await fs.expectFileToContain(filename, [candidate`foo`, candidate`underline`])
159+
160+
await fs.write(
161+
'src/components.css',
162+
css`
163+
.bar {
164+
@apply text-green-500;
165+
}
166+
`,
167+
)
168+
await retryAssertion(async () => {
169+
let files = await fs.glob('dist/**/*.css')
170+
expect(files).toHaveLength(1)
171+
let [, css] = files[0]
172+
expect(css).toContain(candidate`underline`)
173+
expect(css).toContain(candidate`bar`)
174+
expect(css).not.toContain(candidate`foo`)
175+
})
176+
},
177+
)

packages/@tailwindcss-vite/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"lightningcss": "catalog:",
3434
"postcss": "^8.4.41",
3535
"postcss-import": "^16.1.0",
36+
"svelte-preprocess": "^6.0.2",
3637
"tailwindcss": "workspace:^"
3738
},
3839
"devDependencies": {

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import fs from 'node:fs/promises'
88
import path from 'path'
99
import postcss from 'postcss'
1010
import postcssImport from 'postcss-import'
11+
import { sveltePreprocess } from 'svelte-preprocess'
1112
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
1213

1314
export default function tailwindcss(): Plugin[] {
@@ -60,9 +61,14 @@ export default function tailwindcss(): Plugin[] {
6061
function invalidateAllRoots(isSSR: boolean) {
6162
for (let server of servers) {
6263
let updates: Update[] = []
63-
for (let id of roots.keys()) {
64+
for (let [id, root] of roots.entries()) {
6465
let module = server.moduleGraph.getModuleById(id)
6566
if (!module) {
67+
// The module for this root might not exist yet
68+
if (root.builtBeforeTransform) {
69+
return
70+
}
71+
6672
// Note: Removing this during SSR is not safe and will produce
6773
// inconsistent results based on the timing of the removal and
6874
// the order / timing of transforms.
@@ -133,6 +139,7 @@ export default function tailwindcss(): Plugin[] {
133139
}
134140

135141
return [
142+
svelteProcessor(roots),
136143
{
137144
// Step 1: Scan source files for candidates
138145
name: '@tailwindcss/vite:scan',
@@ -184,6 +191,19 @@ export default function tailwindcss(): Plugin[] {
184191

185192
let root = roots.get(id)
186193

194+
if (root.builtBeforeTransform) {
195+
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
196+
root.builtBeforeTransform = undefined
197+
// When a root was built before this transform hook, the candidate
198+
// list might be outdated already by the time the transform hook is
199+
// called.
200+
//
201+
// This requires us to build the CSS file again. However, we do not
202+
// expect dependencies to have changed, so we can avoid a full
203+
// rebuild.
204+
root.requiresRebuild = false
205+
}
206+
187207
if (!options?.ssr) {
188208
// Wait until all other files have been processed, so we can extract
189209
// all candidates before generating CSS. This must not be called
@@ -215,6 +235,18 @@ export default function tailwindcss(): Plugin[] {
215235

216236
let root = roots.get(id)
217237

238+
if (root.builtBeforeTransform) {
239+
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
240+
root.builtBeforeTransform = undefined
241+
// When a root was built before this transform hook, the candidate
242+
// list might be outdated already by the time the transform hook is
243+
// called.
244+
//
245+
// Since we already do a second render pass in build mode, we don't
246+
// need to do any more work here.
247+
return
248+
}
249+
218250
// We do a first pass to generate valid CSS for the downstream plugins.
219251
// However, since not all candidates are guaranteed to be extracted by
220252
// this time, we have to re-run a transform for the root later.
@@ -261,11 +293,13 @@ function getExtension(id: string) {
261293
}
262294

263295
function isPotentialCssRootFile(id: string) {
296+
if (id.includes('/.vite/')) return
264297
let extension = getExtension(id)
265298
let isCssFile =
266299
extension === 'css' ||
267300
(extension === 'vue' && id.includes('&lang.css')) ||
268-
(extension === 'astro' && id.includes('&lang.css'))
301+
(extension === 'astro' && id.includes('&lang.css')) ||
302+
(extension === 'svelte' && id.includes('&lang.css'))
269303
return isCssFile
270304
}
271305

@@ -336,6 +370,14 @@ class Root {
336370
// `renderStart` hook.
337371
public lastContent: string = ''
338372

373+
// When set, indicates that the root was built before the Vite transform hook
374+
// was being called. This can happen in scenarios like when preprocessing
375+
// `<style>` tags for Svelte components.
376+
//
377+
// It can be set to a list of dependencies that will be added whenever the
378+
// next `transform` hook is being called.
379+
public builtBeforeTransform: string[] | undefined
380+
339381
// The lazily-initialized Tailwind compiler components. These are persisted
340382
// throughout rebuilds but will be re-initialized if the rebuild strategy is
341383
// set to `full`.
@@ -451,3 +493,55 @@ class Root {
451493
return this.compiler.build([...this.getSharedCandidates(), ...this.candidates])
452494
}
453495
}
496+
497+
// Register a plugin that can hook into the Svelte preprocessor if svelte is
498+
// enabled. This allows us to transform CSS in `<style>` tags and create a
499+
// stricter version of CSS that passes the Svelte compiler.
500+
//
501+
// Note that these files will undergo a second pass through the vite transpiler
502+
// later. This is necessary to compute `@tailwind utilities;` with the right
503+
// candidate list.
504+
//
505+
// In practice, it is not recommended to use `@tailwind utilities;` inside
506+
// Svelte components. Use an external `.css` file instead.
507+
function svelteProcessor(roots: DefaultMap<string, Root>) {
508+
return {
509+
name: '@tailwindcss/svelte',
510+
api: {
511+
sveltePreprocess: sveltePreprocess({
512+
aliases: [
513+
['postcss', 'tailwindcss'],
514+
['css', 'tailwindcss'],
515+
],
516+
async tailwindcss({
517+
content,
518+
attributes,
519+
filename,
520+
}: {
521+
content: string
522+
attributes: Record<string, string>
523+
filename?: string
524+
}) {
525+
if (!filename) return
526+
let id = filename + '?svelte&type=style&lang.css'
527+
528+
let root = roots.get(id)
529+
// Mark this root as being built before the Vite transform hook is
530+
// called. We capture all eventually added dependencies so that we can
531+
// connect them to the vite module graph later, when the transform
532+
// hook is called.
533+
root.builtBeforeTransform = []
534+
let generated = await root.generate(content, (file) =>
535+
root?.builtBeforeTransform?.push(file),
536+
)
537+
538+
if (!generated) {
539+
roots.delete(id)
540+
return { code: content, attributes }
541+
}
542+
return { code: generated, attributes }
543+
},
544+
}),
545+
},
546+
}
547+
}

0 commit comments

Comments
 (0)