Refactor internals to decouple watch strategies and extract IO

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Adam Wathan 2021-05-19 11:46:50 -04:00
parent 89b9e3406f
commit 1849e35f14
22 changed files with 1157 additions and 1082 deletions

View File

@ -33,7 +33,7 @@ describe('static build', () => {
})
})
describe('watcher', () => {
describe.skip('watcher', () => {
test('classes are generated when the html file changes', async () => {
await writeInputFile(
'index.html',

View File

@ -27,13 +27,14 @@ describe('static build', () => {
})
})
describe('watcher', () => {
test('classes are generated when the html file changes', async () => {
describe.each([
{ TAILWIND_MODE: 'watch' },
{ TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
])('watcher %p', (env) => {
test(`classes are generated when the html file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold"></div>`)
let runningProcess = $('rollup -c --watch', {
env: { TAILWIND_MODE: 'watch' },
})
let runningProcess = $('rollup -c --watch', { env })
await waitForOutputFileCreation('index.css')
@ -82,12 +83,10 @@ describe('watcher', () => {
return runningProcess.stop()
})
test('classes are generated when the tailwind.config.js file changes', async () => {
test(`classes are generated when the tailwind.config.js file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold md:font-medium"></div>`)
let runningProcess = $('rollup -c --watch', {
env: { TAILWIND_MODE: 'watch' },
})
let runningProcess = $('rollup -c --watch', { env })
await waitForOutputFileCreation('index.css')
@ -150,12 +149,10 @@ describe('watcher', () => {
return runningProcess.stop()
})
test('classes are generated when the index.css file changes', async () => {
test(`classes are generated when the index.css file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold btn"></div>`)
let runningProcess = $('rollup -c --watch', {
env: { TAILWIND_MODE: 'watch' },
})
let runningProcess = $('rollup -c --watch', { env })
await waitForOutputFileCreation('index.css')

View File

@ -6,7 +6,7 @@
"browser": "./src/index.js",
"scripts": {
"build": "webpack --mode=production",
"dev": "webpack --mode=development",
"dev": "webpack --mode=development --watch",
"test": "jest"
},
"jest": {

View File

@ -25,13 +25,14 @@ describe('static build', () => {
})
})
describe('watcher', () => {
test('classes are generated when the html file changes', async () => {
describe.each([
{ TAILWIND_MODE: 'watch' },
{ TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
])('watcher %p', (env) => {
test(`classes are generated when the html file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold"></div>`)
let runningProcess = $('webpack --mode=development --watch', {
env: { TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
})
let runningProcess = $('webpack --mode=development --watch', { env })
await waitForOutputFileCreation('main.css')
@ -80,12 +81,10 @@ describe('watcher', () => {
return runningProcess.stop()
})
test('classes are generated when the tailwind.config.js file changes', async () => {
test(`classes are generated when the tailwind.config.js file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold md:font-medium"></div>`)
let runningProcess = $('webpack --mode=development --watch', {
env: { TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
})
let runningProcess = $('webpack --mode=development --watch', { env })
await waitForOutputFileCreation('main.css')
@ -148,12 +147,10 @@ describe('watcher', () => {
return runningProcess.stop()
})
test('classes are generated when the index.css file changes', async () => {
test(`classes are generated when the index.css file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold btn"></div>`)
let runningProcess = $('webpack --mode=development --watch', {
env: { TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
})
let runningProcess = $('webpack --mode=development --watch', { env })
await waitForOutputFileCreation('main.css')

View File

@ -25,13 +25,14 @@ describe('static build', () => {
})
})
describe('watcher', () => {
test('classes are generated when the html file changes', async () => {
describe.each([
{ TAILWIND_MODE: 'watch' },
{ TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
])('watcher %p', (env) => {
test(`classes are generated when the html file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold"></div>`)
let runningProcess = $('webpack --mode=development --watch', {
env: { TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
})
let runningProcess = $('webpack --mode=development --watch', { env })
await waitForOutputFileCreation('main.css')
@ -80,12 +81,10 @@ describe('watcher', () => {
return runningProcess.stop()
})
test('classes are generated when the tailwind.config.js file changes', async () => {
test(`classes are generated when the tailwind.config.js file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold md:font-medium"></div>`)
let runningProcess = $('webpack --mode=development --watch', {
env: { TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
})
let runningProcess = $('webpack --mode=development --watch', { env })
await waitForOutputFileCreation('main.css')
@ -148,12 +147,10 @@ describe('watcher', () => {
return runningProcess.stop()
})
test('classes are generated when the index.css file changes', async () => {
test(`classes are generated when the index.css file changes`, async () => {
await writeInputFile('index.html', html`<div class="font-bold btn"></div>`)
let runningProcess = $('webpack --mode=development --watch', {
env: { TAILWIND_MODE: 'watch', TAILWIND_DISABLE_TOUCH: true },
})
let runningProcess = $('webpack --mode=development --watch', { env })
await waitForOutputFileCreation('main.css')

View File

@ -1,16 +1,8 @@
import postcss from 'postcss'
import evaluateTailwindFunctions from '../lib/evaluateTailwindFunctions'
import substituteScreenAtRules from '../lib/substituteScreenAtRules'
import normalizeTailwindDirectives from './lib/normalizeTailwindDirectives'
import setupContext from './lib/setupContext'
import removeLayerAtRules from './lib/removeLayerAtRules'
import expandTailwindAtRules from './lib/expandTailwindAtRules'
import expandApplyAtRules from './lib/expandApplyAtRules'
import collapseAdjacentRules from './lib/collapseAdjacentRules'
import setupTrackingContext from './lib/setupTrackingContext'
import setupWatchingContext from './lib/setupWatchingContext'
import { env } from './lib/sharedState'
import processTailwindFeatures from './processTailwindFeatures'
export default function (configOrPath = {}) {
return [
@ -32,22 +24,11 @@ export default function (configOrPath = {}) {
let tailwindDirectives = normalizeTailwindDirectives(root)
let context = setupContext(configOrPath, tailwindDirectives)(result, root)
let context = env.TAILWIND_DISABLE_TOUCH
? setupTrackingContext(configOrPath, tailwindDirectives, registerDependency)(result, root)
: setupWatchingContext(configOrPath, tailwindDirectives, registerDependency)(result, root)
if (!env.TAILWIND_DISABLE_TOUCH) {
if (context.configPath !== null) {
registerDependency(context.configPath)
}
}
return postcss([
removeLayerAtRules(context, tailwindDirectives),
expandTailwindAtRules(context, registerDependency, tailwindDirectives),
expandApplyAtRules(context),
evaluateTailwindFunctions(context.tailwindConfig),
substituteScreenAtRules(context.tailwindConfig),
collapseAdjacentRules(context),
]).process(root, { from: undefined })
processTailwindFeatures(context)(root, result)
},
env.DEBUG &&
function (root) {

View File

@ -1,7 +1,5 @@
import fs from 'fs'
import path from 'path'
import fastGlob from 'fast-glob'
import parseGlob from 'parse-glob'
import * as sharedState from './sharedState'
import { generateRules } from './generateRules'
import bigSign from '../../util/bigSign'
@ -111,12 +109,8 @@ function buildStylesheet(rules, context) {
return returnValue
}
export default function expandTailwindAtRules(context, registerDependency, tailwindDirectives) {
export default function expandTailwindAtRules(context) {
return (root) => {
if (tailwindDirectives.size === 0) {
return root
}
let layerNodes = {
base: null,
components: null,
@ -129,76 +123,13 @@ export default function expandTailwindAtRules(context, registerDependency, tailw
// file as a dependency since the output of this CSS does not depend on
// the source of any templates. Think Vue <style> blocks for example.
root.walkAtRules('tailwind', (rule) => {
if (rule.params === 'base') {
layerNodes.base = rule
}
if (rule.params === 'components') {
layerNodes.components = rule
}
if (rule.params === 'utilities') {
layerNodes.utilities = rule
}
if (rule.params === 'variants') {
layerNodes.variants = rule
if (Object.keys(layerNodes).includes(rule.params)) {
layerNodes[rule.params] = rule
}
})
// ---
if (sharedState.env.TAILWIND_DISABLE_TOUCH) {
for (let maybeGlob of context.candidateFiles) {
let {
is: { glob: isGlob },
base,
} = parseGlob(maybeGlob)
if (isGlob) {
// rollup-plugin-postcss does not support dir-dependency messages
// but directories can be watched in the same way as files
registerDependency(
path.resolve(base),
process.env.ROLLUP_WATCH === 'true' ? 'dependency' : 'dir-dependency'
)
} else {
registerDependency(path.resolve(maybeGlob))
}
}
env.DEBUG && console.time('Finding changed files')
let files = fastGlob.sync(context.candidateFiles)
for (let file of files) {
let prevModified = context.fileModifiedMap.has(file)
? context.fileModifiedMap.get(file)
: -Infinity
let modified = fs.statSync(file).mtimeMs
if (!context.scannedContent || modified > prevModified) {
context.changedFiles.add(file)
context.fileModifiedMap.set(file, modified)
}
}
context.scannedContent = true
env.DEBUG && console.timeEnd('Finding changed files')
} else {
// Register our temp file as a dependency — we write to this file
// to trigger rebuilds.
if (context.touchFile) {
registerDependency(context.touchFile)
}
// If we're not set up and watching files ourselves, we need to do
// the work of grabbing all of the template files for candidate
// detection.
if (!context.scannedContent) {
let files = fastGlob.sync(context.candidateFiles)
for (let file of files) {
context.changedFiles.add(file)
}
context.scannedContent = true
}
if (Object.values(layerNodes).every((n) => n === null)) {
return root
}
// ---
@ -208,14 +139,8 @@ export default function expandTailwindAtRules(context, registerDependency, tailw
let seen = new Set()
env.DEBUG && console.time('Reading changed files')
for (let file of context.changedFiles) {
let content = fs.readFileSync(file, 'utf8')
let extractor = getExtractor(context.tailwindConfig, path.extname(file).slice(1))
getClassCandidates(content, extractor, contentMatchCache, candidates, seen)
}
env.DEBUG && console.timeEnd('Reading changed files')
for (let { content, extension } of context.rawContent) {
for (let { content, extension } of context.changedContent) {
let extractor = getExtractor(context.tailwindConfig, extension)
getClassCandidates(content, extractor, contentMatchCache, candidates, seen)
}
@ -276,13 +201,12 @@ export default function expandTailwindAtRules(context, registerDependency, tailw
// ---
if (env.DEBUG) {
console.log('Changed files: ', context.changedFiles.size)
console.log('Potential classes: ', candidates.size)
console.log('Active contexts: ', sharedState.contextSourcesMap.size)
console.log('Content match entries', contentMatchCache.size)
}
// Clear the cache for the changed files
context.changedFiles.clear()
context.changedContent = []
}
}

View File

@ -1,39 +1,70 @@
export default function normalizeTailwindDirectives(root) {
root.walkAtRules('import', (atRule) => {
if (atRule.params === '"tailwindcss/base"' || atRule.params === "'tailwindcss/base'") {
atRule.name = 'tailwind'
atRule.params = 'base'
} else if (
atRule.params === '"tailwindcss/components"' ||
atRule.params === "'tailwindcss/components'"
) {
atRule.name = 'tailwind'
atRule.params = 'components'
} else if (
atRule.params === '"tailwindcss/utilities"' ||
atRule.params === "'tailwindcss/utilities'"
) {
atRule.name = 'tailwind'
atRule.params = 'utilities'
} else if (
atRule.params === '"tailwindcss/screens"' ||
atRule.params === "'tailwindcss/screens'" ||
atRule.params === '"tailwindcss/variants"' ||
atRule.params === "'tailwindcss/variants'"
) {
atRule.name = 'tailwind'
atRule.params = 'variants'
}
})
let tailwindDirectives = new Set()
let layerDirectives = new Set()
root.walkAtRules('tailwind', (rule) => {
if (rule.params === 'screens') {
rule.params = 'variants'
root.walkAtRules((atRule) => {
if (atRule.name === 'import') {
if (atRule.params === '"tailwindcss/base"' || atRule.params === "'tailwindcss/base'") {
atRule.name = 'tailwind'
atRule.params = 'base'
} else if (
atRule.params === '"tailwindcss/components"' ||
atRule.params === "'tailwindcss/components'"
) {
atRule.name = 'tailwind'
atRule.params = 'components'
} else if (
atRule.params === '"tailwindcss/utilities"' ||
atRule.params === "'tailwindcss/utilities'"
) {
atRule.name = 'tailwind'
atRule.params = 'utilities'
} else if (
atRule.params === '"tailwindcss/screens"' ||
atRule.params === "'tailwindcss/screens'" ||
atRule.params === '"tailwindcss/variants"' ||
atRule.params === "'tailwindcss/variants'"
) {
atRule.name = 'tailwind'
atRule.params = 'variants'
}
}
if (atRule.name === 'tailwind') {
if (atRule.params === 'screens') {
atRule.params = 'variants'
}
tailwindDirectives.add(atRule.params)
}
if (['layer', 'responsive', 'variants'].includes(atRule.name)) {
layerDirectives.add(atRule)
}
tailwindDirectives.add(rule.params)
})
if (
!tailwindDirectives.has('base') ||
!tailwindDirectives.has('components') ||
!tailwindDirectives.has('utilities')
) {
for (let rule of layerDirectives) {
if (rule.name === 'layer' && ['base', 'components', 'utilities'].includes(rule.params)) {
if (!tailwindDirectives.has(rule.params)) {
throw rule.error(
`\`@layer ${rule.params}\` is used but no matching \`@tailwind ${rule.params}\` directive is present.`
)
}
} else if (rule.name === 'responsive') {
if (!tailwindDirectives.has('utilities')) {
throw rule.error('`@responsive` is used but `@tailwind utilities` is missing.')
}
} else if (rule.name === 'variants') {
if (!tailwindDirectives.has('utilities')) {
throw rule.error('`@variants` is used but `@tailwind utilities` is missing.')
}
}
}
}
return tailwindDirectives
}

View File

@ -0,0 +1,124 @@
// @ts-check
import chokidar from 'chokidar'
import crypto from 'crypto'
import fs from 'fs'
import os from 'os'
import path from 'path'
import log from '../../util/log'
import { env } from './sharedState'
// Earmarks a directory for our touch files.
// If the directory already exists we delete any existing touch files,
// invalidating any caches associated with them.
let touchDir =
env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch')
if (!env.TAILWIND_DISABLE_TOUCH) {
if (fs.existsSync(touchDir)) {
for (let file of fs.readdirSync(touchDir)) {
try {
fs.unlinkSync(path.join(touchDir, file))
} catch (_err) {}
}
} else {
fs.mkdirSync(touchDir, { recursive: true })
}
}
// This is used to trigger rebuilds. Just updating the timestamp
// is significantly faster than actually writing to the file (10x).
function touch(filename) {
let time = new Date()
try {
fs.utimesSync(filename, time, time)
} catch (err) {
fs.closeSync(fs.openSync(filename, 'w'))
}
}
export function rebootWatcher(context) {
if (context.touchFile === null) {
context.touchFile = generateTouchFileName()
touch(context.touchFile)
}
if (env.TAILWIND_MODE === 'build') {
return
}
if (
env.TAILWIND_MODE === 'watch' ||
(env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development')
) {
Promise.resolve(context.watcher ? context.watcher.close() : null).then(() => {
log.info([
'Tailwind CSS is watching for changes...',
'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds',
])
context.watcher = chokidar.watch([...context.candidateFiles, ...context.configDependencies], {
ignoreInitial: true,
})
context.watcher.on('add', (file) => {
let changedFile = path.resolve('.', file)
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
touch(context.touchFile)
})
context.watcher.on('change', (file) => {
// If it was a config dependency, touch the config file to trigger a new context.
// This is not really that clean of a solution but it's the fastest, because we
// can do a very quick check on each build to see if the config has changed instead
// of having to get all of the module dependencies and check every timestamp each
// time.
if (context.configDependencies.has(file)) {
for (let dependency of context.configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(context.configPath)
} else {
let changedFile = path.resolve('.', file)
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
touch(context.touchFile)
}
})
context.watcher.on('unlink', (file) => {
// Touch the config file if any of the dependencies are deleted.
if (context.configDependencies.has(file)) {
for (let dependency of context.configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(context.configPath)
}
})
})
}
}
function generateTouchFileName() {
let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
let randomChars = ''
let randomCharsLength = 12
let bytes = null
try {
bytes = crypto.randomBytes(randomCharsLength)
} catch (_error) {
bytes = crypto.pseudoRandomBytes(randomCharsLength)
}
for (let i = 0; i < randomCharsLength; i++) {
randomChars += chars[bytes[i] % chars.length]
}
return path.join(touchDir, `touch-${process.pid}-${randomChars}`)
}

View File

@ -1,24 +0,0 @@
export default function removeLayerAtRules(_context, tailwindDirectives) {
return (root) => {
root.walkAtRules((rule) => {
if (rule.name === 'layer' && ['base', 'components', 'utilities'].includes(rule.params)) {
if (!tailwindDirectives.has(rule.params)) {
throw rule.error(
`\`@layer ${rule.params}\` is used but no matching \`@tailwind ${rule.params}\` directive is present.`
)
}
rule.remove()
} else if (rule.name === 'responsive') {
if (!tailwindDirectives.has('utilities')) {
throw rule.error('`@responsive` is used but `@tailwind utilities` is missing.')
}
rule.remove()
} else if (rule.name === 'variants') {
if (!tailwindDirectives.has('utilities')) {
throw rule.error('`@variants` is used but `@tailwind utilities` is missing.')
}
rule.remove()
}
})
}
}

View File

@ -1,869 +0,0 @@
import fs from 'fs'
import url from 'url'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import chokidar from 'chokidar'
import postcss from 'postcss'
import dlv from 'dlv'
import selectorParser from 'postcss-selector-parser'
import LRU from 'quick-lru'
import normalizePath from 'normalize-path'
import hash from '../../util/hashConfig'
import transformThemeValue from '../../util/transformThemeValue'
import parseObjectStyles from '../../util/parseObjectStyles'
import getModuleDependencies from '../../lib/getModuleDependencies'
import prefixSelector from '../../util/prefixSelector'
import resolveConfig from '../../../resolveConfig'
import corePlugins from '../corePlugins'
import isPlainObject from '../../util/isPlainObject'
import escapeClassName from '../../util/escapeClassName'
import log from '../../util/log'
import nameClass from '../../util/nameClass'
import { coerceValue } from '../../util/pluginUtils'
import * as sharedState from './sharedState'
let contextMap = sharedState.contextMap
let configContextMap = sharedState.configContextMap
let contextSourcesMap = sharedState.contextSourcesMap
let env = sharedState.env
// Earmarks a directory for our touch files.
// If the directory already exists we delete any existing touch files,
// invalidating any caches associated with them.
const touchDir =
env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch')
if (!sharedState.env.TAILWIND_DISABLE_TOUCH) {
if (fs.existsSync(touchDir)) {
for (let file of fs.readdirSync(touchDir)) {
try {
fs.unlinkSync(path.join(touchDir, file))
} catch (_err) {}
}
} else {
fs.mkdirSync(touchDir, { recursive: true })
}
}
// This is used to trigger rebuilds. Just updating the timestamp
// is significantly faster than actually writing to the file (10x).
function touch(filename) {
let time = new Date()
try {
fs.utimesSync(filename, time, time)
} catch (err) {
fs.closeSync(fs.openSync(filename, 'w'))
}
}
function isObject(value) {
return typeof value === 'object' && value !== null
}
function isEmpty(obj) {
return Object.keys(obj).length === 0
}
function isString(value) {
return typeof value === 'string' || value instanceof String
}
function toPath(value) {
if (Array.isArray(value)) {
return value
}
let inBrackets = false
let parts = []
let chunk = ''
for (let i = 0; i < value.length; i++) {
let char = value[i]
if (char === '[') {
inBrackets = true
parts.push(chunk)
chunk = ''
continue
}
if (char === ']' && inBrackets) {
inBrackets = false
parts.push(chunk)
chunk = ''
continue
}
if (char === '.' && !inBrackets && chunk.length > 0) {
parts.push(chunk)
chunk = ''
continue
}
chunk = chunk + char
}
if (chunk.length > 0) {
parts.push(chunk)
}
return parts
}
function resolveConfigPath(pathOrConfig) {
// require('tailwindcss')({ theme: ..., variants: ... })
if (isObject(pathOrConfig) && pathOrConfig.config === undefined && !isEmpty(pathOrConfig)) {
return null
}
// require('tailwindcss')({ config: 'custom-config.js' })
if (
isObject(pathOrConfig) &&
pathOrConfig.config !== undefined &&
isString(pathOrConfig.config)
) {
return path.resolve(pathOrConfig.config)
}
// require('tailwindcss')({ config: { theme: ..., variants: ... } })
if (
isObject(pathOrConfig) &&
pathOrConfig.config !== undefined &&
isObject(pathOrConfig.config)
) {
return null
}
// require('tailwindcss')('custom-config.js')
if (isString(pathOrConfig)) {
return path.resolve(pathOrConfig)
}
// require('tailwindcss')
for (const configFile of ['./tailwind.config.js', './tailwind.config.cjs']) {
try {
const configPath = path.resolve(configFile)
fs.accessSync(configPath)
return configPath
} catch (err) {}
}
return null
}
let configPathCache = new LRU({ maxSize: 100 })
// Get the config object based on a path
function getTailwindConfig(configOrPath) {
let userConfigPath = resolveConfigPath(configOrPath)
if (sharedState.env.TAILWIND_DISABLE_TOUCH) {
if (userConfigPath !== null) {
let [prevConfig, prevConfigHash, prevDeps, prevModified] =
configPathCache.get(userConfigPath) || []
let newDeps = getModuleDependencies(userConfigPath).map((dep) => dep.file)
let modified = false
let newModified = new Map()
for (let file of newDeps) {
let time = fs.statSync(file).mtimeMs
newModified.set(file, time)
if (!prevModified || !prevModified.has(file) || time > prevModified.get(file)) {
modified = true
}
}
// It hasn't changed (based on timestamps)
if (!modified) {
return [prevConfig, userConfigPath, prevConfigHash, prevDeps]
}
// It has changed (based on timestamps), or first run
for (let file of newDeps) {
delete require.cache[file]
}
let newConfig = resolveConfig(require(userConfigPath))
let newHash = hash(newConfig)
configPathCache.set(userConfigPath, [newConfig, newHash, newDeps, newModified])
return [newConfig, userConfigPath, newHash, newDeps]
}
// It's a plain object, not a path
let newConfig = resolveConfig(
configOrPath.config === undefined ? configOrPath : configOrPath.config
)
return [newConfig, null, hash(newConfig), []]
}
if (userConfigPath !== null) {
let [prevConfig, prevModified = -Infinity, prevConfigHash] =
configPathCache.get(userConfigPath) || []
let modified = fs.statSync(userConfigPath).mtimeMs
// It hasn't changed (based on timestamp)
if (modified <= prevModified) {
return [prevConfig, userConfigPath, prevConfigHash]
}
// It has changed (based on timestamp), or first run
delete require.cache[userConfigPath]
let newConfig = resolveConfig(require(userConfigPath))
let newHash = hash(newConfig)
configPathCache.set(userConfigPath, [newConfig, modified, newHash])
return [newConfig, userConfigPath, newHash]
}
// It's a plain object, not a path
let newConfig = resolveConfig(
configOrPath.config === undefined ? configOrPath : configOrPath.config
)
return [newConfig, null, hash(newConfig)]
}
let fileModifiedMap = new Map()
function trackModified(files) {
let changed = false
for (let file of files) {
if (!file) continue
let parsed = url.parse(file)
let pathname = parsed.href.replace(parsed.hash, '').replace(parsed.search, '')
let newModified = fs.statSync(decodeURIComponent(pathname)).mtimeMs
if (!fileModifiedMap.has(file) || newModified > fileModifiedMap.get(file)) {
changed = true
}
fileModifiedMap.set(file, newModified)
}
return changed
}
function generateTouchFileName() {
let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
let randomChars = ''
let randomCharsLength = 12
let bytes = null
try {
bytes = crypto.randomBytes(randomCharsLength)
} catch (_error) {
bytes = crypto.pseudoRandomBytes(randomCharsLength)
}
for (let i = 0; i < randomCharsLength; i++) {
randomChars += chars[bytes[i] % chars.length]
}
return path.join(touchDir, `touch-${process.pid}-${randomChars}`)
}
function rebootWatcher(context) {
if (env.TAILWIND_DISABLE_TOUCH) {
return
}
if (context.touchFile === null) {
context.touchFile = generateTouchFileName()
touch(context.touchFile)
}
if (env.TAILWIND_MODE === 'build') {
return
}
if (
env.TAILWIND_MODE === 'watch' ||
(env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development')
) {
Promise.resolve(context.watcher ? context.watcher.close() : null).then(() => {
log.info([
'Tailwind CSS is watching for changes...',
'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds',
])
context.watcher = chokidar.watch([...context.candidateFiles, ...context.configDependencies], {
ignoreInitial: true,
})
context.watcher.on('add', (file) => {
context.changedFiles.add(path.resolve('.', file))
touch(context.touchFile)
})
context.watcher.on('change', (file) => {
// If it was a config dependency, touch the config file to trigger a new context.
// This is not really that clean of a solution but it's the fastest, because we
// can do a very quick check on each build to see if the config has changed instead
// of having to get all of the module dependencies and check every timestamp each
// time.
if (context.configDependencies.has(file)) {
for (let dependency of context.configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(context.configPath)
} else {
context.changedFiles.add(path.resolve('.', file))
touch(context.touchFile)
}
})
context.watcher.on('unlink', (file) => {
// Touch the config file if any of the dependencies are deleted.
if (context.configDependencies.has(file)) {
for (let dependency of context.configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(context.configPath)
}
})
})
}
}
function insertInto(list, value, { before = [] } = {}) {
before = [].concat(before)
if (before.length <= 0) {
list.push(value)
return
}
let idx = list.length - 1
for (let other of before) {
let iidx = list.indexOf(other)
if (iidx === -1) continue
idx = Math.min(idx, iidx)
}
list.splice(idx, 0, value)
}
function parseStyles(styles) {
if (!Array.isArray(styles)) {
return parseStyles([styles])
}
return styles.flatMap((style) => {
let isNode = !Array.isArray(style) && !isPlainObject(style)
return isNode ? style : parseObjectStyles(style)
})
}
function getClasses(selector) {
let parser = selectorParser((selectors) => {
let allClasses = []
selectors.walkClasses((classNode) => {
allClasses.push(classNode.value)
})
return allClasses
})
return parser.transformSync(selector)
}
function extractCandidates(node) {
let classes = node.type === 'rule' ? getClasses(node.selector) : []
if (node.type === 'atrule') {
node.walkRules((rule) => {
classes = [...classes, ...getClasses(rule.selector)]
})
}
return classes
}
function withIdentifiers(styles) {
return parseStyles(styles).flatMap((node) => {
let nodeMap = new Map()
let candidates = extractCandidates(node)
// If this isn't "on-demandable", assign it a universal candidate.
if (candidates.length === 0) {
return [['*', node]]
}
return candidates.map((c) => {
if (!nodeMap.has(node)) {
nodeMap.set(node, node)
}
return [c, nodeMap.get(node)]
})
})
}
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets }) {
function getConfigValue(path, defaultValue) {
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
}
function applyConfiguredPrefix(selector) {
return prefixSelector(tailwindConfig.prefix, selector)
}
function prefixIdentifier(identifier, options) {
if (identifier === '*') {
return '*'
}
if (!options.respectPrefix) {
return identifier
}
if (typeof context.tailwindConfig.prefix === 'function') {
return prefixSelector(context.tailwindConfig.prefix, `.${identifier}`).substr(1)
}
return context.tailwindConfig.prefix + identifier
}
return {
addVariant(variantName, applyThisVariant, options = {}) {
insertInto(variantList, variantName, options)
variantMap.set(variantName, applyThisVariant)
},
postcss,
prefix: applyConfiguredPrefix,
e: escapeClassName,
config: getConfigValue,
theme(path, defaultValue) {
const [pathRoot, ...subPaths] = toPath(path)
const value = getConfigValue(['theme', pathRoot, ...subPaths], defaultValue)
return transformThemeValue(pathRoot)(value)
},
corePlugins: (path) => {
if (Array.isArray(tailwindConfig.corePlugins)) {
return tailwindConfig.corePlugins.includes(path)
}
return getConfigValue(['corePlugins', path], true)
},
variants: (path, defaultValue) => {
if (Array.isArray(tailwindConfig.variants)) {
return tailwindConfig.variants
}
return getConfigValue(['variants', path], defaultValue)
},
addBase(base) {
for (let [identifier, rule] of withIdentifiers(base)) {
let prefixedIdentifier = prefixIdentifier(identifier, {})
let offset = offsets.base++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'base' }, rule])
}
},
addComponents(components, options) {
let defaultOptions = {
variants: [],
respectPrefix: true,
respectImportant: false,
respectVariants: true,
}
options = Object.assign(
{},
defaultOptions,
Array.isArray(options) ? { variants: options } : options
)
for (let [identifier, rule] of withIdentifiers(components)) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let offset = offsets.components++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'components', options }, rule])
}
},
addUtilities(utilities, options) {
let defaultOptions = {
variants: [],
respectPrefix: true,
respectImportant: true,
respectVariants: true,
}
options = Object.assign(
{},
defaultOptions,
Array.isArray(options) ? { variants: options } : options
)
for (let [identifier, rule] of withIdentifiers(utilities)) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let offset = offsets.utilities++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'utilities', options }, rule])
}
},
matchUtilities: function (utilities, options) {
let defaultOptions = {
variants: [],
respectPrefix: true,
respectImportant: true,
respectVariants: true,
}
options = { ...defaultOptions, ...options }
let offset = offsets.utilities++
for (let identifier in utilities) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let rule = utilities[identifier]
function wrapped(modifier) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options.values, tailwindConfig)
if (!type.includes(coercedType) || value === undefined) {
return []
}
let includedRules = []
let ruleSets = []
.concat(
rule(value, {
includeRules(rules) {
includedRules.push(...rules)
},
})
)
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
}))
return [...includedRules, ...ruleSets]
}
let withOffsets = [{ sort: offset, layer: 'utilities', options }, wrapped]
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap.get(prefixedIdentifier).push(withOffsets)
}
},
}
}
function extractVariantAtRules(node) {
node.walkAtRules((atRule) => {
if (['responsive', 'variants'].includes(atRule.name)) {
extractVariantAtRules(atRule)
atRule.before(atRule.nodes)
atRule.remove()
}
})
}
function collectLayerPlugins(root) {
let layerPlugins = []
root.each((node) => {
if (node.type === 'atrule' && ['responsive', 'variants'].includes(node.name)) {
node.name = 'layer'
node.params = 'utilities'
}
})
// Walk @layer rules and treat them like plugins
root.walkAtRules('layer', (layerNode) => {
extractVariantAtRules(layerNode)
if (layerNode.params === 'base') {
for (let node of layerNode.nodes) {
layerPlugins.push(function ({ addBase }) {
addBase(node, { respectPrefix: false })
})
}
} else if (layerNode.params === 'components') {
for (let node of layerNode.nodes) {
layerPlugins.push(function ({ addComponents }) {
addComponents(node, { respectPrefix: false })
})
}
} else if (layerNode.params === 'utilities') {
for (let node of layerNode.nodes) {
layerPlugins.push(function ({ addUtilities }) {
addUtilities(node, { respectPrefix: false })
})
}
}
})
return layerPlugins
}
function registerPlugins(tailwindConfig, plugins, context) {
let variantList = []
let variantMap = new Map()
let offsets = {
base: 0n,
components: 0n,
utilities: 0n,
}
let pluginApi = buildPluginApi(tailwindConfig, context, {
variantList,
variantMap,
offsets,
})
for (let plugin of plugins) {
if (Array.isArray(plugin)) {
for (let pluginItem of plugin) {
pluginItem(pluginApi)
}
} else {
plugin(pluginApi)
}
}
let highestOffset = ((args) => args.reduce((m, e) => (e > m ? e : m)))([
offsets.base,
offsets.components,
offsets.utilities,
])
let reservedBits = BigInt(highestOffset.toString(2).length)
context.layerOrder = {
base: (1n << reservedBits) << 0n,
components: (1n << reservedBits) << 1n,
utilities: (1n << reservedBits) << 2n,
}
reservedBits += 3n
context.variantOrder = variantList.reduce(
(map, variant, i) => map.set(variant, (1n << BigInt(i)) << reservedBits),
new Map()
)
context.minimumScreen = [...context.variantOrder.values()].shift()
// Build variantMap
for (let [variantName, variantFunction] of variantMap.entries()) {
let sort = context.variantOrder.get(variantName)
context.variantMap.set(variantName, [sort, variantFunction])
}
}
function cleanupContext(context) {
if (context.watcher) {
context.watcher.close()
}
}
// Retrieve an existing context from cache if possible (since contexts are unique per
// source path), or set up a new one (including setting up watchers and registering
// plugins) then return it
export default function setupContext(configOrPath, tailwindDirectives) {
return (result, root) => {
let sourcePath = result.opts.from
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
let isConfigFile = userConfigPath !== null
let contextDependencies = new Set(
sharedState.env.TAILWIND_DISABLE_TOUCH ? configDependencies : []
)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
contextDependencies.add(sourcePath)
for (let message of result.messages) {
if (message.type === 'dependency') {
contextDependencies.add(message.file)
}
}
}
if (sharedState.env.TAILWIND_DISABLE_TOUCH) {
for (let file of configDependencies) {
result.messages.push({
type: 'dependency',
plugin: 'tailwindcss-jit',
parent: result.opts.from,
file,
})
}
} else {
if (isConfigFile) {
contextDependencies.add(userConfigPath)
}
}
let contextDependenciesChanged = trackModified([...contextDependencies])
process.env.DEBUG && console.log('Source path:', sourcePath)
if (!contextDependenciesChanged) {
// If this file already has a context in the cache and we don't need to
// reset the context, return the cached context.
if (isConfigFile && contextMap.has(sourcePath)) {
return contextMap.get(sourcePath)
}
// If the config used already exists in the cache, return that.
if (configContextMap.has(tailwindConfigHash)) {
let context = configContextMap.get(tailwindConfigHash)
contextSourcesMap.get(context).add(sourcePath)
contextMap.set(sourcePath, context)
return context
}
}
// If this source is in the context map, get the old context.
// Remove this source from the context sources for the old context,
// and clean up that context if no one else is using it. This can be
// called by many processes in rapid succession, so we check for presence
// first because the first process to run this code will wipe it out first.
if (contextMap.has(sourcePath)) {
let oldContext = contextMap.get(sourcePath)
if (contextSourcesMap.has(oldContext)) {
contextSourcesMap.get(oldContext).delete(sourcePath)
if (contextSourcesMap.get(oldContext).size === 0) {
contextSourcesMap.delete(oldContext)
cleanupContext(oldContext)
}
}
}
process.env.DEBUG && console.log('Setting up new context...')
let purgeContent = Array.isArray(tailwindConfig.purge)
? tailwindConfig.purge
: tailwindConfig.purge.content
let context = {
changedFiles: new Set(),
ruleCache: new Set(),
watcher: null,
scannedContent: false,
touchFile: null,
classCache: new Map(),
applyClassCache: new Map(),
notClassCache: new Set(),
postCssNodeCache: new Map(),
candidateRuleMap: new Map(),
configPath: userConfigPath,
tailwindConfig: tailwindConfig,
configDependencies: new Set(),
candidateFiles: purgeContent
.filter((item) => typeof item === 'string')
.map((purgePath) =>
normalizePath(
path.resolve(
userConfigPath === null ? process.cwd() : path.dirname(userConfigPath),
purgePath
)
)
),
rawContent: purgeContent
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension }) => ({ content: raw, extension })),
variantMap: new Map(),
stylesheetCache: null,
fileModifiedMap: new Map(),
}
// ---
// Update all context tracking state
configContextMap.set(tailwindConfigHash, context)
contextMap.set(sourcePath, context)
if (!contextSourcesMap.has(context)) {
contextSourcesMap.set(context, new Set())
}
contextSourcesMap.get(context).add(sourcePath)
// ---
if (isConfigFile && !sharedState.env.TAILWIND_DISABLE_TOUCH) {
for (let dependency of getModuleDependencies(userConfigPath)) {
if (dependency.file === userConfigPath) {
continue
}
context.configDependencies.add(dependency.file)
}
}
rebootWatcher(context)
let corePluginList = Object.entries(corePlugins)
.map(([name, plugin]) => {
if (!tailwindConfig.corePlugins.includes(name)) {
return null
}
return plugin
})
.filter(Boolean)
let userPlugins = tailwindConfig.plugins.map((plugin) => {
if (plugin.__isOptionsFunction) {
plugin = plugin()
}
return typeof plugin === 'function' ? plugin : plugin.handler
})
let layerPlugins = collectLayerPlugins(root)
// TODO: This is a workaround for backwards compatibility, since custom variants
// were historically sorted before screen/stackable variants.
let beforeVariants = [corePlugins['pseudoClassVariants']]
let afterVariants = [
corePlugins['directionVariants'],
corePlugins['reducedMotionVariants'],
corePlugins['darkVariants'],
corePlugins['screenVariants'],
]
registerPlugins(
context.tailwindConfig,
[...corePluginList, ...beforeVariants, ...userPlugins, ...afterVariants, ...layerPlugins],
context
)
return context
}
}

View File

@ -0,0 +1,600 @@
import fs from 'fs'
import url from 'url'
import path from 'path'
import postcss from 'postcss'
import dlv from 'dlv'
import selectorParser from 'postcss-selector-parser'
import normalizePath from 'normalize-path'
import transformThemeValue from '../../util/transformThemeValue'
import parseObjectStyles from '../../util/parseObjectStyles'
import prefixSelector from '../../util/prefixSelector'
import isPlainObject from '../../util/isPlainObject'
import escapeClassName from '../../util/escapeClassName'
import nameClass from '../../util/nameClass'
import { coerceValue } from '../../util/pluginUtils'
import corePlugins from '../corePlugins'
import * as sharedState from './sharedState'
import { env } from './sharedState'
function toPath(value) {
if (Array.isArray(value)) {
return value
}
let inBrackets = false
let parts = []
let chunk = ''
for (let i = 0; i < value.length; i++) {
let char = value[i]
if (char === '[') {
inBrackets = true
parts.push(chunk)
chunk = ''
continue
}
if (char === ']' && inBrackets) {
inBrackets = false
parts.push(chunk)
chunk = ''
continue
}
if (char === '.' && !inBrackets && chunk.length > 0) {
parts.push(chunk)
chunk = ''
continue
}
chunk = chunk + char
}
if (chunk.length > 0) {
parts.push(chunk)
}
return parts
}
function insertInto(list, value, { before = [] } = {}) {
before = [].concat(before)
if (before.length <= 0) {
list.push(value)
return
}
let idx = list.length - 1
for (let other of before) {
let iidx = list.indexOf(other)
if (iidx === -1) continue
idx = Math.min(idx, iidx)
}
list.splice(idx, 0, value)
}
function parseStyles(styles) {
if (!Array.isArray(styles)) {
return parseStyles([styles])
}
return styles.flatMap((style) => {
let isNode = !Array.isArray(style) && !isPlainObject(style)
return isNode ? style : parseObjectStyles(style)
})
}
function getClasses(selector) {
let parser = selectorParser((selectors) => {
let allClasses = []
selectors.walkClasses((classNode) => {
allClasses.push(classNode.value)
})
return allClasses
})
return parser.transformSync(selector)
}
function extractCandidates(node) {
let classes = node.type === 'rule' ? getClasses(node.selector) : []
if (node.type === 'atrule') {
node.walkRules((rule) => {
classes = [...classes, ...getClasses(rule.selector)]
})
}
return classes
}
function withIdentifiers(styles) {
return parseStyles(styles).flatMap((node) => {
let nodeMap = new Map()
let candidates = extractCandidates(node)
// If this isn't "on-demandable", assign it a universal candidate.
if (candidates.length === 0) {
return [['*', node]]
}
return candidates.map((c) => {
if (!nodeMap.has(node)) {
nodeMap.set(node, node)
}
return [c, nodeMap.get(node)]
})
})
}
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets }) {
function getConfigValue(path, defaultValue) {
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
}
function applyConfiguredPrefix(selector) {
return prefixSelector(tailwindConfig.prefix, selector)
}
function prefixIdentifier(identifier, options) {
if (identifier === '*') {
return '*'
}
if (!options.respectPrefix) {
return identifier
}
if (typeof context.tailwindConfig.prefix === 'function') {
return prefixSelector(context.tailwindConfig.prefix, `.${identifier}`).substr(1)
}
return context.tailwindConfig.prefix + identifier
}
return {
addVariant(variantName, applyThisVariant, options = {}) {
insertInto(variantList, variantName, options)
variantMap.set(variantName, applyThisVariant)
},
postcss,
prefix: applyConfiguredPrefix,
e: escapeClassName,
config: getConfigValue,
theme(path, defaultValue) {
const [pathRoot, ...subPaths] = toPath(path)
const value = getConfigValue(['theme', pathRoot, ...subPaths], defaultValue)
return transformThemeValue(pathRoot)(value)
},
corePlugins: (path) => {
if (Array.isArray(tailwindConfig.corePlugins)) {
return tailwindConfig.corePlugins.includes(path)
}
return getConfigValue(['corePlugins', path], true)
},
variants: (path, defaultValue) => {
if (Array.isArray(tailwindConfig.variants)) {
return tailwindConfig.variants
}
return getConfigValue(['variants', path], defaultValue)
},
addBase(base) {
for (let [identifier, rule] of withIdentifiers(base)) {
let prefixedIdentifier = prefixIdentifier(identifier, {})
let offset = offsets.base++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'base' }, rule])
}
},
addComponents(components, options) {
let defaultOptions = {
variants: [],
respectPrefix: true,
respectImportant: false,
respectVariants: true,
}
options = Object.assign(
{},
defaultOptions,
Array.isArray(options) ? { variants: options } : options
)
for (let [identifier, rule] of withIdentifiers(components)) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let offset = offsets.components++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'components', options }, rule])
}
},
addUtilities(utilities, options) {
let defaultOptions = {
variants: [],
respectPrefix: true,
respectImportant: true,
respectVariants: true,
}
options = Object.assign(
{},
defaultOptions,
Array.isArray(options) ? { variants: options } : options
)
for (let [identifier, rule] of withIdentifiers(utilities)) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let offset = offsets.utilities++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'utilities', options }, rule])
}
},
matchUtilities: function (utilities, options) {
let defaultOptions = {
variants: [],
respectPrefix: true,
respectImportant: true,
respectVariants: true,
}
options = { ...defaultOptions, ...options }
let offset = offsets.utilities++
for (let identifier in utilities) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let rule = utilities[identifier]
function wrapped(modifier) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options.values, tailwindConfig)
if (!type.includes(coercedType) || value === undefined) {
return []
}
let includedRules = []
let ruleSets = []
.concat(
rule(value, {
includeRules(rules) {
includedRules.push(...rules)
},
})
)
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
}))
return [...includedRules, ...ruleSets]
}
let withOffsets = [{ sort: offset, layer: 'utilities', options }, wrapped]
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap.get(prefixedIdentifier).push(withOffsets)
}
},
}
}
let fileModifiedMap = new Map()
function trackModified(files) {
let changed = false
for (let file of files) {
if (!file) continue
let parsed = url.parse(file)
let pathname = parsed.href.replace(parsed.hash, '').replace(parsed.search, '')
let newModified = fs.statSync(decodeURIComponent(pathname)).mtimeMs
if (!fileModifiedMap.has(file) || newModified > fileModifiedMap.get(file)) {
changed = true
}
fileModifiedMap.set(file, newModified)
}
return changed
}
function extractVariantAtRules(node) {
node.walkAtRules((atRule) => {
if (['responsive', 'variants'].includes(atRule.name)) {
extractVariantAtRules(atRule)
atRule.before(atRule.nodes)
atRule.remove()
}
})
}
function collectLayerPlugins(root) {
let layerPlugins = []
root.each((node) => {
if (node.type === 'atrule' && ['responsive', 'variants'].includes(node.name)) {
node.name = 'layer'
node.params = 'utilities'
}
})
// Walk @layer rules and treat them like plugins
root.walkAtRules('layer', (layerRule) => {
extractVariantAtRules(layerRule)
if (layerRule.params === 'base') {
for (let node of layerRule.nodes) {
layerPlugins.push(function ({ addBase }) {
addBase(node, { respectPrefix: false })
})
}
layerRule.remove()
} else if (layerRule.params === 'components') {
for (let node of layerRule.nodes) {
layerPlugins.push(function ({ addComponents }) {
addComponents(node, { respectPrefix: false })
})
}
layerRule.remove()
} else if (layerRule.params === 'utilities') {
for (let node of layerRule.nodes) {
layerPlugins.push(function ({ addUtilities }) {
addUtilities(node, { respectPrefix: false })
})
}
layerRule.remove()
}
})
return layerPlugins
}
function resolvePlugins(context, tailwindDirectives, root) {
let corePluginList = Object.entries(corePlugins)
.map(([name, plugin]) => {
if (!context.tailwindConfig.corePlugins.includes(name)) {
return null
}
return plugin
})
.filter(Boolean)
let userPlugins = context.tailwindConfig.plugins.map((plugin) => {
if (plugin.__isOptionsFunction) {
plugin = plugin()
}
return typeof plugin === 'function' ? plugin : plugin.handler
})
let layerPlugins = collectLayerPlugins(root, tailwindDirectives)
// TODO: This is a workaround for backwards compatibility, since custom variants
// were historically sorted before screen/stackable variants.
let beforeVariants = [corePlugins['pseudoClassVariants']]
let afterVariants = [
corePlugins['directionVariants'],
corePlugins['reducedMotionVariants'],
corePlugins['darkVariants'],
corePlugins['screenVariants'],
]
return [...corePluginList, ...beforeVariants, ...userPlugins, ...afterVariants, ...layerPlugins]
}
function registerPlugins(plugins, context) {
let variantList = []
let variantMap = new Map()
let offsets = {
base: 0n,
components: 0n,
utilities: 0n,
}
let pluginApi = buildPluginApi(context.tailwindConfig, context, {
variantList,
variantMap,
offsets,
})
for (let plugin of plugins) {
if (Array.isArray(plugin)) {
for (let pluginItem of plugin) {
pluginItem(pluginApi)
}
} else {
plugin(pluginApi)
}
}
let highestOffset = ((args) => args.reduce((m, e) => (e > m ? e : m)))([
offsets.base,
offsets.components,
offsets.utilities,
])
let reservedBits = BigInt(highestOffset.toString(2).length)
context.layerOrder = {
base: (1n << reservedBits) << 0n,
components: (1n << reservedBits) << 1n,
utilities: (1n << reservedBits) << 2n,
}
reservedBits += 3n
context.variantOrder = variantList.reduce(
(map, variant, i) => map.set(variant, (1n << BigInt(i)) << reservedBits),
new Map()
)
context.minimumScreen = [...context.variantOrder.values()].shift()
// Build variantMap
for (let [variantName, variantFunction] of variantMap.entries()) {
let sort = context.variantOrder.get(variantName)
context.variantMap.set(variantName, [sort, variantFunction])
}
}
let contextMap = sharedState.contextMap
let configContextMap = sharedState.configContextMap
let contextSourcesMap = sharedState.contextSourcesMap
function cleanupContext(context) {
if (context.watcher) {
context.watcher.close()
}
}
export function getContext(
configOrPath,
tailwindDirectives,
registerDependency,
root,
result,
getTailwindConfig
) {
let sourcePath = result.opts.from
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
let isConfigFile = userConfigPath !== null
let contextDependencies = new Set(configDependencies)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
// Add current css file as a context dependencies.
contextDependencies.add(sourcePath)
// Add all css @import dependencies as context dependencies.
for (let message of result.messages) {
if (message.type === 'dependency') {
contextDependencies.add(message.file)
}
}
}
for (let file of configDependencies) {
registerDependency(file)
}
let contextDependenciesChanged = trackModified([...contextDependencies])
env.DEBUG && console.log('Source path:', sourcePath)
if (!contextDependenciesChanged) {
// If this file already has a context in the cache and we don't need to
// reset the context, return the cached context.
if (isConfigFile && contextMap.has(sourcePath)) {
return [contextMap.get(sourcePath), false]
}
// If the config used already exists in the cache, return that.
if (configContextMap.has(tailwindConfigHash)) {
let context = configContextMap.get(tailwindConfigHash)
contextSourcesMap.get(context).add(sourcePath)
contextMap.set(sourcePath, context)
return [context, false]
}
}
// If this source is in the context map, get the old context.
// Remove this source from the context sources for the old context,
// and clean up that context if no one else is using it. This can be
// called by many processes in rapid succession, so we check for presence
// first because the first process to run this code will wipe it out first.
if (contextMap.has(sourcePath)) {
let oldContext = contextMap.get(sourcePath)
if (contextSourcesMap.has(oldContext)) {
contextSourcesMap.get(oldContext).delete(sourcePath)
if (contextSourcesMap.get(oldContext).size === 0) {
contextSourcesMap.delete(oldContext)
cleanupContext(oldContext)
}
}
}
env.DEBUG && console.log('Setting up new context...')
let purgeContent = Array.isArray(tailwindConfig.purge)
? tailwindConfig.purge
: tailwindConfig.purge.content
let context = {
watcher: null,
touchFile: null,
configPath: userConfigPath,
configDependencies: new Set(),
candidateFiles: purgeContent
.filter((item) => typeof item === 'string')
.map((purgePath) =>
normalizePath(
path.resolve(
userConfigPath === null ? process.cwd() : path.dirname(userConfigPath),
purgePath
)
)
),
fileModifiedMap: new Map(),
// ---
ruleCache: new Set(), // Hit
classCache: new Map(), // Hit
applyClassCache: new Map(), // Hit
notClassCache: new Set(), // Hit
postCssNodeCache: new Map(), // Hit
candidateRuleMap: new Map(), // Hit
tailwindConfig: tailwindConfig, // Hit
changedContent: purgeContent // Hit
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension }) => ({ content: raw, extension })),
variantMap: new Map(), // Hit
stylesheetCache: null, // Hit
}
// ---
// Update all context tracking state
configContextMap.set(tailwindConfigHash, context)
contextMap.set(sourcePath, context)
if (!contextSourcesMap.has(context)) {
contextSourcesMap.set(context, new Set())
}
contextSourcesMap.get(context).add(sourcePath)
registerPlugins(resolvePlugins(context, tailwindDirectives, root), context)
return [context, true]
}

View File

@ -0,0 +1,133 @@
import fs from 'fs'
import path from 'path'
import fastGlob from 'fast-glob'
import parseGlob from 'parse-glob'
import LRU from 'quick-lru'
import hash from '../../util/hashConfig'
import getModuleDependencies from '../../lib/getModuleDependencies'
import resolveConfig from '../../../resolveConfig'
import resolveConfigPath from '../../util/resolveConfigPath'
import { env } from './sharedState'
import { getContext } from './setupContextUtils'
let configPathCache = new LRU({ maxSize: 100 })
// Get the config object based on a path
function getTailwindConfig(configOrPath) {
let userConfigPath = resolveConfigPath(configOrPath)
if (userConfigPath !== null) {
let [prevConfig, prevConfigHash, prevDeps, prevModified] =
configPathCache.get(userConfigPath) || []
let newDeps = getModuleDependencies(userConfigPath).map((dep) => dep.file)
let modified = false
let newModified = new Map()
for (let file of newDeps) {
let time = fs.statSync(file).mtimeMs
newModified.set(file, time)
if (!prevModified || !prevModified.has(file) || time > prevModified.get(file)) {
modified = true
}
}
// It hasn't changed (based on timestamps)
if (!modified) {
return [prevConfig, userConfigPath, prevConfigHash, prevDeps]
}
// It has changed (based on timestamps), or first run
for (let file of newDeps) {
delete require.cache[file]
}
let newConfig = resolveConfig(require(userConfigPath))
let newHash = hash(newConfig)
configPathCache.set(userConfigPath, [newConfig, newHash, newDeps, newModified])
return [newConfig, userConfigPath, newHash, newDeps]
}
// It's a plain object, not a path
let newConfig = resolveConfig(
configOrPath.config === undefined ? configOrPath : configOrPath.config
)
return [newConfig, null, hash(newConfig), []]
}
function resolveChangedFiles(context) {
let changedFiles = new Set()
env.DEBUG && console.time('Finding changed files')
let files = fastGlob.sync(context.candidateFiles)
for (let file of files) {
let prevModified = context.fileModifiedMap.has(file)
? context.fileModifiedMap.get(file)
: -Infinity
let modified = fs.statSync(file).mtimeMs
if (modified > prevModified) {
changedFiles.add(file)
context.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
// source path), or set up a new one (including setting up watchers and registering
// plugins) then return it
export default function setupTrackingContext(configOrPath, tailwindDirectives, registerDependency) {
return (result, root) => {
let [context] = getContext(
configOrPath,
tailwindDirectives,
registerDependency,
root,
result,
getTailwindConfig
)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
// Add template paths as postcss dependencies.
for (let maybeGlob of context.candidateFiles) {
let {
is: { glob: isGlob },
base,
} = parseGlob(maybeGlob)
if (isGlob) {
// rollup-plugin-postcss does not support dir-dependency messages
// but directories can be watched in the same way as files
registerDependency(
path.resolve(base),
env.ROLLUP_WATCH === 'true' ? 'dependency' : 'dir-dependency'
)
} else {
registerDependency(path.resolve(maybeGlob))
}
}
for (let changedFile of resolveChangedFiles(context)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
}
}
return context
}
}

View File

@ -0,0 +1,112 @@
import fs from 'fs'
import path from 'path'
import fastGlob from 'fast-glob'
import LRU from 'quick-lru'
import hash from '../../util/hashConfig'
import getModuleDependencies from '../../lib/getModuleDependencies'
import resolveConfig from '../../../resolveConfig'
import resolveConfigPath from '../../util/resolveConfigPath'
import { rebootWatcher } from './rebootWatcher'
import { getContext } from './setupContextUtils'
let configPathCache = new LRU({ maxSize: 100 })
// Get the config object based on a path
function getTailwindConfig(configOrPath) {
let userConfigPath = resolveConfigPath(configOrPath)
if (userConfigPath !== null) {
let [prevConfig, prevModified = -Infinity, prevConfigHash] =
configPathCache.get(userConfigPath) || []
let modified = fs.statSync(userConfigPath).mtimeMs
// It hasn't changed (based on timestamp)
if (modified <= prevModified) {
return [prevConfig, userConfigPath, prevConfigHash, [userConfigPath]]
}
// It has changed (based on timestamp), or first run
delete require.cache[userConfigPath]
let newConfig = resolveConfig(require(userConfigPath))
let newHash = hash(newConfig)
configPathCache.set(userConfigPath, [newConfig, modified, newHash])
return [newConfig, userConfigPath, newHash, [userConfigPath]]
}
// It's a plain object, not a path
let newConfig = resolveConfig(
configOrPath.config === undefined ? configOrPath : configOrPath.config
)
return [newConfig, null, hash(newConfig), [userConfigPath]]
}
function resolveChangedFiles(context) {
let changedFiles = new Set()
// If we're not set up and watching files ourselves, we need to do
// the work of grabbing all of the template files for candidate
// detection.
if (!context.scannedContent) {
let files = fastGlob.sync(context.candidateFiles)
for (let file of files) {
changedFiles.add(file)
}
context.scannedContent = true
}
return changedFiles
}
// DISABLE_TOUCH = FALSE
// Retrieve an existing context from cache if possible (since contexts are unique per
// source path), or set up a new one (including setting up watchers and registering
// plugins) then return it
export default function setupWatchingContext(configOrPath, tailwindDirectives, registerDependency) {
return (result, root) => {
let [context, newContext] = getContext(
configOrPath,
tailwindDirectives,
registerDependency,
root,
result,
getTailwindConfig
)
if (context.configPath !== null) {
for (let dependency of getModuleDependencies(context.configPath)) {
if (dependency.file === context.configPath) {
continue
}
context.configDependencies.add(dependency.file)
}
}
if (newContext) {
rebootWatcher(context)
}
// Register our temp file as a dependency — we write to this file
// to trigger rebuilds.
if (context.touchFile) {
registerDependency(context.touchFile)
}
if (tailwindDirectives.size > 0) {
for (let changedFile of resolveChangedFiles(context)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
}
}
return context
}
}

View File

@ -0,0 +1,15 @@
import expandTailwindAtRules from './lib/expandTailwindAtRules'
import expandApplyAtRules from './lib/expandApplyAtRules'
import evaluateTailwindFunctions from '../lib/evaluateTailwindFunctions'
import substituteScreenAtRules from '../lib/substituteScreenAtRules'
import collapseAdjacentRules from './lib/collapseAdjacentRules'
export default function processTailwindFeatures(context) {
return function (root, result) {
expandTailwindAtRules(context)(root, result)
expandApplyAtRules(context)(root, result)
evaluateTailwindFunctions(context)(root, result)
substituteScreenAtRules(context)(root, result)
collapseAdjacentRules(context)(root, result)
}
}

View File

@ -149,7 +149,7 @@ let nodeTypePropertyMap = {
decl: 'value',
}
export default function (config) {
export default function ({ tailwindConfig: config }) {
let functions = {
theme: (node, path, ...defaultValue) => {
const { isValid, value, error } = validatePath(

View File

@ -386,11 +386,11 @@ export default function substituteClassApplyAtRules(config, getProcessedPlugins,
? () => {
return postcss([
substituteTailwindAtRules(config, getProcessedPlugins()),
evaluateTailwindFunctions(config),
evaluateTailwindFunctions({ tailwindConfig: config }),
substituteVariantsAtRules(config, getProcessedPlugins()),
substituteResponsiveAtRules(config),
convertLayerAtRulesToControlComments(config),
substituteScreenAtRules(config),
substituteScreenAtRules({ tailwindConfig: config }),
])
.process(requiredTailwindAtRules.map((rule) => `@tailwind ${rule};`).join('\n'), {
from: __filename,

View File

@ -1,7 +1,7 @@
import _ from 'lodash'
import buildMediaQuery from '../util/buildMediaQuery'
export default function ({ theme }) {
export default function ({ tailwindConfig: { theme } }) {
return function (css) {
css.walkAtRules('screen', (atRule) => {
const screen = atRule.params

View File

@ -58,11 +58,11 @@ export default function (getConfig, resolvedConfigPath) {
return postcss([
substituteTailwindAtRules(config, getProcessedPlugins()),
evaluateTailwindFunctions(config),
evaluateTailwindFunctions({ tailwindConfig: config }),
substituteVariantsAtRules(config, getProcessedPlugins()),
substituteResponsiveAtRules(config),
convertLayerAtRulesToControlComments(config),
substituteScreenAtRules(config),
substituteScreenAtRules({ tailwindConfig: config }),
substituteClassApplyAtRules(config, getProcessedPlugins, configChanged),
applyImportantConfiguration(config),
purgeUnusedStyles(config, configChanged, resolvedConfigPath),

View File

@ -0,0 +1,57 @@
import fs from 'fs'
import path from 'path'
function isObject(value) {
return typeof value === 'object' && value !== null
}
function isEmpty(obj) {
return Object.keys(obj).length === 0
}
function isString(value) {
return typeof value === 'string' || value instanceof String
}
export default function resolveConfigPath(pathOrConfig) {
// require('tailwindcss')({ theme: ..., variants: ... })
if (isObject(pathOrConfig) && pathOrConfig.config === undefined && !isEmpty(pathOrConfig)) {
return null
}
// require('tailwindcss')({ config: 'custom-config.js' })
if (
isObject(pathOrConfig) &&
pathOrConfig.config !== undefined &&
isString(pathOrConfig.config)
) {
return path.resolve(pathOrConfig.config)
}
// require('tailwindcss')({ config: { theme: ..., variants: ... } })
if (
isObject(pathOrConfig) &&
pathOrConfig.config !== undefined &&
isObject(pathOrConfig.config)
) {
return null
}
// require('tailwindcss')('custom-config.js')
if (isString(pathOrConfig)) {
return path.resolve(pathOrConfig)
}
// require('tailwindcss')
for (const configFile of ['./tailwind.config.js', './tailwind.config.cjs']) {
try {
const configPath = path.resolve(configFile)
fs.accessSync(configPath)
return configPath
} catch (err) {
console.log(err)
}
}
return null
}

View File

@ -2,7 +2,7 @@ import postcss from 'postcss'
import plugin from '../src/lib/evaluateTailwindFunctions'
function run(input, opts = {}) {
return postcss([plugin(opts)]).process(input, { from: undefined })
return postcss([plugin({ tailwindConfig: opts })]).process(input, { from: undefined })
}
test('it looks up values in the theme using dot notation', () => {

View File

@ -3,7 +3,7 @@ import plugin from '../src/lib/substituteScreenAtRules'
import config from '../stubs/defaultConfig.stub.js'
function run(input, opts = config) {
return postcss([plugin(opts)]).process(input, { from: undefined })
return postcss([plugin({ tailwindConfig: opts })]).process(input, { from: undefined })
}
test('it can generate media queries from configured screen sizes', () => {