Skip to content

Commit c72c83f

Browse files
Vite: Support Tailwind in Svelte <style> blocks (#14151)
Closes #13305 This PR adds registers a Svelte preprocessor, used by the Svelte Vite plugin, to run Tailwind CSS for styles inside the `<style>` block, this enables users to use Tailwind CSS features like `@apply` from inside Svelte components: ```svelte <script> let name = 'world' </script> <h1 class="foo underline">Hello {name}!</h1> <style global> @import 'tailwindcss/utilities'; @import 'tailwindcss/theme' theme(reference); @import './components.css'; </style> ``` ## Test Plan I've added integration tests to validate this works as expected. Furthermore I've used the [tailwindcss-playgrounds](https://github.com/philipp-spiess/tailwind-playgrounds) SvelteKit project to ensure this works in an end-to-end setup: <img width="2250" alt="Screenshot 2024-11-08 at 14 45 31" src="https://github.com/user-attachments/assets/64e9e0f3-53fb-4039-b0a7-3ce945a29179">
1 parent 350add7 commit c72c83f

File tree

5 files changed

+391
-2
lines changed

5 files changed

+391
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Support derived spacing scales based on a single `--spacing` theme value ([#14857](https://github.com/tailwindlabs/tailwindcss/pull/14857))
1313
- Add `svh`, `dvh`, `svw`, `dvw`, and `auto` values to all width/height/size utilities ([#14857](https://github.com/tailwindlabs/tailwindcss/pull/14857))
1414
- Add new `**` variant ([#14903](https://github.com/tailwindlabs/tailwindcss/pull/14903))
15+
- Process `<style>` blocks inside Svelte files when using the Vite extension ([#14151](https://github.com/tailwindlabs/tailwindcss/pull/14151))
1516
- _Upgrade (experimental)_: Migrate `grid-cols-[subgrid]` and `grid-rows-[subgrid]` to `grid-cols-subgrid` and `grid-rows-subgrid` ([#14840](https://github.com/tailwindlabs/tailwindcss/pull/14840))
1617
- _Upgrade (experimental)_: Support migrating projects with multiple config files ([#14863](https://github.com/tailwindlabs/tailwindcss/pull/14863))
1718
- _Upgrade (experimental)_: Rename `shadow` to `shadow-sm`, `shadow-sm` to `shadow-xs`, and `shadow-xs` to `shadow-2xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875))

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
@@ -31,6 +31,7 @@
3131
"@tailwindcss/node": "workspace:^",
3232
"@tailwindcss/oxide": "workspace:^",
3333
"lightningcss": "catalog:",
34+
"svelte-preprocess": "^6.0.2",
3435
"tailwindcss": "workspace:^"
3536
},
3637
"devDependencies": {

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Scanner } from '@tailwindcss/oxide'
44
import { Features, transform } from 'lightningcss'
55
import fs from 'node:fs/promises'
66
import path from 'node:path'
7+
import { sveltePreprocess } from 'svelte-preprocess'
78
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
89

910
const SPECIAL_QUERY_RE = /[?&](raw|url)\b/
@@ -53,9 +54,14 @@ export default function tailwindcss(): Plugin[] {
5354
function invalidateAllRoots(isSSR: boolean) {
5455
for (let server of servers) {
5556
let updates: Update[] = []
56-
for (let id of roots.keys()) {
57+
for (let [id, root] of roots.entries()) {
5758
let module = server.moduleGraph.getModuleById(id)
5859
if (!module) {
60+
// The module for this root might not exist yet
61+
if (root.builtBeforeTransform) {
62+
return
63+
}
64+
5965
// Note: Removing this during SSR is not safe and will produce
6066
// inconsistent results based on the timing of the removal and
6167
// the order / timing of transforms.
@@ -152,6 +158,7 @@ export default function tailwindcss(): Plugin[] {
152158
}
153159

154160
return [
161+
svelteProcessor(roots),
155162
{
156163
// Step 1: Scan source files for candidates
157164
name: '@tailwindcss/vite:scan',
@@ -189,6 +196,19 @@ export default function tailwindcss(): Plugin[] {
189196

190197
let root = roots.get(id)
191198

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

221241
let root = roots.get(id)
222242

243+
if (root.builtBeforeTransform) {
244+
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
245+
root.builtBeforeTransform = undefined
246+
// When a root was built before this transform hook, the candidate
247+
// list might be outdated already by the time the transform hook is
248+
// called.
249+
//
250+
// Since we already do a second render pass in build mode, we don't
251+
// need to do any more work here.
252+
return
253+
}
254+
223255
// We do a first pass to generate valid CSS for the downstream plugins.
224256
// However, since not all candidates are guaranteed to be extracted by
225257
// this time, we have to re-run a transform for the root later.
@@ -266,11 +298,13 @@ function getExtension(id: string) {
266298
}
267299

268300
function isPotentialCssRootFile(id: string) {
301+
if (id.includes('/.vite/')) return
269302
let extension = getExtension(id)
270303
let isCssFile =
271304
(extension === 'css' ||
272305
(extension === 'vue' && id.includes('&lang.css')) ||
273-
(extension === 'astro' && id.includes('&lang.css'))) &&
306+
(extension === 'astro' && id.includes('&lang.css')) ||
307+
(extension === 'svelte' && id.includes('&lang.css'))) &&
274308
// Don't intercept special static asset resources
275309
!SPECIAL_QUERY_RE.test(id)
276310

@@ -338,6 +372,14 @@ class Root {
338372
// `renderStart` hook.
339373
public lastContent: string = ''
340374

375+
// When set, indicates that the root was built before the Vite transform hook
376+
// was being called. This can happen in scenarios like when preprocessing
377+
// `<style>` tags for Svelte components.
378+
//
379+
// It can be set to a list of dependencies that will be added whenever the
380+
// next `transform` hook is being called.
381+
public builtBeforeTransform: string[] | undefined
382+
341383
// The lazily-initialized Tailwind compiler components. These are persisted
342384
// throughout rebuilds but will be re-initialized if the rebuild strategy is
343385
// set to `full`.
@@ -505,3 +547,55 @@ class Root {
505547
return shared
506548
}
507549
}
550+
551+
// Register a plugin that can hook into the Svelte preprocessor if svelte is
552+
// enabled. This allows us to transform CSS in `<style>` tags and create a
553+
// stricter version of CSS that passes the Svelte compiler.
554+
//
555+
// Note that these files will undergo a second pass through the vite transpiler
556+
// later. This is necessary to compute `@tailwind utilities;` with the right
557+
// candidate list.
558+
//
559+
// In practice, it is not recommended to use `@tailwind utilities;` inside
560+
// Svelte components. Use an external `.css` file instead.
561+
function svelteProcessor(roots: DefaultMap<string, Root>) {
562+
return {
563+
name: '@tailwindcss/svelte',
564+
api: {
565+
sveltePreprocess: sveltePreprocess({
566+
aliases: [
567+
['postcss', 'tailwindcss'],
568+
['css', 'tailwindcss'],
569+
],
570+
async tailwindcss({
571+
content,
572+
attributes,
573+
filename,
574+
}: {
575+
content: string
576+
attributes: Record<string, string>
577+
filename?: string
578+
}) {
579+
if (!filename) return
580+
let id = filename + '?svelte&type=style&lang.css'
581+
582+
let root = roots.get(id)
583+
// Mark this root as being built before the Vite transform hook is
584+
// called. We capture all eventually added dependencies so that we can
585+
// connect them to the vite module graph later, when the transform
586+
// hook is called.
587+
root.builtBeforeTransform = []
588+
let generated = await root.generate(content, (file) =>
589+
root?.builtBeforeTransform?.push(file),
590+
)
591+
592+
if (!generated) {
593+
roots.delete(id)
594+
return { code: content, attributes }
595+
}
596+
return { code: generated, attributes }
597+
},
598+
}),
599+
},
600+
}
601+
}

0 commit comments

Comments
 (0)