import watcher from '@parcel/watcher'
import {
compile,
env,
Instrumentation,
optimize,
toSourceMap,
type SourceMap,
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner, type ChangedContent } from '@tailwindcss/oxide'
import { existsSync, type Stats } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Arg, Result } from '../../utils/args'
import { Disposables } from '../../utils/disposables'
import {
eprintln,
formatDuration,
header,
highlight,
println,
relative,
} from '../../utils/renderer'
import { drainStdin, outputFile } from './utils'
const css = String.raw
const DEBUG = env.DEBUG
const DEFAULT_POLL_INTERVAL_MS = 250
export function options() {
return {
'--input': {
type: 'string',
description: 'Input file',
alias: '-i',
},
'--output': {
type: 'string',
description: 'Output file',
alias: '-o',
default: '-',
},
'--watch': {
type: 'boolean | string',
description:
'Watch for changes and rebuild as needed, and use `always` to keep watching when stdin is closed',
alias: '-w',
values: ['always'],
},
'--poll': {
type: 'boolean | number',
description: 'Use polling instead of filesystem events when watching',
default: false,
values: ['ms'],
},
'--minify': {
type: 'boolean',
description: 'Optimize and minify the output',
alias: '-m',
},
'--optimize': {
type: 'boolean',
description: 'Optimize the output without minifying',
},
'--cwd': {
type: 'string',
description: 'The current working directory',
default: '.',
},
'--map': {
type: 'boolean | string',
description: 'Generate a source map',
default: false,
},
'--silent': {
type: 'boolean',
description: 'Suppress non-error output',
},
} satisfies Arg
}
async function handleError<T>(fn: () => T): Promise<T> {
try {
return await fn()
} catch (err) {
eprintln(
[red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n'),
)
process.exit(1)
}
}
export async function handle(args: Result<ReturnType<typeof options>>) {
if (!args['--silent']) eprintln(header())
if (!args['--silent']) eprintln()
using I = new Instrumentation()
DEBUG && I.start('[@tailwindcss/cli] (initial build)')
let base = path.resolve(args['--cwd'])
if (args['--output'] && args['--output'] !== '-') {
args['--output'] = path.resolve(base, args['--output'])
}
if (args['--input'] && args['--input'] !== '-') {
args['--input'] = path.resolve(base, args['--input'])
if (!existsSync(args['--input'])) {
eprintln(`Specified input file ${highlight(relative(args['--input']))} does not exist.`)
process.exit(1)
}
}
if (args['--input'] === args['--output'] && args['--input'] !== '-') {
eprintln(
`Specified input file ${highlight(relative(args['--input']))} and output file ${highlight(relative(args['--output']))} are identical.`,
)
process.exit(1)
}
if (args['--map'] === '-') {
eprintln(`Use --map without a value to inline the source map.`)
process.exit(1)
}
if (args['--poll'] === undefined) {
eprintln(`Use --poll with a non-zero value in milliseconds.`)
process.exit(1)
}
let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll']
if (pollInterval !== false && pollInterval <= 0) {
eprintln(`Specified polling interval must be a positive number.`)
process.exit(1)
}
if (args['--map'] && args['--map'] !== true) {
args['--map'] = path.resolve(base, args['--map'])
}
let start = process.hrtime.bigint()
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
let previous = {
css: '',
optimizedCss: '',
output: null as string | null,
}
async function write(
css: string,
map: SourceMap | null,
args: Result<ReturnType<typeof options>>,
I: Instrumentation,
) {
let output = css
if (args['--minify'] || args['--optimize']) {
if (css !== previous.css) {
DEBUG && I.start('Optimize CSS')
let optimized = optimize(css, {
file: args['--input'] ?? 'input.css',
minify: args['--minify'] ?? false,
map: map?.raw ?? undefined,
})
DEBUG && I.end('Optimize CSS')
previous.css = css
previous.optimizedCss = optimized.code
if (optimized.map) {
map = toSourceMap(optimized.map)
}
output = optimized.code
} else {
output = previous.optimizedCss
}
}
if (map) {
if (args['--map'] === true) {
output += `\n`
output += map.inline
} else if (typeof args['--map'] === 'string') {
let basePath =
args['--output'] && args['--output'] !== '-'
? path.dirname(path.resolve(args['--output']))
: process.cwd()
let mapPath = path.resolve(args['--map'])
let relativePath = path.relative(basePath, mapPath)
output += `\n`
output += map.comment(relativePath)
DEBUG && I.start('Write source map')
await outputFile(args['--map'], map.raw)
DEBUG && I.end('Write source map')
}
}
let didChange = output !== previous.output
DEBUG && I.start('Write output')
if (args['--output'] && args['--output'] !== '-') {
await outputFile(args['--output'], output)
} else if (didChange) {
println(output)
}
DEBUG && I.end('Write output')
previous.output = output
return didChange
}
let inputFilePath =
args['--input'] && args['--input'] !== '-' ? path.resolve(args['--input']) : null
let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd()
let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : []
let backupRebuildPaths = fullRebuildPaths
async function createCompiler(css: string, I: Instrumentation) {
DEBUG && I.start('Setup compiler')
let compiler = await compile(css, {
from: args['--output'] ? (inputFilePath ?? 'stdin.css') : undefined,
base: inputBasePath,
onDependency(path) {
fullRebuildPaths.push(path)
},
})
let sources = (() => {
if (compiler.root === 'none') {
return []
}
if (compiler.root === null) {
return [{ base, pattern: '**/*', negated: false }]
}
return [{ ...compiler.root, negated: false }]
})().concat(compiler.sources)
sources.push({
base: path.dirname(process.execPath),
pattern: path.basename(process.execPath),
negated: true,
})
if (inputFilePath !== null) {
sources.push({
base: path.dirname(inputFilePath),
pattern: path.basename(inputFilePath),
negated: false,
})
}
let scanner = new Scanner({ sources })
DEBUG && I.end('Setup compiler')
return [compiler, scanner] as const
}
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
let cleanupWatchers: (() => Promise<void>)[] = []
if (args['--watch'] && pollInterval === false) {
cleanupWatchers.push(
await createWatchers(await watchDirectories(scanner), async function handle(files) {
try {
if (files.length === 1 && files[0] === args['--output']) return
using I = new Instrumentation()
DEBUG && I.start('[@tailwindcss/cli] (watcher)')
let start = process.hrtime.bigint()
let resolvedFullRebuildPaths = fullRebuildPaths
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
let compiledCss = ''
let compiledMap: SourceMap | null = null
if (rebuildStrategy.kind === 'full') {
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
clearRequireCache(resolvedFullRebuildPaths)
backupRebuildPaths = fullRebuildPaths.slice()
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
;[compiler, scanner] = await createCompiler(input, I)
backupRebuildPaths = fullRebuildPaths.slice()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Setup new watchers')
let newCleanupFunction = await createWatchers(await watchDirectories(scanner), handle)
DEBUG && I.end('Setup new watchers')
DEBUG && I.start('Cleanup old watchers')
await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup()))
DEBUG && I.end('Cleanup old watchers')
cleanupWatchers.push(newCleanupFunction)
DEBUG && I.start('Build CSS')
compiledCss = compiler.build(candidates)
DEBUG && I.end('Build CSS')
if (args['--map']) {
DEBUG && I.start('Build Source Map')
compiledMap = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
}
else if (rebuildStrategy.kind === 'incremental') {
DEBUG && I.start('Scan for candidates')
let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles)
DEBUG && I.end('Scan for candidates')
if (newCandidates.length <= 0) {
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
return
}
DEBUG && I.start('Build CSS')
compiledCss = compiler.build(newCandidates)
DEBUG && I.end('Build CSS')
if (args['--map']) {
DEBUG && I.start('Build Source Map')
compiledMap = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
}
await write(compiledCss, compiledMap, args, I)
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
} catch (err) {
fullRebuildPaths = backupRebuildPaths
eprintln(
[red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n'),
)
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
}
}),
)
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
Promise.all(cleanupWatchers.map((fn) => fn())).then(
() => process.exit(0),
() => process.exit(1),
)
})
}
process.stdin.resume()
}
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Build CSS')
let output = await handleError(() => compiler.build(candidates))
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = await handleError(() => toSourceMap(compiler.buildSourceMap()))
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
if (args['--watch'] && pollInterval !== false) {
let cleanupPollingIndicator = () => {}
function logPollingMessage(message: string) {
if (!args['--silent']) {
process.stderr.write('\r\x1B[2K')
}
eprintln(message)
if (!args['--silent']) {
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
}
}
async function fullRebuild(I: Instrumentation) {
clearRequireCache(fullRebuildPaths)
backupRebuildPaths = fullRebuildPaths.slice()
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
;[compiler, scanner] = await createCompiler(input, I)
backupRebuildPaths = fullRebuildPaths.slice()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Build CSS')
let output = compiler.build(candidates)
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
}
if (!args['--silent']) {
let restored = false
function restoreCursor() {
if (restored) return
restored = true
process.stderr.write('\r\x1B[2K\x1B[?25h')
process.off('exit', restoreCursor)
process.off('SIGINT', onSigint)
process.off('SIGTERM', onSigterm)
}
function onSigint() {
restoreCursor()
process.exit(130)
}
function onSigterm() {
restoreCursor()
process.exit(143)
}
process.stderr.write('\x1B[?25l')
process.on('exit', restoreCursor)
process.on('SIGINT', onSigint)
process.on('SIGTERM', onSigterm)
cleanupPollingIndicator = restoreCursor
cleanupWatchers.push(async () => cleanupPollingIndicator())
}
cleanupWatchers.push(
createPollingWatcher(async () => {
using I = new Instrumentation()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
let files = scanner.scannedFiles.filter(
(file) => file !== args['--output'] && file !== args['--map'],
)
if (files.length <= 0) return
let start = process.hrtime.bigint()
let strategy = getRebuildStrategy(files, fullRebuildPaths)
if (strategy.kind === 'full') {
try {
await fullRebuild(I)
} catch (err) {
fullRebuildPaths = backupRebuildPaths
let message = [red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n')
logPollingMessage(message)
}
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
return
}
if (candidates.length <= 0) {
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
return
}
DEBUG && I.start('Build CSS')
let output = compiler.build(candidates)
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
}, pollInterval),
)
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
Promise.all(cleanupWatchers.map((fn) => fn())).then(
() => process.exit(0),
() => process.exit(1),
)
})
}
process.stdin.resume()
if (!args['--silent']) {
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
}
}
}
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
dirs = dirs.sort((a, z) => a.length - z.length)
let toRemove = []
for (let i = 0; i < dirs.length; ++i) {
for (let j = 0; j < i; ++j) {
if (!dirs[i].startsWith(`${dirs[j]}/`)) continue
toRemove.push(dirs[i])
}
}
dirs = dirs.filter((dir) => !toRemove.includes(dir))
let watchers = new Disposables()
let files = new Set<string>()
let debounceQueue = new Disposables()
async function enqueueCallback() {
await debounceQueue.dispose()
debounceQueue.queueMacrotask(() => {
cb(Array.from(files))
files.clear()
})
}
for (let dir of dirs) {
let { unsubscribe } = await watcher.subscribe(dir, async (err, events) => {
if (err) {
console.error(err)
return
}
await Promise.all(
events.map(async (event) => {
if (event.type === 'delete') {
files.add(event.path)
return
}
let stats: Stats | null = null
try {
stats = await fs.lstat(event.path)
} catch {}
if (!stats?.isFile() && !stats?.isSymbolicLink()) {
return
}
files.add(event.path)
}),
)
await enqueueCallback()
})
watchers.add(unsubscribe)
}
return async () => {
await watchers.dispose()
await debounceQueue.dispose()
}
}
function getRebuildStrategy(
files: string[],
fullRebuildPaths: string[],
): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } {
let changedFiles: ChangedContent[] = []
for (let file of files) {
if (fullRebuildPaths.includes(file)) {
return { kind: 'full' }
}
changedFiles.push({
file,
extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
return { kind: 'incremental', changedFiles }
}
function createPollingWatcher(cb: () => Promise<void>, pollInterval: number) {
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
async function poll() {
if (disposed) return
try {
await cb()
} catch (err) {
console.error(err)
} finally {
if (!disposed) {
timer = setTimeout(poll, pollInterval)
}
}
}
timer = setTimeout(poll, pollInterval)
return async () => {
disposed = true
if (timer) clearTimeout(timer)
}
}
async function watchDirectories(scanner: Scanner) {
let directories = (
await Promise.all(
scanner.normalizedSources.map(async (globEntry) => {
let resolvedPath = path.resolve(globEntry.base)
let realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath)
return fs
.stat(realPath)
.then((stat) => (stat.isDirectory() ? [realPath] : []))
.catch(() => [])
}),
)
).flat(1)
return Array.from(new Set(directories))
}
function dim(str: string) {
return `\x1B[2m${str}\x1B[22m`
}
function red(str: string) {
return `\x1B[31m${str}\x1B[39m`
}