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

View file

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

View file

@ -1,28 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import { lazyPostcss, lazyPostcssImport, lazyCssnano, lazyAutoprefixer } from '../peers/index.js'
import chokidar from 'chokidar'
import path from 'path' import path from 'path'
import arg from 'arg' import arg from 'arg'
import fs from 'fs' 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 = { import { help } from './cli/help.js'
DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0', import { init } from './cli/init.js'
} import { build } from './cli/build.js'
function isESM() { function isESM() {
const pkgPath = path.resolve('./package.json') 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) { function oneOf(...options) {
return Object.assign( return Object.assign(
(value = true) => { (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 = { let commands = {
init: { init: {
run: init, run: init,
@ -351,675 +220,4 @@ if (args['--help']) {
process.exit(0) process.exit(0)
} }
run() run(args, configs)
// ---
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()
}
}

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', 'hoverOnlyWhenSupported',
'respectDefaultRingColorOpacity', 'respectDefaultRingColorOpacity',
'disableColorOpacityUtilitiesByDefault', 'disableColorOpacityUtilitiesByDefault',
'relativeContentPathsByDefault',
],
experimental: [
'optimizeUniversalDefaults',
'matchVariant',
// 'variantGrouping',
], ],
experimental: ['optimizeUniversalDefaults', 'matchVariant' /* , 'variantGrouping' */],
} }
export function flagEnabled(config, flag) { export function flagEnabled(config, flag) {

View file

@ -1,6 +1,7 @@
import setupTrackingContext from './lib/setupTrackingContext' import setupTrackingContext from './lib/setupTrackingContext'
import processTailwindFeatures from './processTailwindFeatures' import processTailwindFeatures from './processTailwindFeatures'
import { env } from './lib/sharedState' import { env } from './lib/sharedState'
import { findAtConfigPath } from './lib/findAtConfigPath'
module.exports = function tailwindcss(configOrPath) { module.exports = function tailwindcss(configOrPath) {
return { return {
@ -13,6 +14,10 @@ module.exports = function tailwindcss(configOrPath) {
return root return root
}, },
function (root, result) { 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) let context = setupTrackingContext(configOrPath)
if (root.type === 'document') { 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) let context = createContext(tailwindConfig, [], root)
Object.assign(context, {
userConfigPath,
})
trackModified([...contextDependencies], getFileModifiedMap(context)) trackModified([...contextDependencies], getFileModifiedMap(context))
// --- // ---

View file

@ -1,22 +1,14 @@
import fs from 'fs' import fs from 'fs'
import path from 'path'
import fastGlob from 'fast-glob'
import LRU from 'quick-lru' import LRU from 'quick-lru'
import normalizePath from 'normalize-path'
import hash from '../util/hashConfig' import hash from '../util/hashConfig'
import getModuleDependencies from '../lib/getModuleDependencies' import getModuleDependencies from '../lib/getModuleDependencies'
import resolveConfig from '../public/resolve-config' import resolveConfig from '../public/resolve-config'
import resolveConfigPath from '../util/resolveConfigPath' import resolveConfigPath from '../util/resolveConfigPath'
import { env } from './sharedState'
import { getContext, getFileModifiedMap } from './setupContextUtils' import { getContext, getFileModifiedMap } from './setupContextUtils'
import parseDependency from '../util/parseDependency' import parseDependency from '../util/parseDependency'
import { validateConfig } from '../util/validateConfig.js' import { validateConfig } from '../util/validateConfig.js'
import { parseCandidateFiles, resolvedChangedContent } from './content.js'
let configPathCache = new LRU({ maxSize: 100 }) let configPathCache = new LRU({ maxSize: 100 })
@ -27,9 +19,7 @@ function getCandidateFiles(context, tailwindConfig) {
return candidateFilesCache.get(context) return candidateFilesCache.get(context)
} }
let candidateFiles = tailwindConfig.content.files let candidateFiles = parseCandidateFiles(context, tailwindConfig)
.filter((item) => typeof item === 'string')
.map((contentPath) => normalizePath(contentPath))
return candidateFilesCache.set(context, candidateFiles).get(context) return candidateFilesCache.set(context, candidateFiles).get(context)
} }
@ -80,36 +70,6 @@ function getTailwindConfig(configOrPath) {
return [newConfig, null, hash(newConfig), []] 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 // DISABLE_TOUCH = TRUE
// Retrieve an existing context from cache if possible (since contexts are unique per // 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) let fileModifiedMap = getFileModifiedMap(context)
// Add template paths as postcss dependencies. // Add template paths as postcss dependencies.
for (let fileOrGlob of candidateFiles) { for (let contentPath of candidateFiles) {
let dependency = parseDependency(fileOrGlob) for (let dependency of parseDependency(contentPath)) {
if (dependency) {
registerDependency(dependency) registerDependency(dependency)
} }
} }

View file

@ -56,9 +56,11 @@ export function normalizeConfig(config) {
// When `config.content` is an object // When `config.content` is an object
if (typeof config.content === 'object' && config.content !== null) { 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 ( 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 return false
} }
@ -112,6 +114,14 @@ export function normalizeConfig(config) {
) { ) {
return false 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 return true
@ -154,6 +164,16 @@ export function normalizeConfig(config) {
// Normalize the `content` // Normalize the `content`
config.content = { config.content = {
relative: (() => {
let { content } = config
if (content?.relative) {
return content.relative
}
return config.future?.relativeContentPathsByDefault ?? false
})(),
files: (() => { files: (() => {
let { content, purge } = config let { content, purge } = config

View file

@ -1,49 +1,44 @@
import isGlob from 'is-glob' // @ts-check
import globParent from 'glob-parent'
import path from 'path'
// Based on `glob-base` /**
// https://github.com/micromatch/glob-base/blob/master/index.js * @typedef {{type: 'dependency', file: string} | {type: 'dir-dependency', dir: string, glob: string}} Dependency
function parseGlob(pattern) { */
let glob = pattern
let base = globParent(pattern)
if (base !== '.') { /**
glob = pattern.substr(base.length) *
if (glob.charAt(0) === '/') { * @param {import('../lib/content.js').ContentPath} contentPath
glob = glob.substr(1) * @returns {Dependency[]}
} */
export default function parseDependency(contentPath) {
if (contentPath.ignore) {
return []
} }
if (glob.substr(0, 2) === './') { if (!contentPath.glob) {
glob = glob.substr(2) return [
} {
if (glob.charAt(0) === '/') { type: 'dependency',
glob = glob.substr(1) file: contentPath.base,
},
]
} }
return { base, glob } 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
export default function parseDependency(normalizedFileOrGlob) { return [
if (normalizedFileOrGlob.startsWith('!')) { {
return null type: 'dependency',
} file: contentPath.base,
},
let message ]
}
if (isGlob(normalizedFileOrGlob)) {
let { base, glob } = parseGlob(normalizedFileOrGlob) return [
message = { type: 'dir-dependency', dir: path.resolve(base), glob } {
} else { type: 'dir-dependency',
message = { type: 'dependency', file: path.resolve(normalizedFileOrGlob) } dir: contentPath.base,
} glob: contentPath.glob,
},
// 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
} }

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)[] | (FilePath | RawFile)[]
| { | {
files: (FilePath | RawFile)[] files: (FilePath | RawFile)[]
relative?: boolean
extract?: ExtractorFn | { [extension: string]: ExtractorFn } extract?: ExtractorFn | { [extension: string]: ExtractorFn }
transform?: TransformerFn | { [extension: string]: TransformerFn } transform?: TransformerFn | { [extension: string]: TransformerFn }
} }