import { compile } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fixRelativePathsPlugin, { normalizePath } from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import fs from 'node:fs/promises'
import path from 'path'
import postcss from 'postcss'
import postcssImport from 'postcss-import'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
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 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.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
}
return optimizeCss(generated, { minify })
}
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'))
return isCssFile
}
function isCssRootFile(content: string) {
return (
content.includes('@tailwind') ||
content.includes('@config') ||
content.includes('@plugin') ||
content.includes('@apply') ||
content.includes('@theme') ||
content.includes('@variant') ||
content.includes('@utility')
)
}
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>()
constructor(
private id: string,
private getSharedCandidates: () => 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)])
let postcssCompiled = await postcss([
postcssImport({
load: (path) => {
this.dependencies.add(path)
addWatchFile(path)
return fs.readFile(path, 'utf8')
},
}),
fixRelativePathsPlugin(),
]).process(content, {
from: inputPath,
to: inputPath,
})
let css = postcssCompiled.css
if (!isCssRootFile(css)) {
return false
}
this.compiler = await compile(css, {
base: inputBase,
onDependency: (path) => {
addWatchFile(path)
this.dependencies.add(path)
},
})
this.scanner = new Scanner({
sources: this.compiler.globs.map(({ origin, pattern }) => ({
base: origin ? path.dirname(path.resolve(inputBase, origin)) : inputBase,
pattern,
})),
})
}
for (let candidate of this.scanner.scan()) {
this.candidates.add(candidate)
}
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))
}
this.requiresRebuild = true
return this.compiler.build([...this.getSharedCandidates(), ...this.candidates])
}
}