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">
This commit is contained in:
Philipp Spiess 2024-11-08 20:01:16 +01:00 • committed by GitHub
parent 350add7490
commit c72c83fee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 391 additions and 2 deletions

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support derived spacing scales based on a single `--spacing` theme value ([#14857](https://github.com/tailwindlabs/tailwindcss/pull/14857))
- Add `svh`, `dvh`, `svw`, `dvw`, and `auto` values to all width/height/size utilities ([#14857](https://github.com/tailwindlabs/tailwindcss/pull/14857))
- Add new `**` variant ([#14903](https://github.com/tailwindlabs/tailwindcss/pull/14903))
- Process `<style>` blocks inside Svelte files when using the Vite extension ([#14151](https://github.com/tailwindlabs/tailwindcss/pull/14151))
- _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))
- _Upgrade (experimental)_: Support migrating projects with multiple config files ([#14863](https://github.com/tailwindlabs/tailwindcss/pull/14863))
- _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))

View file

@ -0,0 +1,177 @@
import { expect } from 'vitest'
import { candidate, css, html, json, retryAssertion, test, ts } from '../utils'
test(
'production build',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"svelte": "^4.2.18",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/vite": "workspace:^",
"vite": "^5.3.5"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
svelte({
preprocess: [vitePreprocess()],
}),
tailwindcss(),
],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.ts': ts`
import App from './App.svelte'
const app = new App({
target: document.body,
})
`,
'src/App.svelte': html`
<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>
`,
'src/components.css': css`
.foo {
@apply text-red-500;
}
`,
},
},
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], [candidate`underline`, candidate`foo`])
},
)
test(
'watch mode',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"svelte": "^4.2.18",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/vite": "workspace:^",
"vite": "^5.3.5"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
svelte({
preprocess: [vitePreprocess()],
}),
tailwindcss(),
],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.ts': ts`
import App from './App.svelte'
const app = new App({
target: document.body,
})
`,
'src/App.svelte': html`
<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>
`,
'src/components.css': css`
.foo {
@apply text-red-500;
}
`,
},
},
async ({ fs, spawn }) => {
await spawn(`pnpm vite build --watch`)
let filename = ''
await retryAssertion(async () => {
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
filename = files[0][0]
})
await fs.expectFileToContain(filename, [candidate`foo`, candidate`underline`])
await fs.write(
'src/components.css',
css`
.bar {
@apply text-green-500;
}
`,
)
await retryAssertion(async () => {
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [, css] = files[0]
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`bar`)
expect(css).not.toContain(candidate`foo`)
})
},
)

View file

@ -31,6 +31,7 @@
"@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "catalog:",
"svelte-preprocess": "^6.0.2",
"tailwindcss": "workspace:^"
},
"devDependencies": {

View file

@ -4,6 +4,7 @@ import { Scanner } from '@tailwindcss/oxide'
import { Features, transform } from 'lightningcss'
import fs from 'node:fs/promises'
import path from 'node:path'
import { sveltePreprocess } from 'svelte-preprocess'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
const SPECIAL_QUERY_RE = /[?&](raw|url)\b/
@ -53,9 +54,14 @@ export default function tailwindcss(): Plugin[] {
function invalidateAllRoots(isSSR: boolean) {
for (let server of servers) {
let updates: Update[] = []
for (let id of roots.keys()) {
for (let [id, root] of roots.entries()) {
let module = server.moduleGraph.getModuleById(id)
if (!module) {
// The module for this root might not exist yet
if (root.builtBeforeTransform) {
return
}
// Note: Removing this during SSR is not safe and will produce
// inconsistent results based on the timing of the removal and
// the order / timing of transforms.
@ -152,6 +158,7 @@ export default function tailwindcss(): Plugin[] {
}
return [
svelteProcessor(roots),
{
// Step 1: Scan source files for candidates
name: '@tailwindcss/vite:scan',
@ -189,6 +196,19 @@ export default function tailwindcss(): Plugin[] {
let root = roots.get(id)
if (root.builtBeforeTransform) {
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
root.builtBeforeTransform = undefined
// When a root was built before this transform hook, the candidate
// list might be outdated already by the time the transform hook is
// called.
//
// This requires us to build the CSS file again. However, we do not
// expect dependencies to have changed, so we can avoid a full
// rebuild.
root.requiresRebuild = false
}
if (!options?.ssr) {
// Wait until all other files have been processed, so we can extract
// all candidates before generating CSS. This must not be called
@ -220,6 +240,18 @@ export default function tailwindcss(): Plugin[] {
let root = roots.get(id)
if (root.builtBeforeTransform) {
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
root.builtBeforeTransform = undefined
// When a root was built before this transform hook, the candidate
// list might be outdated already by the time the transform hook is
// called.
//
// Since we already do a second render pass in build mode, we don't
// need to do any more work here.
return
}
// 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.
@ -266,11 +298,13 @@ function getExtension(id: string) {
}
function isPotentialCssRootFile(id: string) {
if (id.includes('/.vite/')) return
let extension = getExtension(id)
let isCssFile =
(extension === 'css' ||
(extension === 'vue' && id.includes('&lang.css')) ||
(extension === 'astro' && id.includes('&lang.css'))) &&
(extension === 'astro' && id.includes('&lang.css')) ||
(extension === 'svelte' && id.includes('&lang.css'))) &&
// Don't intercept special static asset resources
!SPECIAL_QUERY_RE.test(id)
@ -338,6 +372,14 @@ class Root {
// `renderStart` hook.
public lastContent: string = ''
// When set, indicates that the root was built before the Vite transform hook
// was being called. This can happen in scenarios like when preprocessing
// `<style>` tags for Svelte components.
//
// It can be set to a list of dependencies that will be added whenever the
// next `transform` hook is being called.
public builtBeforeTransform: string[] | undefined
// The lazily-initialized Tailwind compiler components. These are persisted
// throughout rebuilds but will be re-initialized if the rebuild strategy is
// set to `full`.
@ -505,3 +547,55 @@ class Root {
return shared
}
}
// Register a plugin that can hook into the Svelte preprocessor if svelte is
// enabled. This allows us to transform CSS in `<style>` tags and create a
// stricter version of CSS that passes the Svelte compiler.
//
// Note that these files will undergo a second pass through the vite transpiler
// later. This is necessary to compute `@tailwind utilities;` with the right
// candidate list.
//
// In practice, it is not recommended to use `@tailwind utilities;` inside
// Svelte components. Use an external `.css` file instead.
function svelteProcessor(roots: DefaultMap<string, Root>) {
return {
name: '@tailwindcss/svelte',
api: {
sveltePreprocess: sveltePreprocess({
aliases: [
['postcss', 'tailwindcss'],
['css', 'tailwindcss'],
],
async tailwindcss({
content,
attributes,
filename,
}: {
content: string
attributes: Record<string, string>
filename?: string
}) {
if (!filename) return
let id = filename + '?svelte&type=style&lang.css'
let root = roots.get(id)
// Mark this root as being built before the Vite transform hook is
// called. We capture all eventually added dependencies so that we can
// connect them to the vite module graph later, when the transform
// hook is called.
root.builtBeforeTransform = []
let generated = await root.generate(content, (file) =>
root?.builtBeforeTransform?.push(file),
)
if (!generated) {
roots.delete(id)
return { code: content, attributes }
}
return { code: generated, attributes }
},
}),
},
}
}

116
pnpm-lock.yaml generated
View file

@ -348,6 +348,9 @@ importers:
lightningcss:
specifier: 'catalog:'
version: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4)
svelte-preprocess:
specifier: ^6.0.2
version: 6.0.2(@babel/core@7.25.2)(postcss-load-config@6.0.1(jiti@2.3.3)(postcss@8.4.47)(tsx@4.19.1)(yaml@2.6.0))(postcss@8.4.47)(svelte@4.2.18)(typescript@5.6.3)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@ -1666,6 +1669,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@ -1700,6 +1706,10 @@ packages:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@ -2370,6 +2380,9 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
is-reference@3.0.2:
resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==}
is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'}
@ -2569,6 +2582,9 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -2595,6 +2611,9 @@ packages:
magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -2806,6 +2825,9 @@ packages:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -3190,6 +3212,47 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svelte-preprocess@6.0.2:
resolution: {integrity: sha512-OvDTLfaOkkhjprbDKO0SOCkjNYuHy16dbD4SpqbIi6QiabOMHxRT4km5/dzbFFkmW1L0E2INF3MFltG2pgOyKQ==}
engines: {node: '>= 18.0.0'}
peerDependencies:
'@babel/core': ^7.10.2
coffeescript: ^2.5.1
less: ^3.11.3 || ^4.0.0
postcss: ^7 || ^8
postcss-load-config: '>=3'
pug: ^3.0.0
sass: ^1.26.8
stylus: '>=0.55'
sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0
svelte: ^4.0.0 || ^5.0.0-next.100 || ^5.0.0
typescript: ^5.0.0
peerDependenciesMeta:
'@babel/core':
optional: true
coffeescript:
optional: true
less:
optional: true
postcss:
optional: true
postcss-load-config:
optional: true
pug:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
typescript:
optional: true
svelte@4.2.18:
resolution: {integrity: sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==}
engines: {node: '>=16'}
tailwindcss@3.4.14:
resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==}
engines: {node: '>=14.0.0'}
@ -4569,6 +4632,14 @@ snapshots:
client-only@0.0.1: {}
code-red@1.0.4:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
'@types/estree': 1.0.6
acorn: 8.13.0
estree-walker: 3.0.3
periscopic: 3.1.0
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@ -4598,6 +4669,11 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-tree@2.3.1:
dependencies:
mdn-data: 2.0.30
source-map-js: 1.2.1
cssesc@3.0.0: {}
csstype@3.1.3: {}
@ -5557,6 +5633,10 @@ snapshots:
is-path-inside@3.0.3: {}
is-reference@3.0.2:
dependencies:
'@types/estree': 1.0.6
is-regex@1.1.4:
dependencies:
call-bind: 1.0.7
@ -5726,6 +5806,8 @@ snapshots:
load-tsconfig@0.2.5: {}
locate-character@3.0.0: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@ -5752,6 +5834,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
mdn-data@2.0.30: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@ -5945,6 +6029,12 @@ snapshots:
pathval@2.0.0: {}
periscopic@3.1.0:
dependencies:
'@types/estree': 1.0.6
estree-walker: 3.0.3
is-reference: 3.0.2
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@ -6347,6 +6437,32 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-preprocess@6.0.2(@babel/core@7.25.2)(postcss-load-config@6.0.1(jiti@2.3.3)(postcss@8.4.47)(tsx@4.19.1)(yaml@2.6.0))(postcss@8.4.47)(svelte@4.2.18)(typescript@5.6.3):
dependencies:
svelte: 4.2.18
optionalDependencies:
'@babel/core': 7.25.2
postcss: 8.4.47
postcss-load-config: 6.0.1(jiti@2.3.3)(postcss@8.4.47)(tsx@4.19.1)(yaml@2.6.0)
typescript: 5.6.3
svelte@4.2.18:
dependencies:
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@types/estree': 1.0.6
acorn: 8.13.0
aria-query: 5.3.2
axobject-query: 4.1.0
code-red: 1.0.4
css-tree: 2.3.1
estree-walker: 3.0.3
is-reference: 3.0.2
locate-character: 3.0.0
magic-string: 0.30.11
periscopic: 3.1.0
tailwindcss@3.4.14:
dependencies:
'@alloc/quick-lru': 5.2.0