Repository: tailwindlabs/tailwindcss

Problem statement:
[Bug]: JIT doesn't work as expected with multiple entry points importing CSS in Webpack
### What version of Tailwind CSS are you using?

v2.1.2

### What build tool (or framework if it abstracts the build tool) are you using?

Webpack 5.27.2

### What version of Node.js are you using?

14.16.0

### What browser are you using?

all

### What operating system are you using?

macOS

### Reproduction repository

https://github.com/leobauza/tw-jit-bug

### Describe your issue

## The Problem:

When there are multiple entry points that import css in Webpack, JIT does not behave as expected.

## Context:

I ran into this problem trying to set up a Craft CMS site where we have a need for multiple entry points, each with their own CSS. After a lot of trial and error we noticed that whenever we have multiple entry points JIT seems to not update the css file until we hit save on a `.css` file. That's a ok workaround, but I am wondering if I am doing something wrong or if this is a bug in Tailwind JIT.

Removing JIT also works, but I like the JIT experience too much so saving a `.css` file is my workaround.

## To reproduce the problem in the reproduction repository:

1. Start dev server with `yarn start`
2. Open `tailwind.config.js`
3. Change the value of `spacing[150]`
4. There will be no change until the `src/css/styles.css` file is saved
5. Open `./src/js/second.js`
6. Comment out the line: `import '../css/second.css'`
7. Doing step 2 and 3 results in a normal HMR update

Thanks! Love the JIT improvements!

## A video of all of the above in action:

https://user-images.githubusercontent.com/1823027/118910693-ee078a00-b8f2-11eb-834e-447d1c1ac0cf.mov




Hints:

Patch:
diff --git a/src/jit/index.js b/src/jit/index.js
--- a/src/jit/index.js
+++ b/src/jit/index.js
@@ -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)
-
-      if (!env.TAILWIND_DISABLE_TOUCH) {
-        if (context.configPath !== null) {
-          registerDependency(context.configPath)
-        }
-      }
+      let context = env.TAILWIND_DISABLE_TOUCH
+        ? setupTrackingContext(configOrPath, tailwindDirectives, registerDependency)(result, root)
+        : setupWatchingContext(configOrPath, tailwindDirectives, registerDependency)(result, root)
 
