mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
This PR exposes when using the the `DEBUG` environment variable. This follows the `DEBUG` conventions where: - `DEBUG=1` - `DEBUG=true` - `DEBUG=*` - `DEBUG=tailwindcss` Will enable the debug information, but when using: - `DEBUG=0` - `DEBUG=false` - `DEBUG=-tailwindcss` It will not. This currently only exposes some timings related to: 1. Scanning for candidates 2. Building the CSS 3. Optimizing the CSS We can implement a more advanced version of this where we also expose more fine grained information such as the files we scanned, the amount of candidates we found and so on. But I believe that this will be enough to start triaging performance related issues.
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import watcher from '@parcel/watcher'
|
|
import { compile, env } from '@tailwindcss/node'
|
|
import { clearRequireCache } from '@tailwindcss/node/require-cache'
|
|
import { Scanner, type ChangedContent } from '@tailwindcss/oxide'
|
|
import { Features, transform } from 'lightningcss'
|
|
import { existsSync } 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
|
|
|
|
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'])
|
|
|
|
// Resolve the output as an absolute path.
|
|
if (args['--output']) {
|
|
args['--output'] = path.resolve(base, args['--output'])
|
|
}
|
|
|
|
// Resolve the input as an absolute path. If the input is a `-`, then we don't
|
|
// need to resolve it because this is a flag to indicate that we want to use
|
|
// `stdin` instead.
|
|
if (args['--input'] && args['--input'] !== '-') {
|
|
args['--input'] = path.resolve(base, args['--input'])
|
|
|
|
// Ensure the provided `--input` exists.
|
|
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 = args['--input']
|
|
? args['--input'] === '-'
|
|
? await drainStdin()
|
|
: await fs.readFile(args['--input'], 'utf-8')
|
|
: css`
|
|
@import 'tailwindcss';
|
|
`
|
|
|
|
let previous = {
|
|
css: '',
|
|
optimizedCss: '',
|
|
}
|
|
|
|
async function write(css: string, args: Result<ReturnType<typeof options>>) {
|
|
let output = css
|
|
|
|
// Optimize the output
|
|
if (args['--minify'] || args['--optimize']) {
|
|
if (css !== previous.css) {
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Optimize CSS')
|
|
let optimizedCss = optimizeCss(css, {
|
|
file: args['--input'] ?? 'input.css',
|
|
minify: args['--minify'] ?? false,
|
|
})
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Optimize CSS')
|
|
previous.css = css
|
|
previous.optimizedCss = optimizedCss
|
|
output = optimizedCss
|
|
} else {
|
|
output = previous.optimizedCss
|
|
}
|
|
}
|
|
|
|
// Write the output
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Write output')
|
|
if (args['--output']) {
|
|
await outputFile(args['--output'], output)
|
|
} else {
|
|
println(output)
|
|
}
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Write output')
|
|
}
|
|
|
|
let inputBasePath =
|
|
args['--input'] && args['--input'] !== '-'
|
|
? path.dirname(path.resolve(args['--input']))
|
|
: process.cwd()
|
|
let fullRebuildPaths: string[] = []
|
|
|
|
function createCompiler(css: string) {
|
|
return compile(css, {
|
|
base: inputBasePath,
|
|
onDependency(path) {
|
|
fullRebuildPaths.push(path)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Compile the input
|
|
let compiler = await createCompiler(input)
|
|
let scanner = new Scanner({
|
|
detectSources: { base },
|
|
sources: compiler.globs,
|
|
})
|
|
|
|
// Watch for changes
|
|
if (args['--watch']) {
|
|
let cleanupWatchers = await createWatchers(
|
|
watchDirectories(base, scanner),
|
|
async function handle(files) {
|
|
try {
|
|
// If the only change happened to the output file, then we don't want to
|
|
// trigger a rebuild because that will result in an infinite loop.
|
|
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 one of the changed files is related to the input CSS or JS
|
|
// config/plugin files, then we need to do a full rebuild because
|
|
// the theme might have changed.
|
|
if (resolvedFullRebuildPaths.includes(file)) {
|
|
rebuildStrategy = 'full'
|
|
|
|
// No need to check the rest of the events, because we already know we
|
|
// need to do a full rebuild.
|
|
break
|
|
}
|
|
|
|
// Track new and updated files for incremental rebuilds.
|
|
changedFiles.push({
|
|
file,
|
|
extension: path.extname(file).slice(1),
|
|
} satisfies ChangedContent)
|
|
}
|
|
|
|
// Re-compile the input
|
|
let start = process.hrtime.bigint()
|
|
|
|
// Track the compiled CSS
|
|
let compiledCss = ''
|
|
|
|
// Scan the entire `base` directory for full rebuilds.
|
|
if (rebuildStrategy === 'full') {
|
|
// Clear all watchers
|
|
cleanupWatchers()
|
|
|
|
// Read the new `input`.
|
|
let input = args['--input']
|
|
? args['--input'] === '-'
|
|
? await drainStdin()
|
|
: await fs.readFile(args['--input'], 'utf-8')
|
|
: css`
|
|
@import 'tailwindcss';
|
|
`
|
|
clearRequireCache(resolvedFullRebuildPaths)
|
|
fullRebuildPaths = []
|
|
|
|
// Create a new compiler, given the new `input`
|
|
compiler = await createCompiler(input)
|
|
|
|
// Re-scan the directory to get the new `candidates`
|
|
scanner = new Scanner({
|
|
detectSources: { base },
|
|
sources: compiler.globs,
|
|
})
|
|
|
|
// Scan the directory for candidates
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Scan for candidates')
|
|
let candidates = scanner.scan()
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Scan for candidates')
|
|
|
|
// Setup new watchers
|
|
cleanupWatchers = await createWatchers(watchDirectories(base, scanner), handle)
|
|
|
|
// Re-compile the CSS
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Build CSS')
|
|
compiledCss = compiler.build(candidates)
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Build CSS')
|
|
}
|
|
|
|
// Scan changed files only for incremental rebuilds.
|
|
else if (rebuildStrategy === 'incremental') {
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Scan for candidates')
|
|
let newCandidates = scanner.scanFiles(changedFiles)
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Scan for candidates')
|
|
|
|
// No new candidates found which means we don't need to write to
|
|
// disk, and can return early.
|
|
if (newCandidates.length <= 0) {
|
|
let end = process.hrtime.bigint()
|
|
eprintln(`Done in ${formatDuration(end - start)}`)
|
|
return
|
|
}
|
|
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Build CSS')
|
|
compiledCss = compiler.build(newCandidates)
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Build CSS')
|
|
}
|
|
|
|
await write(compiledCss, args)
|
|
|
|
let end = process.hrtime.bigint()
|
|
eprintln(`Done in ${formatDuration(end - start)}`)
|
|
} catch (err) {
|
|
// Catch any errors and print them to stderr, but don't exit the process
|
|
// and keep watching.
|
|
if (err instanceof Error) {
|
|
eprintln(err.toString())
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Abort the watcher if `stdin` is closed to avoid zombie processes. You can
|
|
// disable this behavior with `--watch=always`.
|
|
if (args['--watch'] !== 'always') {
|
|
process.stdin.on('end', () => {
|
|
cleanupWatchers()
|
|
process.exit(0)
|
|
})
|
|
}
|
|
|
|
// Keep the process running
|
|
process.stdin.resume()
|
|
}
|
|
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Scan for candidates')
|
|
let candidates = scanner.scan()
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Scan for candidates')
|
|
env.DEBUG && console.time('[@tailwindcss/cli] Build CSS')
|
|
let output = compiler.build(candidates)
|
|
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Build CSS')
|
|
await write(output, 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) => {
|
|
// We don't want a watcher for negated globs.
|
|
if (globEntry.pattern[0] === '!') return []
|
|
|
|
// We don't want a watcher for nested directories, these will be covered
|
|
// by the `base` directory already.
|
|
if (globEntry.base.startsWith(base)) return []
|
|
|
|
return globEntry.base
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
|
|
// Track all Parcel watchers for each glob.
|
|
//
|
|
// When we encounter a change in a CSS file, we need to setup new watchers and
|
|
// we want to cleanup the old ones we captured here.
|
|
let watchers = new Disposables()
|
|
|
|
// Track all files that were added or changed.
|
|
let files = new Set<string>()
|
|
|
|
// Keep track of the debounce queue to avoid multiple rebuilds.
|
|
let debounceQueue = new Disposables()
|
|
|
|
// A changed file can be watched by multiple watchers, but we only want to
|
|
// handle the file once. We debounce the handle function with the collected
|
|
// files to handle them in a single batch and to avoid multiple rebuilds.
|
|
function enqueueCallback() {
|
|
// Dispose all existing macrotask.
|
|
debounceQueue.dispose()
|
|
|
|
// Setup a new macrotask to handle the files in batch.
|
|
debounceQueue.queueMacrotask(() => {
|
|
cb(Array.from(files))
|
|
files.clear()
|
|
})
|
|
}
|
|
|
|
// Setup a watcher for every directory.
|
|
for (let dir of dirs) {
|
|
let { unsubscribe } = await watcher.subscribe(dir, async (err, events) => {
|
|
// Whenever an error occurs we want to let the user know about it but we
|
|
// want to keep watching for changes.
|
|
if (err) {
|
|
console.error(err)
|
|
return
|
|
}
|
|
|
|
await Promise.all(
|
|
events.map(async (event) => {
|
|
// We currently don't handle deleted files because it doesn't influence
|
|
// the CSS output. This is because we currently keep all scanned
|
|
// candidates in a cache for performance reasons.
|
|
if (event.type === 'delete') return
|
|
|
|
// Ignore directory changes. We only care about file changes
|
|
let stats = await fs.lstat(event.path)
|
|
if (stats.isDirectory()) {
|
|
return
|
|
}
|
|
|
|
// Track the changed file.
|
|
files.add(event.path)
|
|
}),
|
|
)
|
|
|
|
// Handle the tracked files at some point in the future.
|
|
enqueueCallback()
|
|
})
|
|
|
|
// Ensure we cleanup the watcher when we're done.
|
|
watchers.add(unsubscribe)
|
|
}
|
|
|
|
// Cleanup
|
|
return () => {
|
|
watchers.dispose()
|
|
debounceQueue.dispose()
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|