Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
Jordan Pittman
b2b4358dfe Split CLI into separate files 2022-09-22 11:31:53 -04:00
Jordan Pittman
d76e5db551 cleanup
wip

cleanup
2022-09-22 11:31:53 -04:00
Jordan Pittman
377f372536 wip
cleanup

wip
2022-09-22 11:31:41 -04:00
Jordan Pittman
df35325e27 wip 2022-09-22 11:31:12 -04:00
Jordan Pittman
6bd3327d8e wip
wip

wip

wip
2022-09-22 11:31:12 -04:00
Jordan Pittman
788e283379 wip 2022-09-22 11:30:17 -04:00
Jordan Pittman
3c07c8b92d Normalize and resolve content paths earlier
Not just for PostCSS dependencies
2022-09-22 11:30:17 -04:00
Jordan Pittman
2102566df8 Resolve symlinks for paths 2022-09-22 11:30:17 -04:00
Jordan Pittman
14c39f4d44 Add flag to resolve content relative to the config file 2022-09-22 11:30:17 -04:00
Jordan Pittman
137a85c0ff Refactor parseDependency 2022-09-22 11:30:17 -04:00
Jordan Pittman
d18fd513a4 Add option to load config file relative to the input file 2022-09-22 11:30:17 -04:00
Jordan Pittman
a3b42e4fec Tweak formatting 2022-09-22 11:30:17 -04:00
Jordan Pittman
182e3dc480 Update lockfile 2022-09-22 11:30:17 -04:00
17 changed files with 1175 additions and 919 deletions

View file

