#!/usr/bin/env node import { lazyPostcss, lazyPostcssImport, lazyCssnano, lazyAutoprefixer } from '../peers/index.js' import chokidar from 'chokidar' import path from 'path' import arg from 'arg' import fs from 'fs' import postcssrc from 'postcss-load-config' import { lilconfig } from 'lilconfig' import loadPlugins from 'postcss-load-config/src/plugins' // Little bit scary, looking at private/internal API import loadOptions from 'postcss-load-config/src/options' // Little bit scary, looking at private/internal API import tailwind from './processTailwindFeatures' import resolveConfigInternal from '../resolveConfig' import fastGlob from 'fast-glob' import getModuleDependencies from './lib/getModuleDependencies' import log from './util/log' import packageJson from '../package.json' import normalizePath from 'normalize-path' import { validateConfig } from './util/validateConfig.js' let env = { DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0', } function isESM() { const pkgPath = path.resolve('./package.json') try { let pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) return pkg.type && pkg.type === 'module' } catch (err) { return false } } let configs = isESM() ? { tailwind: 'tailwind.config.cjs', postcss: 'postcss.config.cjs', } : { tailwind: 'tailwind.config.js', postcss: 'postcss.config.js', } // --- function indentRecursive(node, indent = 0) { node.each && node.each((child, i) => { if (!child.raws.before || !child.raws.before.trim() || child.raws.before.includes('\n')) { child.raws.before = `\n${node.type !== 'rule' && i > 0 ? '\n' : ''}${' '.repeat(indent)}` } child.raws.after = `\n${' '.repeat(indent)}` indentRecursive(child, indent + 1) }) } function formatNodes(root) { indentRecursive(root) if (root.first) { root.first.raws.before = '' } } async function outputFile(file, contents) { if (fs.existsSync(file) && (await fs.promises.readFile(file, 'utf8')) === contents) { return // Skip writing the file } // Write the file await fs.promises.writeFile(file, contents, 'utf8') } function drainStdin() { return new Promise((resolve, reject) => { let result = '' process.stdin.on('data', (chunk) => { result += chunk }) process.stdin.on('end', () => resolve(result)) process.stdin.on('error', (err) => reject(err)) }) } function help({ message, usage, commands, options }) { let indent = 2 // Render header console.log() console.log(`${packageJson.name} v${packageJson.version}`) // Render message if (message) { console.log() for (let msg of message.split('\n')) { console.log(msg) } } // Render usage if (usage && usage.length > 0) { console.log() console.log('Usage:') for (let example of usage) { console.log(' '.repeat(indent), example) } } // Render commands if (commands && commands.length > 0) { console.log() console.log('Commands:') for (let command of commands) { console.log(' '.repeat(indent), command) } } // Render options if (options) { let groupedOptions = {} for (let [key, value] of Object.entries(options)) { if (typeof value === 'object') { groupedOptions[key] = { ...value, flags: [key] } } else { groupedOptions[value].flags.push(key) } } console.log() console.log('Options:') for (let { flags, description, deprecated } of Object.values(groupedOptions)) { if (deprecated) continue if (flags.length === 1) { console.log( ' '.repeat(indent + 4 /* 4 = "-i, ".length */), flags.slice().reverse().join(', ').padEnd(20, ' '), description ) } else { console.log( ' '.repeat(indent), flags.slice().reverse().join(', ').padEnd(24, ' '), description ) } } } console.log() } function oneOf(...options) { return Object.assign( (value = true) => { for (let option of options) { let parsed = option(value) if (parsed === value) { return parsed } } throw new Error('...') }, { manualParsing: true } ) } function loadPostcss() { // Try to load a local `postcss` version first try { return require('postcss') } catch {} return lazyPostcss() } let commands = { init: { run: init, args: { '--full': { type: Boolean, description: `Initialize a full \`${configs.tailwind}\` file` }, '--postcss': { type: Boolean, description: `Initialize a \`${configs.postcss}\` file` }, '-f': '--full', '-p': '--postcss', }, }, build: { run: build, args: { '--input': { type: String, description: 'Input file' }, '--output': { type: String, description: 'Output file' }, '--watch': { type: Boolean, description: 'Watch for changes and rebuild as needed' }, '--poll': { type: Boolean, description: 'Use polling instead of filesystem events when watching', }, '--content': { type: String, description: 'Content paths to use for removing unused classes', }, '--purge': { type: String, deprecated: true, }, '--postcss': { type: oneOf(String, Boolean), description: 'Load custom PostCSS configuration', }, '--minify': { type: Boolean, description: 'Minify the output' }, '--config': { type: String, description: 'Path to a custom config file', }, '--no-autoprefixer': { type: Boolean, description: 'Disable autoprefixer', }, '-c': '--config', '-i': '--input', '-o': '--output', '-m': '--minify', '-w': '--watch', '-p': '--poll', }, }, } let sharedFlags = { '--help': { type: Boolean, description: 'Display usage information' }, '-h': '--help', } if ( process.stdout.isTTY /* Detect redirecting output to a file */ && (process.argv[2] === undefined || process.argv.slice(2).every((flag) => sharedFlags[flag] !== undefined)) ) { help({ usage: [ 'tailwindcss [--input input.css] [--output output.css] [--watch] [options...]', 'tailwindcss init [--full] [--postcss] [options...]', ], commands: Object.keys(commands) .filter((command) => command !== 'build') .map((command) => `${command} [options]`), options: { ...commands.build.args, ...sharedFlags }, }) process.exit(0) } let command = ((arg = '') => (arg.startsWith('-') ? undefined : arg))(process.argv[2]) || 'build' if (commands[command] === undefined) { if (fs.existsSync(path.resolve(command))) { // TODO: Deprecate this in future versions // Check if non-existing command, might be a file. command = 'build' } else { help({ message: `Invalid command: ${command}`, usage: ['tailwindcss [options]'], commands: Object.keys(commands) .filter((command) => command !== 'build') .map((command) => `${command} [options]`), options: sharedFlags, }) process.exit(1) } } // Execute command let { args: flags, run } = commands[command] let args = (() => { try { let result = arg( Object.fromEntries( Object.entries({ ...flags, ...sharedFlags }) .filter(([_key, value]) => !value?.type?.manualParsing) .map(([key, value]) => [key, typeof value === 'object' ? value.type : value]) ), { permissive: true } ) // Manual parsing of flags to allow for special flags like oneOf(Boolean, String) for (let i = result['_'].length - 1; i >= 0; --i) { let flag = result['_'][i] if (!flag.startsWith('-')) continue let flagName = flag let handler = flags[flag] // Resolve flagName & handler while (typeof handler === 'string') { flagName = handler handler = flags[handler] } if (!handler) continue let args = [] let offset = i + 1 // Parse args for current flag while (result['_'][offset] && !result['_'][offset].startsWith('-')) { args.push(result['_'][offset++]) } // Cleanup manually parsed flags + args result['_'].splice(i, 1 + args.length) // Set the resolved value in the `result` object result[flagName] = handler.type( args.length === 0 ? undefined : args.length === 1 ? args[0] : args, flagName ) } // Ensure that the `command` is always the first argument in the `args`. // This is important so that we don't have to check if a default command // (build) was used or not from within each plugin. // // E.g.: tailwindcss input.css -> _: ['build', 'input.css'] // E.g.: tailwindcss build input.css -> _: ['build', 'input.css'] if (result['_'][0] !== command) { result['_'].unshift(command) } return result } catch (err) { if (err.code === 'ARG_UNKNOWN_OPTION') { help({ message: err.message, usage: ['tailwindcss [options]'], options: sharedFlags, }) process.exit(1) } throw err } })() if (args['--help']) { help({ options: { ...flags, ...sharedFlags }, usage: [`tailwindcss ${command} [options]`], }) process.exit(0) } run() // --- function init() { let messages = [] let tailwindConfigLocation = path.resolve(args['_'][1] ?? `./${configs.tailwind}`) if (fs.existsSync(tailwindConfigLocation)) { messages.push(`${path.basename(tailwindConfigLocation)} already exists.`) } else { let stubFile = fs.readFileSync( args['--full'] ? path.resolve(__dirname, '../stubs/defaultConfig.stub.js') : path.resolve(__dirname, '../stubs/simpleConfig.stub.js'), 'utf8' ) // Change colors import stubFile = stubFile.replace('../colors', 'tailwindcss/colors') fs.writeFileSync(tailwindConfigLocation, stubFile, 'utf8') messages.push(`Created Tailwind CSS config file: ${path.basename(tailwindConfigLocation)}`) } if (args['--postcss']) { let postcssConfigLocation = path.resolve(`./${configs.postcss}`) if (fs.existsSync(postcssConfigLocation)) { messages.push(`${path.basename(postcssConfigLocation)} already exists.`) } else { let stubFile = fs.readFileSync( path.resolve(__dirname, '../stubs/defaultPostCssConfig.stub.js'), 'utf8' ) fs.writeFileSync(postcssConfigLocation, stubFile, 'utf8') messages.push(`Created PostCSS config file: ${path.basename(postcssConfigLocation)}`) } } if (messages.length > 0) { console.log() for (let message of messages) { console.log(message) } } } async function build() { let input = args['--input'] let output = args['--output'] let shouldWatch = args['--watch'] let shouldPoll = args['--poll'] let shouldCoalesceWriteEvents = shouldPoll || process.platform === 'win32' let includePostCss = args['--postcss'] // Polling interval in milliseconds // Used only when polling or coalescing add/change events on Windows let pollInterval = 10 // TODO: Deprecate this in future versions if (!input && args['_'][1]) { console.error('[deprecation] Running tailwindcss without -i, please provide an input file.') input = args['--input'] = args['_'][1] } if (input && input !== '-' && !fs.existsSync((input = path.resolve(input)))) { console.error(`Specified input file ${args['--input']} does not exist.`) process.exit(9) } if (args['--config'] && !fs.existsSync((args['--config'] = path.resolve(args['--config'])))) { console.error(`Specified config file ${args['--config']} does not exist.`) process.exit(9) } let configPath = args['--config'] ? args['--config'] : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))( path.resolve(`./${configs.tailwind}`) ) async function loadPostCssPlugins() { let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined let config = customPostCssPath ? await (async () => { let file = path.resolve(customPostCssPath) // Implementation, see: https://unpkg.com/browse/postcss-load-config@3.1.0/src/index.js let { config = {} } = await lilconfig('postcss').load(file) if (typeof config === 'function') { config = config() } else { config = Object.assign({}, config) } if (!config.plugins) { config.plugins = [] } return { file, plugins: loadPlugins(config, file), options: loadOptions(config, file), } })() : await postcssrc() let configPlugins = config.plugins let configPluginTailwindIdx = configPlugins.findIndex((plugin) => { if (typeof plugin === 'function' && plugin.name === 'tailwindcss') { return true } if (typeof plugin === 'object' && plugin !== null && plugin.postcssPlugin === 'tailwindcss') { return true } return false }) let beforePlugins = configPluginTailwindIdx === -1 ? [] : configPlugins.slice(0, configPluginTailwindIdx) let afterPlugins = configPluginTailwindIdx === -1 ? configPlugins : configPlugins.slice(configPluginTailwindIdx + 1) return [beforePlugins, afterPlugins, config.options] } function loadBuiltinPostcssPlugins() { let postcss = loadPostcss() let IMPORT_COMMENT = '__TAILWIND_RESTORE_IMPORT__: ' return [ [ (root) => { root.walkAtRules('import', (rule) => { if (rule.params.slice(1).startsWith('tailwindcss/')) { rule.after(postcss.comment({ text: IMPORT_COMMENT + rule.params })) rule.remove() } }) }, (() => { try { return require('postcss-import') } catch {} return lazyPostcssImport() })(), (root) => { root.walkComments((rule) => { if (rule.text.startsWith(IMPORT_COMMENT)) { rule.after( postcss.atRule({ name: 'import', params: rule.text.replace(IMPORT_COMMENT, ''), }) ) rule.remove() } }) }, ], [], {}, ] } function resolveConfig() { let config = configPath ? require(configPath) : {} if (args['--purge']) { log.warn('purge-flag-deprecated', [ 'The `--purge` flag has been deprecated.', 'Please use `--content` instead.', ]) if (!args['--content']) { args['--content'] = args['--purge'] } } if (args['--content']) { let files = args['--content'].split(/(? { // Strings in this case are files / globs. If it is something else, // like an object it's probably a raw content object. But this object // is not watchable, so let's remove it. return typeof file === 'string' }) .map((glob) => normalizePath(glob)) } function extractRawContent(config) { return config.content.files.filter((file) => { return typeof file === 'object' && file !== null }) } function getChangedContent(config) { let changedContent = [] // Resolve globs from the content config let globs = extractFileGlobs(config) let files = fastGlob.sync(globs) for (let file of files) { changedContent.push({ content: fs.readFileSync(path.resolve(file), 'utf8'), extension: path.extname(file).slice(1), }) } // Resolve raw content in the tailwind config for (let { raw: content, extension = 'html' } of extractRawContent(config)) { changedContent.push({ content, extension }) } return changedContent } async function buildOnce() { let config = resolveConfig() let changedContent = getChangedContent(config) let tailwindPlugin = () => { return { postcssPlugin: 'tailwindcss', Once(root, { result }) { tailwind(({ createContext }) => { return () => { return createContext(config, changedContent) } })(root, result) }, } } tailwindPlugin.postcss = true let [beforePlugins, afterPlugins, postcssOptions] = includePostCss ? await loadPostCssPlugins() : loadBuiltinPostcssPlugins() let plugins = [ ...beforePlugins, tailwindPlugin, !args['--minify'] && formatNodes, ...afterPlugins, !args['--no-autoprefixer'] && (() => { // Try to load a local `autoprefixer` version first try { return require('autoprefixer') } catch {} return lazyAutoprefixer() })(), args['--minify'] && (() => { let options = { preset: ['default', { cssDeclarationSorter: false }] } // Try to load a local `cssnano` version first try { return require('cssnano') } catch {} return lazyCssnano()(options) })(), ].filter(Boolean) let postcss = loadPostcss() let processor = postcss(plugins) function processCSS(css) { let start = process.hrtime.bigint() return Promise.resolve() .then(() => (output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null)) .then(() => processor.process(css, { ...postcssOptions, from: input, to: output })) .then((result) => { if (!output) { return process.stdout.write(result.css) } return Promise.all( [ outputFile(output, result.css), result.map && outputFile(output + '.map', result.map.toString()), ].filter(Boolean) ) }) .then(() => { let end = process.hrtime.bigint() console.error() console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') }) } let css = await (() => { // Piping in data, let's drain the stdin if (input === '-') { return drainStdin() } // Input file has been provided if (input) { return fs.readFileSync(path.resolve(input), 'utf8') } // No input file provided, fallback to default atrules return '@tailwind base; @tailwind components; @tailwind utilities' })() return processCSS(css) } let context = null async function startWatcher() { let changedContent = [] let configDependencies = [] let contextDependencies = new Set() let watcher = null function refreshConfig() { env.DEBUG && console.time('Module dependencies') for (let file of configDependencies) { delete require.cache[require.resolve(file)] } if (configPath) { configDependencies = getModuleDependencies(configPath).map(({ file }) => file) for (let dependency of configDependencies) { contextDependencies.add(dependency) } } env.DEBUG && console.timeEnd('Module dependencies') return resolveConfig() } let [beforePlugins, afterPlugins] = includePostCss ? await loadPostCssPlugins() : loadBuiltinPostcssPlugins() let plugins = [ ...beforePlugins, '__TAILWIND_PLUGIN_POSITION__', !args['--minify'] && formatNodes, ...afterPlugins, !args['--no-autoprefixer'] && (() => { // Try to load a local `autoprefixer` version first try { return require('autoprefixer') } catch {} return lazyAutoprefixer() })(), args['--minify'] && (() => { let options = { preset: ['default', { cssDeclarationSorter: false }] } // Try to load a local `cssnano` version first try { return require('cssnano') } catch {} return lazyCssnano()(options) })(), ].filter(Boolean) async function rebuild(config) { env.DEBUG && console.time('Finished in') let tailwindPlugin = () => { return { postcssPlugin: 'tailwindcss', Once(root, { result }) { env.DEBUG && console.time('Compiling CSS') tailwind(({ createContext }) => { console.error() console.error('Rebuilding...') return () => { if (context !== null) { context.changedContent = changedContent.splice(0) return context } env.DEBUG && console.time('Creating context') context = createContext(config, changedContent.splice(0)) env.DEBUG && console.timeEnd('Creating context') return context } })(root, result) env.DEBUG && console.timeEnd('Compiling CSS') }, } } tailwindPlugin.postcss = true let tailwindPluginIdx = plugins.indexOf('__TAILWIND_PLUGIN_POSITION__') let copy = plugins.slice() copy.splice(tailwindPluginIdx, 1, tailwindPlugin) let postcss = loadPostcss() let processor = postcss(copy) function processCSS(css) { let start = process.hrtime.bigint() return Promise.resolve() .then(() => output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null ) .then(() => processor.process(css, { from: input, to: output })) .then(async (result) => { for (let message of result.messages) { if (message.type === 'dependency') { contextDependencies.add(message.file) } } watcher.add([...contextDependencies]) if (!output) { return process.stdout.write(result.css) } return Promise.all( [ outputFile(output, result.css), result.map && outputFile(output + '.map', result.map.toString()), ].filter(Boolean) ) }) .then(() => { let end = process.hrtime.bigint() console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') }) .catch((err) => { if (err.name === 'CssSyntaxError') { console.error(err.toString()) } else { console.error(err) } }) } let css = await (() => { // Piping in data, let's drain the stdin if (input === '-') { return drainStdin() } // Input file has been provided if (input) { return fs.readFileSync(path.resolve(input), 'utf8') } // No input file provided, fallback to default atrules return '@tailwind base; @tailwind components; @tailwind utilities' })() let result = await processCSS(css) env.DEBUG && console.timeEnd('Finished in') return result } let config = refreshConfig(configPath) if (input) { contextDependencies.add(path.resolve(input)) } watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], { usePolling: shouldPoll, interval: shouldPoll ? pollInterval : undefined, ignoreInitial: true, awaitWriteFinish: shouldCoalesceWriteEvents ? { stabilityThreshold: 50, pollInterval: pollInterval, } : false, }) let chain = Promise.resolve() watcher.on('change', async (file) => { if (contextDependencies.has(file)) { env.DEBUG && console.time('Resolve config') context = null config = refreshConfig(configPath) env.DEBUG && console.timeEnd('Resolve config') env.DEBUG && console.time('Watch new files') let globs = extractFileGlobs(config) watcher.add(configDependencies) watcher.add(globs) env.DEBUG && console.timeEnd('Watch new files') chain = chain.then(async () => { changedContent.push(...getChangedContent(config)) await rebuild(config) }) } else { chain = chain.then(async () => { changedContent.push({ content: fs.readFileSync(path.resolve(file), 'utf8'), extension: path.extname(file).slice(1), }) await rebuild(config) }) } }) watcher.on('add', async (file) => { chain = chain.then(async () => { changedContent.push({ content: fs.readFileSync(path.resolve(file), 'utf8'), extension: path.extname(file).slice(1), }) await rebuild(config) }) }) chain = chain.then(() => { changedContent.push(...getChangedContent(config)) return rebuild(config) }) } if (shouldWatch) { /* Abort the watcher if stdin is closed to avoid zombie processes */ if (process.stdin.isTTY) { process.stdin.on('end', () => process.exit(0)) process.stdin.resume() } startWatcher() } else { buildOnce() } }