import QuickLRU from '@alloc/quick-lru'
import { compile, env, Features } 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'
import path from 'node:path'
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
import fixRelativePathsPlugin from './postcss-fix-relative-paths'
interface CacheEntry {
mtimes: Map<string, number>
compiler: null | Awaited<ReturnType<typeof compile>>
scanner: null | Scanner
css: string
optimizedCss: string
fullRebuildPaths: string[]
}
let cache = new QuickLRU<string, CacheEntry>({ maxSize: 50 })
function getContextFromCache(inputFile: string, opts: PluginOptions): CacheEntry {
let key = `${inputFile}:${opts.base ?? ''}:${opts.optimize ?? ''}`
if (cache.has(key)) return cache.get(key)!
let entry = {
mtimes: new Map<string, number>(),
compiler: null,
scanner: null,
css: '',
optimizedCss: '',
fullRebuildPaths: [] as string[],
}
cache.set(key, entry)
return entry
}
export type PluginOptions = {
base?: string
optimize?: boolean | { minify?: boolean }
}
function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let base = opts.base ?? process.cwd()
let optimize = opts.optimize ?? process.env.NODE_ENV === 'production'
return {
postcssPlugin: '@tailwindcss/postcss',
plugins: [
fixRelativePathsPlugin(),
{
postcssPlugin: 'tailwindcss',
async OnceExit(root, { result }) {
env.DEBUG && console.time('[@tailwindcss/postcss] Total time in @tailwindcss/postcss')
let inputFile = result.opts.from ?? ''
let context = getContextFromCache(inputFile, opts)
let inputBasePath = path.dirname(path.resolve(inputFile))
async function createCompiler() {
env.DEBUG && console.time('[@tailwindcss/postcss] Setup compiler')
if (context.fullRebuildPaths.length > 0 && !isInitialBuild) {
clearRequireCache(context.fullRebuildPaths)
}
context.fullRebuildPaths = []
let compiler = await compile(root.toString(), {
base: inputBasePath,
onDependency: (path) => {
context.fullRebuildPaths.push(path)
},
})
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Setup compiler')
return compiler
}
let isInitialBuild = context.compiler === null
context.compiler ??= await createCompiler()
if (context.compiler.features === Features.None) {
return
}
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
{
for (let file of context.fullRebuildPaths) {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file,
parent: result.opts.from,
})
}
let files = result.messages.flatMap((message) => {
if (message.type !== 'dependency') return []
return message.file
})
files.push(inputFile)
for (let file of files) {
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
if (changedTime === null) {
if (file === inputFile) {
rebuildStrategy = 'full'
}
continue
}
let prevTime = context.mtimes.get(file)
if (prevTime === changedTime) continue
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
}
let css = ''
if (
rebuildStrategy === 'full' &&
!isInitialBuild
) {
context.compiler = await createCompiler()
}
if (context.scanner === null || rebuildStrategy === 'full') {
let sources = (() => {
if (context.compiler.root === 'none') {
return []
}
if (context.compiler.root === null) {
return [{ base, pattern: '**/*' }]
}
return [context.compiler.root]
})().concat(context.compiler.globs)
context.scanner = new Scanner({ sources })
}
env.DEBUG && console.time('[@tailwindcss/postcss] Scan for candidates')
let candidates =
context.compiler.features & Features.Utilities ? context.scanner.scan() : []
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Scan for candidates')
if (context.compiler.features & Features.Utilities) {
for (let file of context.scanner.files) {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file,
parent: result.opts.from,
})
}
for (let { base: globBase, pattern } of context.scanner.globs) {
if (pattern === '*' && base === globBase) {
continue
}
if (pattern === '') {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file: globBase,
parent: result.opts.from,
})
} else {
result.messages.push({
type: 'dir-dependency',
plugin: '@tailwindcss/postcss',
dir: globBase,
glob: pattern,
parent: result.opts.from,
})
}
}
}
env.DEBUG && console.time('[@tailwindcss/postcss] Build CSS')
css = context.compiler.build(candidates)
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Build CSS')
if (css !== context.css && optimize) {
env.DEBUG && console.time('[@tailwindcss/postcss] Optimize CSS')
context.optimizedCss = optimizeCss(css, {
minify: typeof optimize === 'object' ? optimize.minify : true,
})
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Optimize CSS')
}
context.css = css
env.DEBUG && console.time('[@tailwindcss/postcss] Update PostCSS AST')
root.removeAll()
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Update PostCSS AST')
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Total time in @tailwindcss/postcss')
},
},
],
}
}
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()
}
export default Object.assign(tailwindcss, { postcss: true }) as PluginCreator<PluginOptions>