import watcher from '@parcel/watcher'
import { compile } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner, type ChangedContent } from '@tailwindcss/oxide'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import { existsSync, readFileSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import atImport from 'postcss-import'
import type { Arg, Result } from '../../utils/args'
import { Disposables } from '../../utils/disposables'
import {
eprintln,
formatDuration,
header,
highlight,
println,
relative,
} from '../../utils/renderer'
import { resolveCssId } from '../../utils/resolve'
import { drainStdin, outputFile } from './utils'
const css = String.raw
export function options() {
return {
'--input': {
type: 'string',
description: 'Input file',
alias: '-i',
},
'--output': {
type: 'string',
description: 'Output file',
alias: '-o',
},
'--watch': {
type: 'boolean | string',
description: 'Watch for changes and rebuild as needed',
alias: '-w',
},
'--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: '.',
},
} satisfies Arg
}
export async function handle(args: Result<ReturnType<typeof options>>) {
let base = path.resolve(args['--cwd'])
if (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(header())
eprintln()
eprintln(`Specified input file ${highlight(relative(args['--input']))} does not exist.`)
process.exit(1)
}
}
let start = process.hrtime.bigint()
let [input, cssImportPaths] = await handleImports(
args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`,
args['--input'] ?? base,
)
let previous = {
css: '',
optimizedCss: '',
}
async function write(css: string, args: Result<ReturnType<typeof options>>) {
let output = css
if (args['--minify'] || args['--optimize']) {
if (css !== previous.css) {
let optimizedCss = optimizeCss(css, {
file: args['--input'] ?? 'input.css',
minify: args['--minify'] ?? false,
})
previous.css = css
previous.optimizedCss = optimizedCss
output = optimizedCss
} else {
output = previous.optimizedCss
}
}
if (args['--output']) {
await outputFile(args['--output'], output)
} else {
println(output)
}
}
let inputFile = args['--input'] && args['--input'] !== '-' ? args['--input'] : process.cwd()
let inputBasePath = path.dirname(path.resolve(inputFile))
let fullRebuildPaths: string[] = cssImportPaths.slice()
function createCompiler(css: string) {
return compile(css, {
base: inputBasePath,
onDependency(path) {
fullRebuildPaths.push(path)
},
})
}
let compiler = await createCompiler(input)
let scanner = new Scanner({
detectSources: { base },
sources: compiler.globs.map(({ origin, pattern }) => ({
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
pattern,
})),
})
if (args['--watch']) {
let cleanupWatchers = await createWatchers(
watchDirectories(base, scanner),
async function handle(files) {
try {
if (files.length === 1 && files[0] === args['--output']) return
let changedFiles: ChangedContent[] = []
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
let resolvedFullRebuildPaths = fullRebuildPaths
for (let file of files) {
if (resolvedFullRebuildPaths.includes(file)) {
rebuildStrategy = 'full'
break
}
changedFiles.push({
file,
extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
let start = process.hrtime.bigint()
let compiledCss = ''
if (rebuildStrategy === 'full') {
cleanupWatchers()
;[input, cssImportPaths] = await handleImports(
args['--input']
? await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`,
args['--input'] ?? base,
)
clearRequireCache(resolvedFullRebuildPaths)
fullRebuildPaths = cssImportPaths.slice()
compiler = await createCompiler(input)
scanner = new Scanner({
detectSources: { base },
sources: compiler.globs.map(({ origin, pattern }) => ({
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
pattern,
})),
})
let candidates = scanner.scan()
cleanupWatchers = await createWatchers(watchDirectories(base, scanner), handle)
compiledCss = compiler.build(candidates)
}
else if (rebuildStrategy === 'incremental') {
let newCandidates = scanner.scanFiles(changedFiles)
if (newCandidates.length <= 0) return
compiledCss = compiler.build(newCandidates)
}
await write(compiledCss, args)
let end = process.hrtime.bigint()
eprintln(`Done in ${formatDuration(end - start)}`)
} catch (err) {
if (err instanceof Error) {
eprintln(err.toString())
}
}
},
)
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
cleanupWatchers()
process.exit(0)
})
}
process.stdin.resume()
}
await write(compiler.build(scanner.scan()), args)
let end = process.hrtime.bigint()
eprintln(header())
eprintln()
eprintln(`Done in ${formatDuration(end - start)}`)
}
function watchDirectories(base: string, scanner: Scanner) {
return [base].concat(
scanner.globs.flatMap((globEntry) => {
if (globEntry.pattern[0] === '!') return []
if (globEntry.base.startsWith(base)) return []
return globEntry.base
}),
)
}
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
let watchers = new Disposables()
let files = new Set<string>()
let debounceQueue = new Disposables()
function enqueueCallback() {
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') return
let stats = await fs.lstat(event.path)
if (stats.isDirectory()) {
return
}
files.add(event.path)
}),
)
enqueueCallback()
})
watchers.add(unsubscribe)
}
return () => {
watchers.dispose()
debounceQueue.dispose()
}
}
function handleImports(
input: string,
file: string,
): [css: string, paths: string[]] | Promise<[css: string, paths: string[]]> {
if (!input.includes('@import')) {
return [input, [file]]
}
return postcss()
.use(
atImport({
resolve(id, basedir) {
let resolved = resolveCssId(id, basedir)
if (!resolved) {
throw new Error(`Could not resolve ${id} from ${basedir}`)
}
return resolved
},
load(id) {
return readFileSync(id, 'utf-8')
},
}),
)
.use(fixRelativePathsPlugin())
.process(input, { from: file })
.then((result) => [
result.css,
[file].concat(
result.messages.filter((msg) => msg.type === 'dependency').map((msg) => msg.file),
),
])
}
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()
}