-      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) {
diff --git a/src/jit/lib/expandTailwindAtRules.js b/src/jit/lib/expandTailwindAtRules.js
--- a/src/jit/lib/expandTailwindAtRules.js
+++ b/src/jit/lib/expandTailwindAtRules.js
@@ -1,7 +1,3 @@
-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'
@@ -13,38 +9,63 @@ let contentMatchCache = sharedState.contentMatchCache
 const BROAD_MATCH_GLOBAL_REGEXP = /[^<>"'`\s]*[^<>"'`\s:]/g
 const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%]*[^<>"'`\s.(){}[\]#=%:]/g
 
-function getDefaultExtractor(fileExtension) {
-  return function (content) {
-    if (fileExtension === 'svelte') {
-      content = content.replace(/(?:^|\s)class:/g, ' ')
-    }
+const builtInExtractors = {
+  DEFAULT: (content) => {
     let broadMatches = content.match(BROAD_MATCH_GLOBAL_REGEXP) || []
     let innerMatches = content.match(INNER_MATCH_GLOBAL_REGEXP) || []
 
     return [...broadMatches, ...innerMatches]
-  }
+  },
+}
+
+const builtInTransformers = {
+  DEFAULT: (content) => content,
+  svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '),
 }
 
 function getExtractor(tailwindConfig, fileExtension) {
-  const purgeOptions = tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.options
+  let extractors = (tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.extract) || {}
+  const purgeOptions =
+    (tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.options) || {}
 
-  if (!fileExtension) {
-    return (purgeOptions && purgeOptions.defaultExtractor) || getDefaultExtractor()
+  if (typeof extractors === 'function') {
+    extractors = {
+      DEFAULT: extractors,
+    }
   }
-
-  if (!purgeOptions) {
-    return getDefaultExtractor(fileExtension)
+  if (purgeOptions.defaultExtractor) {
+    extractors.DEFAULT = purgeOptions.defaultExtractor
+  }
+  for (let { extensions, extractor } of purgeOptions.extractors || []) {
+    for (let extension of extensions) {
+      extractors[extension] = extractor
+    }
   }
 
-  const fileSpecificExtractor = (purgeOptions.extractors || []).find((extractor) =>
-    extractor.extensions.includes(fileExtension)
+  return (
+    extractors[fileExtension] ||
+    extractors.DEFAULT ||
+    builtInExtractors[fileExtension] ||
+    builtInExtractors.DEFAULT
   )
+}
+
+function getTransformer(tailwindConfig, fileExtension) {
+  let transformers =
+    (tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.transform) || {}
 
-  if (fileSpecificExtractor) {
-    return fileSpecificExtractor.extractor
+  if (typeof transformers === 'function') {
+    transformers = {
+      DEFAULT: transformers,
+    }
   }
 
-  return purgeOptions.defaultExtractor || getDefaultExtractor(fileExtension)
+  return (
+    transformers[fileExtension] ||
+    transformers.DEFAULT ||
+    builtInTransformers[fileExtension] ||
+    builtInTransformers.DEFAULT
+  )
 }
 
 // Scans template contents for possible classes. This is a hot path on initial build but
@@ -111,12 +132,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 +146,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,16 +162,11 @@ 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 transformer = getTransformer(context.tailwindConfig, extension)
       let extractor = getExtractor(context.tailwindConfig, extension)
-      getClassCandidates(content, extractor, contentMatchCache, candidates, seen)
+      getClassCandidates(transformer(content), extractor, contentMatchCache, candidates, seen)
     }
 
     // ---
@@ -276,13 +225,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 = []
   }
 }
diff --git a/src/jit/lib/normalizeTailwindDirectives.js b/src/jit/lib/normalizeTailwindDirectives.js
--- a/src/jit/lib/normalizeTailwindDirectives.js
+++ b/src/jit/lib/normalizeTailwindDirectives.js
@@ -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((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'
+      }
     }
-  })
 
-  let tailwindDirectives = new Set()
+    if (atRule.name === 'tailwind') {
+      if (atRule.params === 'screens') {
+        atRule.params = 'variants'
+      }
+      tailwindDirectives.add(atRule.params)
+    }
 
-  root.walkAtRules('tailwind', (rule) => {
-    if (rule.params === 'screens') {
-      rule.params = 'variants'
+    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
 }
diff --git a/src/jit/lib/rebootWatcher.js b/src/jit/lib/rebootWatcher.js
new file mode 100644
--- /dev/null
+++ b/src/jit/lib/rebootWatcher.js
@@ -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}`)
+}
diff --git a/src/jit/lib/removeLayerAtRules.js b/src/jit/lib/removeLayerAtRules.js
deleted file mode 100644
--- a/src/jit/lib/removeLayerAtRules.js
+++ /dev/null
@@ -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()
-      }
-    })
-  }
-}
diff --git a/src/jit/lib/setupContext.js b/src/jit/lib/setupContext.js
deleted file mode 100644
--- a/src/jit/lib/setupContext.js
+++ /dev/null
@@ -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
-  }
-}
diff --git a/src/jit/lib/setupContextUtils.js b/src/jit/lib/setupContextUtils.js
new file mode 100644
--- /dev/null
+++ b/src/jit/lib/setupContextUtils.js
@@ -0,0 +1,606 @@
+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)
+      }
+    },
+  }
+}
+
+function trackModified(files, context) {
+  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 (!context.fileModifiedMap.has(file) || newModified > context.fileModifiedMap.get(file)) {
+      changed = true
+    }
+
+    context.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)
+  }
+
+  env.DEBUG && console.log('Source path:', sourcePath)
+
+  let existingContext
+
+  if (isConfigFile && contextMap.has(sourcePath)) {
+    existingContext = contextMap.get(sourcePath)
+  } else if (configContextMap.has(tailwindConfigHash)) {
+    let context = configContextMap.get(tailwindConfigHash)
+    contextSourcesMap.get(context).add(sourcePath)
+    contextMap.set(sourcePath, context)
+
+    existingContext = context
+  }
+
+  // If there's already a context in the cache and we don't need to
+  // reset the context, return the cached context.
+  if (existingContext) {
+    let contextDependenciesChanged = trackModified([...contextDependencies], existingContext)
+    if (!contextDependenciesChanged) {
+      return [existingContext, 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
+          )
+        )
+      ),
+    // Carry over the existing modified map if we have one.
+    fileModifiedMap: new Map(existingContext ? existingContext.fileModifiedMap : undefined),
+    // ---
+    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
+  }
+
+  if (!existingContext) {
+    // If we didn't have an existing modified map then populate it now.
+    trackModified([...contextDependencies], context)
+  }
+
+  // ---
+
+  // 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]
+}
diff --git a/src/jit/lib/setupTrackingContext.js b/src/jit/lib/setupTrackingContext.js
new file mode 100644
--- /dev/null
+++ b/src/jit/lib/setupTrackingContext.js
@@ -0,0 +1,129 @@
+import fs from 'fs'
+import path from 'path'
+
+import fastGlob from 'fast-glob'
+import isGlob from 'is-glob'
+import globParent from 'glob-parent'
+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) {
+        if (isGlob(maybeGlob)) {
+          // rollup-plugin-postcss does not support dir-dependency messages
+          // but directories can be watched in the same way as files
+          registerDependency(
+            path.resolve(globParent(maybeGlob)),
+            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
+  }
+}
diff --git a/src/jit/lib/setupWatchingContext.js b/src/jit/lib/setupWatchingContext.js
new file mode 100644
--- /dev/null
+++ b/src/jit/lib/setupWatchingContext.js
@@ -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
+  }
+}
diff --git a/src/jit/processTailwindFeatures.js b/src/jit/processTailwindFeatures.js
new file mode 100644
--- /dev/null
+++ b/src/jit/processTailwindFeatures.js
@@ -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)
+  }
+}
diff --git a/src/lib/evaluateTailwindFunctions.js b/src/lib/evaluateTailwindFunctions.js
--- a/src/lib/evaluateTailwindFunctions.js
+++ b/src/lib/evaluateTailwindFunctions.js
@@ -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(
diff --git a/src/lib/purgeUnusedStyles.js b/src/lib/purgeUnusedStyles.js
--- a/src/lib/purgeUnusedStyles.js
+++ b/src/lib/purgeUnusedStyles.js
@@ -34,6 +34,18 @@ export function tailwindExtractor(content) {
   return broadMatches.concat(broadMatchesWithoutTrailingSlash).concat(innerMatches)
 }
 
+function getTransformer(config, fileExtension) {
+  let transformers = (config.purge && config.purge.transform) || {}
+
+  if (typeof transformers === 'function') {
+    transformers = {
+      DEFAULT: transformers,
+    }
+  }
+
+  return transformers[fileExtension] || transformers.DEFAULT || ((content) => content)
+}
+
 export default function purgeUnusedUtilities(config, configChanged, resolvedConfigPath) {
   const purgeEnabled = _.get(
     config,
@@ -58,7 +70,50 @@ export default function purgeUnusedUtilities(config, configChanged, resolvedConf
     return removeTailwindMarkers
   }
 
-  const { defaultExtractor, ...purgeOptions } = config.purge.options || {}
+  const extractors = config.purge.extract || {}
+  const transformers = config.purge.transform || {}
+  let { defaultExtractor: originalDefaultExtractor, ...purgeOptions } = config.purge.options || {}
+
+  if (!originalDefaultExtractor) {
+    originalDefaultExtractor =
+      typeof extractors === 'function' ? extractors : extractors.DEFAULT || tailwindExtractor
+  }
+
+  const defaultExtractor = (content) => {
+    const preserved = originalDefaultExtractor(content)
+
+    if (_.get(config, 'purge.preserveHtmlElements', true)) {
+      preserved.push(...htmlTags)
+    }
+
+    return preserved
+  }
+
+  // If `extractors` is a function then we don't have any file-specific extractors,
+  // only a default one.
+  let fileSpecificExtractors = typeof extractors === 'function' ? {} : extractors
+
+  // PurgeCSS doesn't support "transformers," so we implement those using extractors.
+  // If we have a custom transformer for an extension, but not a matching extractor,
+  // then we need to create an extractor that we can augment later.
+  if (typeof transformers !== 'function') {
+    for (let [extension] of Object.entries(transformers)) {
+      if (!fileSpecificExtractors[extension]) {
+        fileSpecificExtractors[extension] = defaultExtractor
+      }
+    }
+  }
+
+  // Augment file-specific extractors by running the transformer before we extract classes.
+  fileSpecificExtractors = Object.entries(fileSpecificExtractors).map(([extension, extractor]) => {
+    return {
+      extensions: [extension],
+      extractor: (content) => {
+        const transformer = getTransformer(config, extension)
+        return extractor(transformer(content))
+      },
+    }
+  })
 
   return postcss([
     function (css) {
@@ -106,15 +161,10 @@ export default function purgeUnusedUtilities(config, configChanged, resolvedConf
     removeTailwindMarkers,
     purgecss({
       defaultExtractor: (content) => {
-        const extractor = defaultExtractor || tailwindExtractor
-        const preserved = [...extractor(content)]
-
-        if (_.get(config, 'purge.preserveHtmlElements', true)) {
-          preserved.push(...htmlTags)
-        }
-
-        return preserved
+        const transformer = getTransformer(config)
+        return defaultExtractor(transformer(content))
       },
+      extractors: fileSpecificExtractors,
       ...purgeOptions,
       content: (Array.isArray(config.purge)
         ? config.purge
diff --git a/src/lib/substituteClassApplyAtRules.js b/src/lib/substituteClassApplyAtRules.js
--- a/src/lib/substituteClassApplyAtRules.js
+++ b/src/lib/substituteClassApplyAtRules.js
@@ -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,
diff --git a/src/lib/substituteScreenAtRules.js b/src/lib/substituteScreenAtRules.js
--- a/src/lib/substituteScreenAtRules.js
+++ b/src/lib/substituteScreenAtRules.js
@@ -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
diff --git a/src/processTailwindFeatures.js b/src/processTailwindFeatures.js
--- a/src/processTailwindFeatures.js
+++ b/src/processTailwindFeatures.js
@@ -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),
diff --git a/src/util/resolveConfigPath.js b/src/util/resolveConfigPath.js
new file mode 100644
--- /dev/null
+++ b/src/util/resolveConfigPath.js
@@ -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
+}


Test patch:
diff --git a/integrations/parcel/tests/integration.test.js b/integrations/parcel/tests/integration.test.js
--- a/integrations/parcel/tests/integration.test.js
+++ b/integrations/parcel/tests/integration.test.js
@@ -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',
diff --git a/integrations/rollup/tests/integration.test.js b/integrations/rollup/tests/integration.test.js
--- a/integrations/rollup/tests/integration.test.js
+++ b/integrations/rollup/tests/integration.test.js
@@ -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')
 
diff --git a/integrations/webpack-4/tests/integration.test.js b/integrations/webpack-4/tests/integration.test.js
--- a/integrations/webpack-4/tests/integration.test.js
+++ b/integrations/webpack-4/tests/integration.test.js
@@ -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')
 
diff --git a/integrations/webpack-5/tests/integration.test.js b/integrations/webpack-5/tests/integration.test.js
--- a/integrations/webpack-5/tests/integration.test.js
+++ b/integrations/webpack-5/tests/integration.test.js
@@ -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')
 
diff --git a/tests/evaluateTailwindFunctions.test.js b/tests/evaluateTailwindFunctions.test.js
--- a/tests/evaluateTailwindFunctions.test.js
+++ b/tests/evaluateTailwindFunctions.test.js
@@ -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', () => {
diff --git a/tests/jit/custom-extractors.test.js b/tests/jit/custom-extractors.test.js
--- a/tests/jit/custom-extractors.test.js
+++ b/tests/jit/custom-extractors.test.js
@@ -65,3 +65,58 @@ test('extractors array', () => {
     expect(result.css).toMatchFormattedCss(expected)
   })
 })
+
+test('extract function', () => {
+  let config = {
+    mode: 'jit',
+    purge: {
+      content: [path.resolve(__dirname, './custom-extractors.test.html')],
+      extract: customExtractor,
+    },
+    corePlugins: { preflight: false },
+    theme: {},
+    plugins: [],
+  }
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(expected)
+  })
+})
+
+test('extract.DEFAULT', () => {
+  let config = {
+    mode: 'jit',
+    purge: {
+      content: [path.resolve(__dirname, './custom-extractors.test.html')],
+      extract: {
+        DEFAULT: customExtractor,
+      },
+    },
+    corePlugins: { preflight: false },
+    theme: {},
+    plugins: [],
+  }
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(expected)
+  })
+})
+
+test('extract.{extension}', () => {
+  let config = {
+    purge: {
+      content: [path.resolve(__dirname, './custom-extractors.test.html')],
+      extract: {
+        html: customExtractor,
+      },
+    },
+    mode: 'jit',
+    corePlugins: { preflight: false },
+    theme: {},
+    plugins: [],
+  }
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(expected)
+  })
+})
diff --git a/tests/jit/custom-transformers.test.js b/tests/jit/custom-transformers.test.js
new file mode 100644
--- /dev/null
+++ b/tests/jit/custom-transformers.test.js
@@ -0,0 +1,93 @@
+import postcss from 'postcss'
+import path from 'path'
+
+function run(input, config = {}) {
+  jest.resetModules()
+  const tailwind = require('../../src/jit/index.js').default
+  return postcss(tailwind(config)).process(input, {
+    from: path.resolve(__filename),
+  })
+}
+
+function customTransformer(content) {
+  return content.replace(/uppercase/g, 'lowercase')
+}
+
+const css = `
+  @tailwind base;
+  @tailwind components;
+  @tailwind utilities;
+`
+
+test('transform function', () => {
+  let config = {
+    mode: 'jit',
+    purge: {
+      content: [{ raw: '<div class="uppercase"></div>' }],
+      transform: customTransformer,
+    },
+    corePlugins: { preflight: false, borderColor: false, ringWidth: false, boxShadow: false },
+    theme: {},
+    plugins: [],
+  }
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .lowercase {
+        text-transform: lowercase;
+      }
+    `)
+  })
+})
+
+test('transform.DEFAULT', () => {
+  let config = {
+    mode: 'jit',
+    purge: {
+      content: [{ raw: '<div class="uppercase"></div>' }],
+      transform: {
+        DEFAULT: customTransformer,
+      },
+    },
+    corePlugins: { preflight: false, borderColor: false, ringWidth: false, boxShadow: false },
+    theme: {},
+    plugins: [],
+  }
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .lowercase {
+        text-transform: lowercase;
+      }
+    `)
+  })
+})
+
+test('transform.{extension}', () => {
+  let config = {
+    mode: 'jit',
+    purge: {
+      content: [
+        { raw: '<div class="uppercase"></div>', extension: 'html' },
+        { raw: '<div class="uppercase"></div>', extension: 'php' },
+      ],
+      transform: {
+        html: customTransformer,
+      },
+    },
+    corePlugins: { preflight: false, borderColor: false, ringWidth: false, boxShadow: false },
+    theme: {},
+    plugins: [],
+  }
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .uppercase {
+        text-transform: uppercase;
+      }
+      .lowercase {
+        text-transform: lowercase;
+      }
+    `)
+  })
+})
diff --git a/tests/purgeUnusedStyles.test.js b/tests/purgeUnusedStyles.test.js
--- a/tests/purgeUnusedStyles.test.js
+++ b/tests/purgeUnusedStyles.test.js
@@ -663,6 +663,187 @@ test('element selectors are preserved by default', () => {
   )
 })
 
