import { compile, env, normalizePath } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { Features, transform } from 'lightningcss'
import fs from 'node:fs/promises'
import path from 'path'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
const SPECIAL_QUERY_RE = /[?&](raw|url)\b/
export default function tailwindcss(): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
let isSSR = false
let minify = false
let cssPlugins: readonly Plugin[] = []
let moduleGraphCandidates = new DefaultMap<string, Set<string>>(() => new Set<string>())
let moduleGraphScanner = new Scanner({})
let roots: DefaultMap<string, Root> = new DefaultMap(
(id) => new Root(id, () => moduleGraphCandidates, config!.base),
)
function scanFile(id: string, content: string, extension: string, isSSR: boolean) {
let updated = false
for (let candidate of moduleGraphScanner.scanFiles([{ content, extension }])) {
updated = true
moduleGraphCandidates.get(id).add(candidate)
}
if (updated) {
invalidateAllRoots(isSSR)
}
}
function invalidateAllRoots(isSSR: boolean) {
for (let server of servers) {
let updates: Update[] = []
for (let id of roots.keys()) {
let module = server.moduleGraph.getModuleById(id)
if (!module) {
if (!isSSR) {
roots.delete(id)
}
continue
}
roots.get(id).requiresRebuild = false
server.moduleGraph.invalidateModule(module)
updates.push({
type: `${module.type}-update`,
path: module.url,
acceptedPath: module.url,
timestamp: Date.now(),
})
}
if (updates.length > 0) {
server.hot.send({ type: 'update', updates })
}
}
}
async function regenerateOptimizedCss(root: Root, addWatchFile: (file: string) => void) {
let content = root.lastContent
let generated = await root.generate(content, addWatchFile)
if (generated === false) {
return
}
env.DEBUG && console.time('[@tailwindcss/vite] Optimize CSS')
let result = optimizeCss(generated, { minify })
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Optimize CSS')
return result
}
async function transformWithPlugins(context: Rollup.PluginContext, id: string, css: string) {
let transformPluginContext = {
...context,
getCombinedSourcemap: () => {
throw new Error('getCombinedSourcemap not implemented')
},
}
for (let plugin of cssPlugins) {
if (!plugin.transform) continue
let transformHandler =
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
try {
let result = await transformHandler.call(transformPluginContext, css, id)
if (!result) continue
if (typeof result === 'string') {
css = result
} else if (result.code) {
css = result.code
}
} catch (e) {
console.error(`Error running ${plugin.name} on Tailwind CSS output. Skipping.`)
}
}
return css
}
return [
{
name: '@tailwindcss/vite:scan',
enforce: 'pre',
configureServer(server) {
servers.push(server)
},
async configResolved(_config) {
config = _config
minify = config.build.cssMinify !== false
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
let allowedPlugins = [
'vite:css',
...(config.command === 'build' ? ['vite:css-post'] : []),
]
cssPlugins = config.plugins.filter((plugin) => {
return allowedPlugins.includes(plugin.name)
})
},
transformIndexHtml(html, { path }) {
scanFile(path, html, 'html', isSSR)
},
transform(src, id, options) {
let extension = getExtension(id)
if (isPotentialCssRootFile(id)) return
scanFile(id, src, extension, options?.ssr ?? false)
},
},
{
name: '@tailwindcss/vite:generate:serve',
apply: 'serve',
enforce: 'pre',
async transform(src, id, options) {
if (!isPotentialCssRootFile(id)) return
let root = roots.get(id)
if (!options?.ssr) {
await Promise.all(servers.map((server) => server.waitForRequestsIdle(id)))
}
let generated = await root.generate(src, (file) => this.addWatchFile(file))
if (!generated) {
roots.delete(id)
return src
}
return { code: generated }
},
},
{
name: '@tailwindcss/vite:generate:build',
apply: 'build',
enforce: 'pre',
async transform(src, id) {
if (!isPotentialCssRootFile(id)) return
let root = roots.get(id)
let generated = await root.generate(src, (file) => this.addWatchFile(file))
if (!generated) {
roots.delete(id)
return src
}
return { code: generated }
},
async renderStart() {
for (let [id, root] of roots.entries()) {
let generated = await regenerateOptimizedCss(
root,
() => {},
)
if (!generated) {
roots.delete(id)
continue
}
await transformWithPlugins(this, id, generated)
}
},
},
] satisfies Plugin[]
}
function getExtension(id: string) {
let [filename] = id.split('?', 2)
return path.extname(filename).slice(1)
}
function isPotentialCssRootFile(id: string) {
let extension = getExtension(id)
let isCssFile =
(extension === 'css' ||
(extension === 'vue' && id.includes('&lang.css')) ||
(extension === 'astro' && id.includes('&lang.css'))) &&
!SPECIAL_QUERY_RE.test(id)
return isCssFile
}
function optimizeCss(
input: string,
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
) {
return transform({
filename: file,
code: Buffer.from(input),
minify,
sourceMap: false,
drafts: {
customMedia: true,
},
nonStandard: {
deepSelectorCombinator: true,
},
include: Features.Nesting,
exclude: Features.LogicalProperties,
targets: {
safari: (16 << 16) | (4 << 8),
},
errorRecovery: true,
}).code.toString()
}
function idToPath(id: string) {
return path.resolve(id.replace(/\?.*$/, ''))
}
class DefaultMap<K, V> extends Map<K, V> {
constructor(private factory: (key: K, self: DefaultMap<K, V>) => V) {
super()
}
get(key: K): V {
let value = super.get(key)
if (value === undefined) {
value = this.factory(key, this)
this.set(key, value)
}
return value
}
}
class Root {
public lastContent: string = ''
private compiler?: Awaited<ReturnType<typeof compile>>
public requiresRebuild: boolean = true
private scanner?: Scanner
private candidates: Set<string> = new Set<string>()
private dependencies = new Set<string>()
private basePath: string | null = null
constructor(
private id: string,
private getSharedCandidates: () => Map<string, Set<string>>,
private base: string,
) {}
public async generate(
content: string,
addWatchFile: (file: string) => void,
): Promise<string | false> {
this.lastContent = content
let inputPath = idToPath(this.id)
let inputBase = path.dirname(path.resolve(inputPath))
if (!this.compiler || !this.scanner || this.requiresRebuild) {
clearRequireCache(Array.from(this.dependencies))
this.dependencies = new Set([idToPath(inputPath)])
env.DEBUG && console.time('[@tailwindcss/vite] Setup compiler')
this.compiler = await compile(content, {
base: inputBase,
onDependency: (path) => {
addWatchFile(path)
this.dependencies.add(path)
},
})
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Setup compiler')
let sources = (() => {
if (this.compiler.root === 'none') {
return []
}
if (this.compiler.root === null) {
return []
}
return [this.compiler.root]
})().concat(this.compiler.globs)
this.scanner = new Scanner({ sources })
}
env.DEBUG && console.time('[@tailwindcss/vite] Scan for candidates')
for (let candidate of this.scanner.scan()) {
this.candidates.add(candidate)
}
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Scan for candidates')
for (let file of this.scanner.files) {
addWatchFile(file)
}
for (let glob of this.scanner.globs) {
if (glob.pattern[0] === '!') continue
let relative = path.relative(this.base, glob.base)
if (relative[0] !== '.') {
relative = './' + relative
}
relative = normalizePath(relative)
addWatchFile(path.posix.join(relative, glob.pattern))
let root = this.compiler.root
if (root !== 'none' && root !== null) {
let basePath = normalizePath(path.resolve(root.base, root.pattern))
let isDir = await fs.stat(basePath).then(
(stats) => stats.isDirectory(),
() => false,
)
if (!isDir) {
throw new Error(
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
)
}
this.basePath = basePath
} else if (root === null) {
this.basePath = null
}
}
this.requiresRebuild = true
env.DEBUG && console.time('[@tailwindcss/vite] Build CSS')
let result = this.compiler.build([...this.sharedCandidates(), ...this.candidates])
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Build CSS')
return result
}
private sharedCandidates(): Set<string> {
if (!this.compiler) return new Set()
if (this.compiler.root === 'none') return new Set()
const HAS_DRIVE_LETTER = /^[A-Z]:/
let shouldIncludeCandidatesFrom = (id: string) => {
if (this.basePath === null) return true
if (id.startsWith(this.basePath)) return true
if (HAS_DRIVE_LETTER.test(id)) return false
if (!id.startsWith('/')) return true
return false
}
let shared = new Set<string>()
for (let [id, candidates] of this.getSharedCandidates()) {
if (!shouldIncludeCandidatesFrom(id)) continue
for (let candidate of candidates) {
shared.add(candidate)
}
}
return shared
}
}