tailwindcss/packages/@tailwindcss-cli/src/commands/build/index.ts
Robin Malfait d68a780f98
Auto source detection improvements (#14820)
This PR introduces a new `source(…)` argument and improves on the
existing `@source`. The goal of this PR is to make the automatic source
detection configurable, let's dig in.

By default, we will perform automatic source detection starting at the
current working directory. Auto source detection will find plain text
files (no binaries, images, ...) and will ignore git-ignored files.

If you want to start from a different directory, you can use the new
`source(…)` next to the `@import "tailwindcss/utilities"
layer(utilities) source(…)`.

E.g.:

```css
/* ./src/styles/index.css */
@import 'tailwindcss/utilities' layer(utilities) source('../../');
```

Most people won't split their source files, and will just use the simple
`@import "tailwindcss";`, because of this reason, you can use
`source(…)` on the import as well:

E.g.:

```css
/* ./src/styles/index.css */
@import 'tailwindcss' source('../../');
```

Sometimes, you want to rely on auto source detection, but also want to
look in another directory for source files. In this case, yuo can use
the `@source` directive:

```css
/* ./src/index.css */
@import 'tailwindcss';

/* Look for `blade.php` files in `../resources/views` */
@source '../resources/views/**/*.blade.php';
```

However, you don't need to specify the extension, instead you can just
point the directory and all the same automatic source detection rules
will apply.

```css
/* ./src/index.css */
@import 'tailwindcss';

@source '../resources/views';
```

If, for whatever reason, you want to disable the default source
detection feature entirely, and only want to rely on very specific glob
patterns you define, then you can disable it via `source(none)`.

```css
/* Completely disable the default auto source detection */
@import 'tailwindcss' source(none);

/* Only look at .blade.php files, nothing else  */
@source "../resources/views/**/*.blade.php";
```

Note: even with `source(none)`, if your `@source` points to a directory,
then auto source detection will still be performed in that directory. If
you don't want that, then you can simply add explicit files in the globs
as seen in the previous example.

```css
/* Completely disable the default auto source detection */
@import 'tailwindcss' source(none);

/* Run auto source detection in `../resources/views` */
@source "../resources/views";
```

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
2024-10-29 20:33:34 +00:00

404 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, 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
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 inputFilePath =
args['--input'] && args['--input'] !== '-' ? path.resolve(args['--input']) : null
let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd()
let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : []
async function createCompiler(css: string) {
env.DEBUG && console.time('[@tailwindcss/cli] Setup compiler')
let compiler = await compile(css, {
base: inputBasePath,
onDependency(path) {
fullRebuildPaths.push(path)
},
})
let sources = (() => {
// Disable auto source detection
if (compiler.root === 'none') {
return []
}
// No root specified, use the base directory
if (compiler.root === null) {
return [{ base, pattern: '**/*' }]
}
// Use the specified root
return [compiler.root]
})().concat(compiler.globs)
let scanner = new Scanner({ sources })
env.DEBUG && console.timeEnd('[@tailwindcss/cli] Setup compiler')
return [compiler, scanner] as const
}
let [compiler, scanner] = await createCompiler(input)
// Watch for changes
if (args['--watch']) {
let cleanupWatchers = await createWatchers(
watchDirectories(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 = inputFilePath ? [inputFilePath] : []
// Create a new compiler, given the new `input`
;[compiler, scanner] = await createCompiler(input)
// 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(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(scanner: Scanner) {
return 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 files, only directories.
if (globEntry.pattern === '') 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: Stats | null = null
try {
stats = await fs.lstat(event.path)
} catch {}
if (!stats?.isFile() && !stats?.isSymbolicLink()) {
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()
}