import { compile, env, Features, normalizePath } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { Features as LightningCssFeatures, 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/
export default function tailwindcss(): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
let isSSR = false
let minify = false
let moduleGraphCandidates = new DefaultMap<string, Set<string>>(() => new Set<string>())
let moduleGraphScanner = new Scanner({})
let roots: DefaultMap<string, Root> = new DefaultMap((id) => {
let cssResolver = config!.createResolver({
...config!.resolve,
extensions: ['.css'],
mainFields: ['style'],
conditions: ['style', 'development|production'],
tryIndex: false,
preferRelative: true,
})
function customCssResolver(id: string, base: string) {
return cssResolver(id, base, false, isSSR)
}
let jsResolver = config!.createResolver(config!.resolve)
function customJsResolver(id: string, base: string) {
return jsResolver(id, base, true, isSSR)
}
return new Root(
id,
() => moduleGraphCandidates,
config!.base,
customCssResolver,
customJsResolver,
)
})
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, root] of roots.entries()) {
let module = server.moduleGraph.getModuleById(id)
if (!module) {
if (root.builtBeforeTransform) {
continue
}
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 config!.plugins) {
if (!plugin.transform) continue
if (plugin.name.startsWith('@tailwindcss/')) {
continue
} else if (
plugin.name.startsWith('vite:') &&
plugin.name !== 'vite:css' &&
plugin.name !== 'vite:css-post' &&
plugin.name !== 'vite:vue'
) {
continue
} else if (plugin.name === 'ssr-styles') {
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 [
svelteProcessor(roots),
{
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
},
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 (root.builtBeforeTransform) {
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
root.builtBeforeTransform = undefined
}
if (isSvelteStyle(id)) {
return src
}
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)
if (root.builtBeforeTransform) {
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
root.builtBeforeTransform = undefined
}
if (isSvelteStyle(id)) {
return src
}
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()) {
if (isSvelteStyle(id)) continue
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) {
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')) ||
isSvelteStyle(id)) &&
!SPECIAL_QUERY_RE.test(id)
return isCssFile
}
function isSvelteStyle(id: string) {
let extension = getExtension(id)
return extension === 'svelte' && id.includes('&lang.css')
}
function optimizeCss(
input: string,
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
) {
function optimize(code: Buffer | Uint8Array) {
return transform({
filename: file,
code,
minify,
sourceMap: false,
drafts: {
customMedia: true,
},
nonStandard: {
deepSelectorCombinator: true,
},
include: LightningCssFeatures.Nesting,
exclude: LightningCssFeatures.LogicalProperties,
targets: {
safari: (16 << 16) | (4 << 8),
ios_saf: (16 << 16) | (4 << 8),
firefox: 128 << 16,
chrome: 120 << 16,
},
errorRecovery: true,
}).code
}
return optimize(optimize(Buffer.from(input))).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 = ''
public builtBeforeTransform: string[] | undefined
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
public overwriteCandidates: string[] | null = null
constructor(
private id: string,
private getSharedCandidates: () => Map<string, Set<string>>,
private base: string,
private customCssResolver: (id: string, base: string) => Promise<string | false | undefined>,
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
) {}
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,
shouldRewriteUrls: true,
onDependency: (path) => {
addWatchFile(path)
this.dependencies.add(path)
},
customCssResolver: this.customCssResolver,
customJsResolver: this.customJsResolver,
})
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 })
}
if (
!(
this.compiler.features &
(Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities)
)
) {
return false
}
if (!this.overwriteCandidates || this.compiler.features & Features.Utilities) {
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')
}
if (this.compiler.features & Features.Utilities) {
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.overwriteCandidates
? this.overwriteCandidates
: [...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
}
}
function svelteProcessor(roots: DefaultMap<string, Root>) {
let preprocessor = sveltePreprocess()
return {
name: '@tailwindcss/svelte',
api: {
sveltePreprocess: {
markup: preprocessor.markup,
script: preprocessor.script,
async style({
content,
filename,
markup,
...rest
}: {
content: string
filename?: string
attributes: Record<string, string | boolean>
markup: string
}) {
if (!filename) return preprocessor.style?.({ ...rest, content, filename, markup })
let id = filename + '?svelte&type=style&lang.css'
let root = roots.get(id)
root.requiresRebuild = true
root.builtBeforeTransform = []
let scanner = new Scanner({})
root.overwriteCandidates = scanner.scanFiles([
{ content: markup, file: filename, extension: 'svelte' },
])
let generated = await root.generate(content, (file) =>
root?.builtBeforeTransform?.push(file),
)
if (!generated) {
roots.delete(id)
return preprocessor.style?.({ ...rest, content, filename, markup })
}
return preprocessor.style?.({ ...rest, content: generated, filename, markup })
},
},
},
}
}