Skip to content

Commit

Permalink
Vite: Support Tailwind in Svelte <style> blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
philipp-spiess committed Sep 4, 2024
1 parent 8359228 commit 8dbfb9b
Show file tree
Hide file tree
Showing 4 changed files with 413 additions and 6 deletions.
177 changes: 177 additions & 0 deletions integrations/vite/svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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.debug(
'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)

Check failure on line 170 in integrations/vite/svelte.test.ts

View workflow job for this annotation

GitHub Actions / build (20, windows-latest)

vite/svelte.test.ts > watch mode

AssertionError: expected [] to have a length of 1 but got +0 - Expected + Received - 1 + 0 ❯ vite/svelte.test.ts:170:21 ❯ Module.retryAssertion utils.ts:526:14 ❯ vite/svelte.test.ts:168:5 ❯ utils.ts:351:14
let [, css] = files[0]
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`bar`)
expect(css).not.toContain(candidate`foo`)
})
},
)
1 change: 1 addition & 0 deletions packages/@tailwindcss-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"lightningcss": "catalog:",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"svelte-preprocess": "^6.0.2",
"tailwindcss": "workspace:^"
},
"devDependencies": {
Expand Down
106 changes: 100 additions & 6 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fs from 'node:fs/promises'
import path from 'path'
import postcss from 'postcss'
import postcssImport from 'postcss-import'
import { sveltePreprocess } from 'svelte-preprocess'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'

export default function tailwindcss(): Plugin[] {
Expand Down Expand Up @@ -62,13 +63,17 @@ export default function tailwindcss(): Plugin[] {
if (!server) return

let updates: Update[] = []
for (let id of roots.keys()) {
for (let [id, root] of roots.entries()) {
let module = server.moduleGraph.getModuleById(id)
if (!module) {
// 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.
// The module for this root might not exist yet
if (root.builtBeforeTransform) {
return
}
if (!isSSR) {
// 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.
// It is safe to remove the item here since we're iterating on a copy
// of the keys.
roots.delete(id)
Expand Down Expand Up @@ -134,6 +139,7 @@ export default function tailwindcss(): Plugin[] {
}

return [
svelteProcessor(roots),
{
// Step 1: Scan source files for candidates
name: '@tailwindcss/vite:scan',
Expand Down Expand Up @@ -185,6 +191,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
Expand Down Expand Up @@ -212,11 +231,22 @@ export default function tailwindcss(): Plugin[] {
enforce: 'pre',

async transform(src, id) {
// TODO: Check if this is also triggered by invalidateModule
if (!isPotentialCssRootFile(id)) return

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.
Expand Down Expand Up @@ -263,8 +293,12 @@ 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'))
let isCssFile =
extension === 'css' ||
(extension === 'vue' && id.includes('&lang.css')) ||
(extension === 'svelte' && id.includes('&lang.css'))
return isCssFile
}

Expand Down Expand Up @@ -335,6 +369,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`.
Expand Down Expand Up @@ -450,3 +492,55 @@ class Root {
return this.compiler.build([...this.getSharedCandidates(), ...this.candidates])
}
}

// 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 }
},
}),
},
}
}
Loading

0 comments on commit 8dbfb9b

Please sign in to comment.