+test('custom default extractor', () => {
+  return inProduction(
+    suppressConsoleLogs(() => {
+      const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`)
+      const input = fs.readFileSync(inputPath, 'utf8')
+
+      return postcss([
+        tailwind({
+          ...config,
+          corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false },
+          purge: {
+            content: [
+              path.resolve(`${__dirname}/fixtures/**/*.html`),
+              { raw: '<div class="uppercase"></div>', extension: 'php' },
+            ],
+            extract: () => [],
+            mode: 'all',
+            preserveHtmlElements: false,
+            options: {
+              keyframes: true,
+            },
+          },
+        }),
+      ])
+        .process(input, { from: withTestName(inputPath) })
+        .then((result) => {
+          expect(result.css).toMatchFormattedCss('')
+        })
+    })
+  )
+})
+
+test('custom extension-specific extractor', () => {
+  return inProduction(
+    suppressConsoleLogs(() => {
+      const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`)
+      const input = fs.readFileSync(inputPath, 'utf8')
+
+      return postcss([
+        tailwind({
+          ...config,
+          corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false },
+          purge: {
+            content: [
+              path.resolve(`${__dirname}/fixtures/**/*.html`),
+              { raw: '<div class="lowercase"></div>', extension: 'html' },
+              { raw: '<div class="uppercase"></div>', extension: 'php' },
+            ],
+            extract: {
+              html: () => [],
+            },
+            mode: 'all',
+            preserveHtmlElements: false,
+            options: {
+              keyframes: true,
+            },
+          },
+        }),
+      ])
+        .process(input, { from: withTestName(inputPath) })
+        .then((result) => {
+          expect(result.css).toMatchFormattedCss(`
+            .uppercase {
+              text-transform: uppercase;
+            }
+          `)
+        })
+    })
+  )
+})
+
+test('custom default transformer', () => {
+  return inProduction(
+    suppressConsoleLogs(() => {
+      const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`)
+      const input = fs.readFileSync(inputPath, 'utf8')
+
+      return postcss([
+        tailwind({
+          ...config,
+          corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false },
+          purge: {
+            content: [{ raw: '<div class="uppercase"></div>', extension: 'html' }],
+            transform: (content) => content.replace(/uppercase/g, 'lowercase'),
+            mode: 'all',
+            preserveHtmlElements: false,
+            options: {
+              keyframes: true,
+            },
+          },
+        }),
+      ])
+        .process(input, { from: withTestName(inputPath) })
+        .then((result) => {
+          expect(result.css).toMatchFormattedCss(`
+            .lowercase {
+              text-transform: lowercase;
+            }
+          `)
+        })
+    })
+  )
+})
+
+test('custom explicit default transformer', () => {
+  return inProduction(
+    suppressConsoleLogs(() => {
+      const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`)
+      const input = fs.readFileSync(inputPath, 'utf8')
+
+      return postcss([
+        tailwind({
+          ...config,
+          corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false },
+          purge: {
+            content: [{ raw: '<div class="uppercase"></div>', extension: 'html' }],
+            transform: {
+              DEFAULT: (content) => content.replace(/uppercase/g, 'lowercase'),
+            },
+            mode: 'all',
+            preserveHtmlElements: false,
+            options: {
+              keyframes: true,
+            },
+          },
+        }),
+      ])
+        .process(input, { from: withTestName(inputPath) })
+        .then((result) => {
+          expect(result.css).toMatchFormattedCss(`
+            .lowercase {
+              text-transform: lowercase;
+            }
+          `)
+        })
+    })
+  )
+})
+
+test('custom extension-specific transformer', () => {
+  return inProduction(
+    suppressConsoleLogs(() => {
+      const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`)
+      const input = fs.readFileSync(inputPath, 'utf8')
+
+      return postcss([
+        tailwind({
+          ...config,
+          corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false },
+          purge: {
+            content: [
+              { raw: '<div class="uppercase"></div>', extension: 'html' },
+              { raw: '<div class="uppercase"></div>', extension: 'php' },
+            ],
+            transform: {
+              html: (content) => content.replace(/uppercase/g, 'lowercase'),
+            },
+            mode: 'all',
+            preserveHtmlElements: false,
+            options: {
+              keyframes: true,
+            },
+          },
+        }),
+      ])
+        .process(input, { from: withTestName(inputPath) })
+        .then((result) => {
+          expect(result.css).toMatchFormattedCss(`
+            .uppercase {
+              text-transform: uppercase;
+            }
+
+            .lowercase {
+              text-transform: lowercase;
+            }
+          `)
+        })
+    })
+  )
+})
+
 test('element selectors are preserved even when defaultExtractor is overridden', () => {
   return inProduction(
     suppressConsoleLogs(() => {
diff --git a/tests/screenAtRule.test.js b/tests/screenAtRule.test.js
--- a/tests/screenAtRule.test.js
+++ b/tests/screenAtRule.test.js
@@ -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', () => {