@ -12,13 +12,13 @@
}
},
"../..": {
"version": "3.0.24",
"version": "3.1.5",
"license": "MIT",
"dependencies": {
"arg": "^5.0.1",
"arg": "^5.0.2",
"chokidar": "^3.5.3",
"color-name": "^1.1.4",
"detective": "^5.2.0",
"detective": "^5.2.1",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.2.11",
@ -36,26 +36,26 @@
"postcss-selector-parser": "^6.0.10",
"postcss-value-parser": "^4.2.0",
"quick-lru": "^5.1.1",
"resolve": "^1.22.0"
"resolve": "^1.22.1"
},
"bin": {
"tailwind": "lib/cli.js",
"tailwindcss": "lib/cli.js"
},
"devDependencies": {
"@parcel/css": "^1.11.2",
"@swc/cli": "^0.1.57",
"@swc/core": "^1.2.160",
"@swc/core": "^1.2.196",
"@swc/jest": "^0.2.21",
"@swc/register": "^0.1.10",
"autoprefixer": "^10.4.7",
"cssnano": "^5.1.9",
"esbuild": "^0.14.39",
"eslint": "^8.15.0",
"esbuild": "^0.14.48",
"eslint": "^8.18.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^28.0.3",
"jest-diff": "^28.1.0",
"prettier": "^2.6.2",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^28.1.2",
"jest-diff": "^28.1.1",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.11",
"rimraf": "^3.0.0",
"source-map-js": "^1.0.2"
@ -76,27 +76,27 @@
"tailwindcss": {
"version": "file:../..",
"requires": {
"@parcel/css": "^1.11.2",
"@swc/cli": "^0.1.57",
"@swc/core": "^1.2.160",
"@swc/core": "^1.2.196",
"@swc/jest": "^0.2.21",
"@swc/register": "^0.1.10",
"arg": "^5.0.1",
"arg": "^5.0.2",
"autoprefixer": "^10.4.7",
"chokidar": "^3.5.3",
"color-name": "^1.1.4",
"cssnano": "^5.1.9",
"detective": "^5.2.0",
"detective": "^5.2.1",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"esbuild": "^0.14.39",
"eslint": "^8.15.0",
"esbuild": "^0.14.48",
"eslint": "^8.18.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-prettier": "^4.2.1",
"fast-glob": "^3.2.11",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jest": "^28.0.3",
"jest-diff": "^28.1.0",
"jest": "^28.1.2",
"jest-diff": "^28.1.1",
"lilconfig": "^2.0.5",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
@ -108,10 +108,10 @@
"postcss-nested": "5.0.6",
"postcss-selector-parser": "^6.0.10",
"postcss-value-parser": "^4.2.0",
"prettier": "^2.6.2",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.11",
"quick-lru": "^5.1.1",
"resolve": "^1.22.0",
"resolve": "^1.22.1",
"rimraf": "^3.0.0",
"source-map-js": "^1.0.2"
}

View file

@ -459,6 +459,7 @@ describe('Build command', () => {
-m, --minify Minify the output
-c, --config Path to a custom config file
--no-autoprefixer Disable autoprefixer
--experimental-contextual-paths Enable experimental contextual path resolution
-h, --help Display usage information
`)
)

View file

@ -1,28 +1,12 @@
#!/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 micromatch from 'micromatch'
import { validateConfig } from './util/validateConfig.js'
let env = {
DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0',
}
import { help } from './cli/help.js'
import { init } from './cli/init.js'
import { build } from './cli/build.js'
function isESM() {
const pkgPath = path.resolve('./package.json')
@ -47,112 +31,6 @@ let configs = isESM()
// ---
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) => {
@ -169,15 +47,6 @@ function oneOf(...options) {
)
}
function loadPostcss() {
// Try to load a local `postcss` version first
try {
return require('postcss')
} catch {}
return lazyPostcss()
}
let commands = {
init: {
run: init,
@ -351,675 +220,4 @@ if (args['--help']) {
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(/(?<!{[^}]+),/)
let resolvedConfig = resolveConfigInternal(config, { content: { files } })
resolvedConfig.content.files = files
resolvedConfig = validateConfig(resolvedConfig)
return resolvedConfig
}
let resolvedConfig = resolveConfigInternal(config)
resolvedConfig = validateConfig(resolvedConfig)
return resolvedConfig
}
function extractFileGlobs(config) {
return config.content.files
.filter((file) => {
// 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)
let contentPatterns = refreshContentPatterns(config)
/**
* @param {import('../types/config.js').RequiredConfig} config
* @return {{all: string[], dynamic: string[], static: string[]}}
**/
function refreshContentPatterns(config) {
let globs = extractFileGlobs(config)
let tasks = fastGlob.generateTasks(globs, { absolute: true })
let dynamicPatterns = tasks.filter((task) => task.dynamic).flatMap((task) => task.patterns)
let staticPatterns = tasks.filter((task) => !task.dynamic).flatMap((task) => task.patterns)
return {
all: [...staticPatterns, ...dynamicPatterns],
dynamic: dynamicPatterns,
}
}
if (input) {
contextDependencies.add(path.resolve(input))
}
watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], {
// Force checking for atomic writes in all situations
// This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
// This only works when watching directories though
atomic: true,
usePolling: shouldPoll,
interval: shouldPoll ? pollInterval : undefined,
ignoreInitial: true,
awaitWriteFinish: shouldCoalesceWriteEvents
? {
stabilityThreshold: 50,
pollInterval: pollInterval,
}
: false,
})
let chain = Promise.resolve()
let pendingRebuilds = new Set()
watcher.on('change', async (file) => {
if (contextDependencies.has(file)) {
env.DEBUG && console.time('Resolve config')
context = null
config = refreshConfig(configPath)
contentPatterns = refreshContentPatterns(config)
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)
})
}
})
/**
* When rapidly saving files atomically a couple of situations can happen:
* - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save.
* - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held.
*
* To work around this we retry reading the file a handful of times with a delay between each attempt
*
* @param {string} path
* @param {number} tries
* @returns {string}
* @throws {Error} If the file is still missing or busy after the specified number of tries
*/
async function readFileWithRetries(path, tries = 5) {
for (let n = 0; n <= tries; n++) {
try {
return await fs.promises.readFile(path, 'utf8')
} catch (err) {
if (n !== tries) {
if (err.code === 'ENOENT' || err.code === 'EBUSY') {
await new Promise((resolve) => setTimeout(resolve, 10))
continue
}
}
throw err
}
}
}
// Restore watching any files that are "removed"
// This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
// TODO: An an optimization we should allow removal when the config changes
watcher.on('unlink', (file) => {
file = normalizePath(file)
// Only re-add the file if it's not covered by a dynamic pattern
if (!micromatch.some([file], contentPatterns.dynamic)) {
watcher.add(file)
}
})
// Some applications such as Visual Studio (but not VS Code)
// will only fire a rename event for atomic writes and not a change event
// This is very likely a chokidar bug but it's one we need to work around
// We treat this as a change event and rebuild the CSS
watcher.on('raw', (evt, filePath, meta) => {
if (evt !== 'rename') {
return
}
let watchedPath = meta.watchedPath
// Watched path might be the file itself
// Or the directory it is in
filePath = watchedPath.endsWith(filePath) ? watchedPath : path.join(watchedPath, filePath)
// Skip this event since the files it is for does not match any of the registered content globs
if (!micromatch.some([filePath], contentPatterns.all)) {
return
}
// Skip since we've already queued a rebuild for this file that hasn't happened yet
if (pendingRebuilds.has(filePath)) {
return
}
pendingRebuilds.add(filePath)
chain = chain.then(async () => {
let content
try {
content = await readFileWithRetries(path.resolve(filePath))
} finally {
pendingRebuilds.delete(filePath)
}
changedContent.push({
content,
extension: path.extname(filePath).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 */
process.stdin.on('end', () => process.exit(0))
process.stdin.resume()
startWatcher()
} else {
buildOnce()
}
}
run(args, configs)

644
src/cli/build.js Normal file
View file

@ -0,0 +1,644 @@
// @ts-check
import fs from 'fs'
import path from 'path'
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 { loadAutoprefixer, loadCssNano, loadPostcss, loadPostcssImport } from './deps'
import log from '../util/log'
import chokidar from 'chokidar'
import fastGlob from 'fast-glob'
import micromatch from 'micromatch'
import normalizePath from 'normalize-path'
import tailwind from '../processTailwindFeatures'
import resolveConfigInternal from '../../resolveConfig'
import getModuleDependencies from '../lib/getModuleDependencies'
import { validateConfig } from '../util/validateConfig.js'
import { parseCandidateFiles } from '../lib/content.js'
let env = {
DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0',
}
export async function build(args, configs) {
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)
}
// TODO: Reference the @config path here if exists
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()
}
})
},
loadPostcssImport(),
(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(/(?<!{[^}]+),/)
let resolvedConfig = resolveConfigInternal(config, { content: { files } })
resolvedConfig.content.files = files
resolvedConfig = validateConfig(resolvedConfig)
return resolvedConfig
}
let resolvedConfig = resolveConfigInternal(config)
resolvedConfig = validateConfig(resolvedConfig)
return resolvedConfig
}
function extractFileGlobs(config) {
let context = {
tailwindConfig: config,
userConfigPath: configPath,
}
let contentPaths = parseCandidateFiles(context, config)
return contentPaths.map((contentPath) => contentPath.pattern)
}
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'] && loadAutoprefixer(),
args['--minify'] && loadCssNano(),
].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'] && loadAutoprefixer(),
args['--minify'] && loadCssNano(),
].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()
let contentPatterns = refreshContentPatterns(config)
/**
* @param {import('../../types/config.js').RequiredConfig} config
* @return {{all: string[], dynamic: string[]}}
**/
function refreshContentPatterns(config) {
// TODO: This can be optimized since we're using `fastGlob.generateTasks` indirectly in extractFileGlobs
let globs = extractFileGlobs(config)
let tasks = fastGlob.generateTasks(globs, { absolute: true })
let dynamicPatterns = tasks.filter((task) => task.dynamic).flatMap((task) => task.patterns)
let staticPatterns = tasks.filter((task) => !task.dynamic).flatMap((task) => task.patterns)
return {
all: [...staticPatterns, ...dynamicPatterns],
dynamic: dynamicPatterns,
}
}
if (input) {
contextDependencies.add(path.resolve(input))
}
watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], {
// Force checking for atomic writes in all situations
// This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
// This only works when watching directories though
atomic: true,
usePolling: shouldPoll,
interval: shouldPoll ? pollInterval : undefined,
ignoreInitial: true,
awaitWriteFinish: shouldCoalesceWriteEvents
? {
stabilityThreshold: 50,
pollInterval: pollInterval,
}
: false,
})
let chain = Promise.resolve()
let pendingRebuilds = new Set()
watcher.on('change', async (file) => {
if (contextDependencies.has(file)) {
env.DEBUG && console.time('Resolve config')
context = null
config = refreshConfig()
contentPatterns = refreshContentPatterns(config)
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)
})
}
})
/**
* When rapidly saving files atomically a couple of situations can happen:
* - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save.
* - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held.
*
* To work around this we retry reading the file a handful of times with a delay between each attempt
*
* @param {string} path
* @param {number} tries
* @returns {Promise<string | undefined>}
* @throws {Error} If the file is still missing or busy after the specified number of tries
*/
async function readFileWithRetries(path, tries = 5) {
for (let n = 0; n <= tries; n++) {
try {
return await fs.promises.readFile(path, 'utf8')
} catch (err) {
if (n !== tries) {
if (err.code === 'ENOENT' || err.code === 'EBUSY') {
await new Promise((resolve) => setTimeout(resolve, 10))
continue
}
}
throw err
}
}
}
// Restore watching any files that are "removed"
// This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
// TODO: An an optimization we should allow removal when the config changes
watcher.on('unlink', (file) => {
file = normalizePath(file)
// Only re-add the file if it's not covered by a dynamic pattern
if (!micromatch.some([file], contentPatterns.dynamic)) {
watcher.add(file)
}
})
// Some applications such as Visual Studio (but not VS Code)
// will only fire a rename event for atomic writes and not a change event
// This is very likely a chokidar bug but it's one we need to work around
// We treat this as a change event and rebuild the CSS
watcher.on('raw', (evt, filePath, meta) => {
if (evt !== 'rename') {
return
}
let watchedPath = meta.watchedPath
// Watched path might be the file itself
// Or the directory it is in
filePath = watchedPath.endsWith(filePath) ? watchedPath : path.join(watchedPath, filePath)
// Skip this event since the files it is for does not match any of the registered content globs
if (!micromatch.some([filePath], contentPatterns.all)) {
return
}
// Skip since we've already queued a rebuild for this file that hasn't happened yet
if (pendingRebuilds.has(filePath)) {
return
}
pendingRebuilds.add(filePath)
chain = chain.then(async () => {
let content
try {
content = await readFileWithRetries(path.resolve(filePath))
} finally {
pendingRebuilds.delete(filePath)
}
changedContent.push({
content,
extension: path.extname(filePath).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 */
process.stdin.on('end', () => process.exit(0))
process.stdin.resume()
startWatcher()
} else {
buildOnce()
}
}
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 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')
}

40
src/cli/deps.js Normal file
View file

@ -0,0 +1,40 @@
// @ts-check
import { lazyPostcss, lazyPostcssImport, lazyCssnano, lazyAutoprefixer } from '../../peers/index.js'
export function loadPostcss() {
// Try to load a local `postcss` version first
try {
return require('postcss')
} catch {}
return lazyPostcss()
}
export function loadPostcssImport() {
try {
return require('postcss-import')
} catch {}
return lazyPostcssImport()
}
export function loadCssNano() {
let options = { preset: ['default', { cssDeclarationSorter: false }] }
// Try to load a local `cssnano` version first
try {
return require('cssnano')
} catch {}
return lazyCssnano()(options)
}
export function loadAutoprefixer() {
// Try to load a local `autoprefixer` version first
try {
return require('autoprefixer')
} catch {}
return lazyAutoprefixer()
}

70
src/cli/help.js Normal file
View file

@ -0,0 +1,70 @@
// @ts-check
import packageJson from '../../package.json'
export 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()
}

50
src/cli/init.js Normal file
View file

@ -0,0 +1,50 @@
// @ts-check
import fs from 'fs'
import path from 'path'
export function init(args, configs) {
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)
}
}
}

View file

@ -10,8 +10,13 @@ let featureFlags = {
'hoverOnlyWhenSupported',
'respectDefaultRingColorOpacity',
'disableColorOpacityUtilitiesByDefault',
'relativeContentPathsByDefault',
],
experimental: [
'optimizeUniversalDefaults',
'matchVariant',
// 'variantGrouping',
],
experimental: ['optimizeUniversalDefaults', 'matchVariant' /* , 'variantGrouping' */],
}
export function flagEnabled(config, flag) {

View file

@ -1,6 +1,7 @@
import setupTrackingContext from './lib/setupTrackingContext'
import processTailwindFeatures from './processTailwindFeatures'
import { env } from './lib/sharedState'
import { findAtConfigPath } from './lib/findAtConfigPath'
module.exports = function tailwindcss(configOrPath) {
return {
@ -13,6 +14,10 @@ module.exports = function tailwindcss(configOrPath) {
return root
},
function (root, result) {
// Use the path for the `@config` directive if it exists, otherwise use the
// path for the file being processed
configOrPath = findAtConfigPath(root, result) ?? configOrPath
let context = setupTrackingContext(configOrPath)
if (root.type === 'document') {

200
src/lib/content.js Normal file
View file

@ -0,0 +1,200 @@
// @ts-check
import fs from 'fs'
import path from 'path'
import isGlob from 'is-glob'
import fastGlob from 'fast-glob'
import normalizePath from 'normalize-path'
import { flagEnabled } from '../featureFlags.js'
import { parseGlob } from '../util/parseGlob'
import { env } from './sharedState'
/** @typedef {import('../../types/config.js').RawFile} RawFile */
/** @typedef {import('../../types/config.js').FilePath} FilePath */
/**
* @typedef {object} ContentPath
* @property {string} original
* @property {string} base
* @property {string | null} glob
* @property {boolean} ignore
* @property {string} pattern
*/
/**
* Turn a list of content paths (absolute or not; glob or not) into a list of
* absolute file paths that exist on the filesystem
*
* If there are symlinks in the path then multiple paths will be returned
* one for the symlink and one for the actual file
*
* @param {*} context
* @param {import('tailwindcss').Config} tailwindConfig
* @returns {ContentPath[]}
*/
export function parseCandidateFiles(context, tailwindConfig) {
let files = tailwindConfig.content.files
// Normalize the file globs
files = files.filter((filePath) => typeof filePath === 'string')
files = files.map(normalizePath)
// Split into included and excluded globs
let tasks = fastGlob.generateTasks(files)
/** @type {ContentPath[]} */
let included = []
/** @type {ContentPath[]} */
let excluded = []
for (const task of tasks) {
included.push(...task.positive.map((filePath) => parseFilePath(filePath, false)))
excluded.push(...task.negative.map((filePath) => parseFilePath(filePath, true)))
}
let paths = [...included, ...excluded]
// Resolve paths relative to the config file or cwd
paths = resolveRelativePaths(context, paths)
// Resolve symlinks if possible
paths = paths.flatMap(resolvePathSymlinks)
// Update cached patterns
paths = paths.map(resolveGlobPattern)
return paths
}
/**
*
* @param {string} filePath
* @param {boolean} ignore
* @returns {ContentPath}
*/
function parseFilePath(filePath, ignore) {
let contentPath = {
original: filePath,
base: filePath,
ignore,
pattern: filePath,
glob: null,
}
if (isGlob(filePath)) {
Object.assign(contentPath, parseGlob(filePath))
}
return contentPath
}
/**
*
* @param {ContentPath} contentPath
* @returns {ContentPath}
*/
function resolveGlobPattern(contentPath) {
contentPath.pattern = contentPath.glob
? `${contentPath.base}/${contentPath.glob}`
: contentPath.base
contentPath.pattern = contentPath.ignore ? `!${contentPath.pattern}` : contentPath.pattern
return contentPath
}
/**
* Resolve each path relative to the config file (when possible) if the experimental flag is enabled
* Otherwise, resolve relative to the current working directory
*
* @param {any} context
* @param {ContentPath[]} contentPaths
* @returns {ContentPath[]}
*/
function resolveRelativePaths(context, contentPaths) {
let resolveFrom = []
// Resolve base paths relative to the config file (when possible) if the experimental flag is enabled
if (context.userConfigPath && context.tailwindConfig.content.relative) {
resolveFrom = [path.dirname(context.userConfigPath)]
}
return contentPaths.map((contentPath) => {
contentPath.base = path.resolve(...resolveFrom, contentPath.base)
return contentPath
})
}
/**
* Resolve the symlink for the base directory / file in each path
* These are added as additional dependencies to watch for changes because
* some tools (like webpack) will only watch the actual file or directory
* but not the symlink itself even in projects that use monorepos.
*
* @param {ContentPath} contentPath
* @returns {ContentPath[]}
*/
function resolvePathSymlinks(contentPath) {
let paths = [contentPath]
try {
let resolvedPath = fs.realpathSync(contentPath.base)
if (resolvedPath !== contentPath.base) {
paths.push({
...contentPath,
base: resolvedPath,
})
}
} catch {
// TODO: log this?
}
return paths
}
/**
* @param {any} context
* @param {ContentPath[]} candidateFiles
* @param {Map<string, number>} fileModifiedMap
* @returns {{ content: string, extension: string }[]}
*/
export function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
let changedContent = context.tailwindConfig.content.files
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension = 'html' }) => ({ content: raw, extension }))
for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
changedContent.push({ content, extension })
}
return changedContent
}
/**
*
* @param {ContentPath[]} candidateFiles
* @param {Map<string, number>} fileModifiedMap
* @returns {Set<string>}
*/
function resolveChangedFiles(candidateFiles, fileModifiedMap) {
let paths = candidateFiles.map((contentPath) => contentPath.pattern)
let changedFiles = new Set()
env.DEBUG && console.time('Finding changed files')
let files = fastGlob.sync(paths, { absolute: true })
for (let file of files) {
let prevModified = fileModifiedMap.has(file) ? fileModifiedMap.get(file) : -Infinity
let modified = fs.statSync(file).mtimeMs
if (modified > prevModified) {
changedFiles.add(file)
fileModifiedMap.set(file, modified)
}
}
env.DEBUG && console.timeEnd('Finding changed files')
return changedFiles
}

View file

@ -0,0 +1,40 @@
import path from 'path'
/**
* Find the @config at-rule in the given CSS AST and return the relative path to the config file
*
* @param {import('postcss').Root} root
* @param {import('postcss').Result} result
*/
export function findAtConfigPath(root, result) {
let configPath = null
let relativeTo = root.source?.input.file ?? result.opts.from ?? null
root.walkAtRules('config', (rule) => {
if (relativeTo === undefined) {
throw rule.error(
'The `@config` at-rule cannot be used without a `from` option being set on the PostCSS config.'
)
}
if (configPath) {
throw rule.error('Only `@config` at-rule is allowed per file.')
}
let matches = rule.params.match(/(['"])(.*?)\1/)
if (!matches) {
throw rule.error(
'The `@config` at-rule must be followed by a string containing the path to the config file.'
)
}
configPath = matches[2]
if (path.isAbsolute(configPath)) {
throw rule.error('The `@config` at-rule cannot be used with an absolute path.')
}
rule.remove()
})
return configPath ? path.resolve(path.dirname(relativeTo), configPath) : null
}

View file

@ -1000,6 +1000,10 @@ export function getContext(
let context = createContext(tailwindConfig, [], root)
Object.assign(context, {
userConfigPath,
})
trackModified([...contextDependencies], getFileModifiedMap(context))
// ---

View file

@ -1,22 +1,14 @@
import fs from 'fs'
import path from 'path'
import fastGlob from 'fast-glob'
import LRU from 'quick-lru'
import normalizePath from 'normalize-path'
import hash from '../util/hashConfig'
import getModuleDependencies from '../lib/getModuleDependencies'
import resolveConfig from '../public/resolve-config'
import resolveConfigPath from '../util/resolveConfigPath'
import { env } from './sharedState'
import { getContext, getFileModifiedMap } from './setupContextUtils'
import parseDependency from '../util/parseDependency'
import { validateConfig } from '../util/validateConfig.js'
import { parseCandidateFiles, resolvedChangedContent } from './content.js'
let configPathCache = new LRU({ maxSize: 100 })
@ -27,9 +19,7 @@ function getCandidateFiles(context, tailwindConfig) {
return candidateFilesCache.get(context)
}
let candidateFiles = tailwindConfig.content.files
.filter((item) => typeof item === 'string')
.map((contentPath) => normalizePath(contentPath))
let candidateFiles = parseCandidateFiles(context, tailwindConfig)
return candidateFilesCache.set(context, candidateFiles).get(context)
}
@ -80,36 +70,6 @@ function getTailwindConfig(configOrPath) {
return [newConfig, null, hash(newConfig), []]
}
function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
let changedContent = context.tailwindConfig.content.files
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension = 'html' }) => ({ content: raw, extension }))
for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
changedContent.push({ content, extension })
}
return changedContent
}
function resolveChangedFiles(candidateFiles, fileModifiedMap) {
let changedFiles = new Set()
env.DEBUG && console.time('Finding changed files')
let files = fastGlob.sync(candidateFiles, { absolute: true })
for (let file of files) {
let prevModified = fileModifiedMap.has(file) ? fileModifiedMap.get(file) : -Infinity
let modified = fs.statSync(file).mtimeMs
if (modified > prevModified) {
changedFiles.add(file)
fileModifiedMap.set(file, modified)
}
}
env.DEBUG && console.timeEnd('Finding changed files')
return changedFiles
}
// DISABLE_TOUCH = TRUE
// Retrieve an existing context from cache if possible (since contexts are unique per
@ -161,9 +121,8 @@ export default function setupTrackingContext(configOrPath) {
let fileModifiedMap = getFileModifiedMap(context)
// Add template paths as postcss dependencies.
for (let fileOrGlob of candidateFiles) {
let dependency = parseDependency(fileOrGlob)
if (dependency) {
for (let contentPath of candidateFiles) {
for (let dependency of parseDependency(contentPath)) {
registerDependency(dependency)
}
}

View file

@ -56,9 +56,11 @@ export function normalizeConfig(config) {
// When `config.content` is an object
if (typeof config.content === 'object' && config.content !== null) {
// Only `files`, `extract` and `transform` can exist in `config.content`
// Only `files`, `relative`, `extract`, and `transform` can exist in `config.content`
if (
Object.keys(config.content).some((key) => !['files', 'extract', 'transform'].includes(key))
Object.keys(config.content).some(
(key) => !['files', 'relative', 'extract', 'transform'].includes(key)
)
) {
return false
}
@ -112,6 +114,14 @@ export function normalizeConfig(config) {
) {
return false
}
// `config.content.relative` is optional and can be a boolean
if (
typeof config.content.relative !== 'boolean' &&
typeof config.content.relative !== 'undefined'
) {
return false
}
}
return true
@ -154,6 +164,16 @@ export function normalizeConfig(config) {
// Normalize the `content`
config.content = {
relative: (() => {
let { content } = config
if (content?.relative) {
return content.relative
}
return config.future?.relativeContentPathsByDefault ?? false
})(),
files: (() => {
let { content, purge } = config

View file

@ -1,49 +1,44 @@
import isGlob from 'is-glob'
import globParent from 'glob-parent'
import path from 'path'
// @ts-check
// Based on `glob-base`
// https://github.com/micromatch/glob-base/blob/master/index.js
function parseGlob(pattern) {
let glob = pattern
let base = globParent(pattern)
/**
* @typedef {{type: 'dependency', file: string} | {type: 'dir-dependency', dir: string, glob: string}} Dependency
*/
if (base !== '.') {
glob = pattern.substr(base.length)
if (glob.charAt(0) === '/') {
glob = glob.substr(1)
}
/**
*
* @param {import('../lib/content.js').ContentPath} contentPath
* @returns {Dependency[]}
*/
export default function parseDependency(contentPath) {
if (contentPath.ignore) {
return []
}
if (glob.substr(0, 2) === './') {
glob = glob.substr(2)
}
if (glob.charAt(0) === '/') {
glob = glob.substr(1)
if (!contentPath.glob) {
return [
{
type: 'dependency',
file: contentPath.base,
},
]
}
return { base, glob }
}
export default function parseDependency(normalizedFileOrGlob) {
if (normalizedFileOrGlob.startsWith('!')) {
return null
}
let message
if (isGlob(normalizedFileOrGlob)) {
let { base, glob } = parseGlob(normalizedFileOrGlob)
message = { type: 'dir-dependency', dir: path.resolve(base), glob }
} else {
message = { type: 'dependency', file: path.resolve(normalizedFileOrGlob) }
}
// rollup-plugin-postcss does not support dir-dependency messages
// but directories can be watched in the same way as files
if (message.type === 'dir-dependency' && process.env.ROLLUP_WATCH === 'true') {
message = { type: 'dependency', file: message.dir }
}
return message
if (process.env.ROLLUP_WATCH === 'true') {
// rollup-plugin-postcss does not support dir-dependency messages
// but directories can be watched in the same way as files
return [
{
type: 'dependency',
file: contentPath.base,
},
]
}
return [
{
type: 'dir-dependency',
dir: contentPath.base,
glob: contentPath.glob,
},
]
}

24
src/util/parseGlob.js Normal file
View file

@ -0,0 +1,24 @@
import globParent from 'glob-parent'
// Based on `glob-base`
// https://github.com/micromatch/glob-base/blob/master/index.js
export function parseGlob(pattern) {
let glob = pattern
let base = globParent(pattern)
if (base !== '.') {
glob = pattern.substr(base.length)
if (glob.charAt(0) === '/') {
glob = glob.substr(1)
}
}
if (glob.substr(0, 2) === './') {
glob = glob.substr(2)
}
if (glob.charAt(0) === '/') {
glob = glob.substr(1)
}
return { base, glob }
}

1
types/config.d.ts vendored
View file

@ -31,6 +31,7 @@ type ContentConfig =
| (FilePath | RawFile)[]
| {
files: (FilePath | RawFile)[]
relative?: boolean
extract?: ExtractorFn | { [extension: string]: ExtractorFn }
transform?: TransformerFn | { [extension: string]: TransformerFn }
}