mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Refactor internals to decouple watch strategies and extract IO
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
parent
89b9e3406f
commit
1849e35f14
@ -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',
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"browser": "./src/index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --mode=production",
|
||||
"dev": "webpack --mode=development",
|
||||
"dev": "webpack --mode=development --watch",
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
import postcss from 'postcss'
|
||||
|
||||
import evaluateTailwindFunctions from '../lib/evaluateTailwindFunctions'
|
||||
import substituteScreenAtRules from '../lib/substituteScreenAtRules'
|
||||
|
||||
import normalizeTailwindDirectives from './lib/normalizeTailwindDirectives'
|
||||
import setupContext from './lib/setupContext'
|
||||
import removeLayerAtRules from './lib/removeLayerAtRules'
|
||||
import expandTailwindAtRules from './lib/expandTailwindAtRules'
|
||||
import expandApplyAtRules from './lib/expandApplyAtRules'
|
||||
import collapseAdjacentRules from './lib/collapseAdjacentRules'
|
||||
|
||||
import setupTrackingContext from './lib/setupTrackingContext'
|
||||
import setupWatchingContext from './lib/setupWatchingContext'
|
||||
import { env } from './lib/sharedState'
|
||||
import processTailwindFeatures from './processTailwindFeatures'
|
||||
|
||||
export default function (configOrPath = {}) {
|
||||
return [
|
||||
@ -32,22 +24,11 @@ export default function (configOrPath = {}) {
|
||||
|
||||
let tailwindDirectives = normalizeTailwindDirectives(root)
|
||||
|
||||
let context = setupContext(configOrPath, tailwindDirectives)(result, root)
|
||||
let context = env.TAILWIND_DISABLE_TOUCH
|
||||
? setupTrackingContext(configOrPath, tailwindDirectives, registerDependency)(result, root)
|
||||
: setupWatchingContext(configOrPath, tailwindDirectives, registerDependency)(result, root)
|
||||
|
||||
if (!env.TAILWIND_DISABLE_TOUCH) {
|
||||
if (context.configPath !== null) {
|
||||
registerDependency(context.configPath)
|
||||
}
|
||||
}
|
||||
|
||||
return postcss([
|
||||
removeLayerAtRules(context, tailwindDirectives),
|
||||
expandTailwindAtRules(context, registerDependency, tailwindDirectives),
|
||||
expandApplyAtRules(context),
|
||||
evaluateTailwindFunctions(context.tailwindConfig),
|
||||
substituteScreenAtRules(context.tailwindConfig),
|
||||
collapseAdjacentRules(context),
|
||||
]).process(root, { from: undefined })
|
||||
processTailwindFeatures(context)(root, result)
|
||||
},
|
||||
env.DEBUG &&
|
||||
function (root) {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import fastGlob from 'fast-glob'
|
||||
import parseGlob from 'parse-glob'
|
||||
import * as sharedState from './sharedState'
|
||||
import { generateRules } from './generateRules'
|
||||
import bigSign from '../../util/bigSign'
|
||||
@ -111,12 +109,8 @@ function buildStylesheet(rules, context) {
|
||||
return returnValue
|
||||
}
|
||||
|
||||
export default function expandTailwindAtRules(context, registerDependency, tailwindDirectives) {
|
||||
export default function expandTailwindAtRules(context) {
|
||||
return (root) => {
|
||||
if (tailwindDirectives.size === 0) {
|
||||
return root
|
||||
}
|
||||
|
||||
let layerNodes = {
|
||||
base: null,
|
||||
components: null,
|
||||
@ -129,76 +123,13 @@ export default function expandTailwindAtRules(context, registerDependency, tailw
|
||||
// file as a dependency since the output of this CSS does not depend on
|
||||
// the source of any templates. Think Vue <style> blocks for example.
|
||||
root.walkAtRules('tailwind', (rule) => {
|
||||
if (rule.params === 'base') {
|
||||
layerNodes.base = rule
|
||||
}
|
||||
|
||||
if (rule.params === 'components') {
|
||||
layerNodes.components = rule
|
||||
}
|
||||
|
||||
if (rule.params === 'utilities') {
|
||||
layerNodes.utilities = rule
|
||||
}
|
||||
|
||||
if (rule.params === 'variants') {
|
||||
layerNodes.variants = rule
|
||||
if (Object.keys(layerNodes).includes(rule.params)) {
|
||||
layerNodes[rule.params] = rule
|
||||
}
|
||||
})
|
||||
|
||||
// ---
|
||||
|
||||
if (sharedState.env.TAILWIND_DISABLE_TOUCH) {
|
||||
for (let maybeGlob of context.candidateFiles) {
|
||||
let {
|
||||
is: { glob: isGlob },
|
||||
base,
|
||||
} = parseGlob(maybeGlob)
|
||||
|
||||
if (isGlob) {
|
||||
// rollup-plugin-postcss does not support dir-dependency messages
|
||||
// but directories can be watched in the same way as files
|
||||
registerDependency(
|
||||
path.resolve(base),
|
||||
process.env.ROLLUP_WATCH === 'true' ? 'dependency' : 'dir-dependency'
|
||||
)
|
||||
} else {
|
||||
registerDependency(path.resolve(maybeGlob))
|
||||
}
|
||||
}
|
||||
|
||||
env.DEBUG && console.time('Finding changed files')
|
||||
let files = fastGlob.sync(context.candidateFiles)
|
||||
for (let file of files) {
|
||||
let prevModified = context.fileModifiedMap.has(file)
|
||||
? context.fileModifiedMap.get(file)
|
||||
: -Infinity
|
||||
let modified = fs.statSync(file).mtimeMs
|
||||
|
||||
if (!context.scannedContent || modified > prevModified) {
|
||||
context.changedFiles.add(file)
|
||||
context.fileModifiedMap.set(file, modified)
|
||||
}
|
||||
}
|
||||
context.scannedContent = true
|
||||
env.DEBUG && console.timeEnd('Finding changed files')
|
||||
} else {
|
||||
// Register our temp file as a dependency — we write to this file
|
||||
// to trigger rebuilds.
|
||||
if (context.touchFile) {
|
||||
registerDependency(context.touchFile)
|
||||
}
|
||||
|
||||
// If we're not set up and watching files ourselves, we need to do
|
||||
// the work of grabbing all of the template files for candidate
|
||||
// detection.
|
||||
if (!context.scannedContent) {
|
||||
let files = fastGlob.sync(context.candidateFiles)
|
||||
for (let file of files) {
|
||||
context.changedFiles.add(file)
|
||||
}
|
||||
context.scannedContent = true
|
||||
}
|
||||
if (Object.values(layerNodes).every((n) => n === null)) {
|
||||
return root
|
||||
}
|
||||
|
||||
// ---
|
||||
@ -208,14 +139,8 @@ export default function expandTailwindAtRules(context, registerDependency, tailw
|
||||
let seen = new Set()
|
||||
|
||||
env.DEBUG && console.time('Reading changed files')
|
||||
for (let file of context.changedFiles) {
|
||||
let content = fs.readFileSync(file, 'utf8')
|
||||
let extractor = getExtractor(context.tailwindConfig, path.extname(file).slice(1))
|
||||
getClassCandidates(content, extractor, contentMatchCache, candidates, seen)
|
||||
}
|
||||
env.DEBUG && console.timeEnd('Reading changed files')
|
||||
|
||||
for (let { content, extension } of context.rawContent) {
|
||||
for (let { content, extension } of context.changedContent) {
|
||||
let extractor = getExtractor(context.tailwindConfig, extension)
|
||||
getClassCandidates(content, extractor, contentMatchCache, candidates, seen)
|
||||
}
|
||||
@ -276,13 +201,12 @@ export default function expandTailwindAtRules(context, registerDependency, tailw
|
||||
// ---
|
||||
|
||||
if (env.DEBUG) {
|
||||
console.log('Changed files: ', context.changedFiles.size)
|
||||
console.log('Potential classes: ', candidates.size)
|
||||
console.log('Active contexts: ', sharedState.contextSourcesMap.size)
|
||||
console.log('Content match entries', contentMatchCache.size)
|
||||
}
|
||||
|
||||
// Clear the cache for the changed files
|
||||
context.changedFiles.clear()
|
||||
context.changedContent = []
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,39 +1,70 @@
|
||||
export default function normalizeTailwindDirectives(root) {
|
||||
root.walkAtRules('import', (atRule) => {
|
||||
if (atRule.params === '"tailwindcss/base"' || atRule.params === "'tailwindcss/base'") {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'base'
|
||||
} else if (
|
||||
atRule.params === '"tailwindcss/components"' ||
|
||||
atRule.params === "'tailwindcss/components'"
|
||||
) {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'components'
|
||||
} else if (
|
||||
atRule.params === '"tailwindcss/utilities"' ||
|
||||
atRule.params === "'tailwindcss/utilities'"
|
||||
) {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'utilities'
|
||||
} else if (
|
||||
atRule.params === '"tailwindcss/screens"' ||
|
||||
atRule.params === "'tailwindcss/screens'" ||
|
||||
atRule.params === '"tailwindcss/variants"' ||
|
||||
atRule.params === "'tailwindcss/variants'"
|
||||
) {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'variants'
|
||||
}
|
||||
})
|
||||
|
||||
let tailwindDirectives = new Set()
|
||||
let layerDirectives = new Set()
|
||||
|
||||
root.walkAtRules('tailwind', (rule) => {
|
||||
if (rule.params === 'screens') {
|
||||
rule.params = 'variants'
|
||||
root.walkAtRules((atRule) => {
|
||||
if (atRule.name === 'import') {
|
||||
if (atRule.params === '"tailwindcss/base"' || atRule.params === "'tailwindcss/base'") {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'base'
|
||||
} else if (
|
||||
atRule.params === '"tailwindcss/components"' ||
|
||||
atRule.params === "'tailwindcss/components'"
|
||||
) {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'components'
|
||||
} else if (
|
||||
atRule.params === '"tailwindcss/utilities"' ||
|
||||
atRule.params === "'tailwindcss/utilities'"
|
||||
) {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'utilities'
|
||||
} else if (
|
||||
atRule.params === '"tailwindcss/screens"' ||
|
||||
atRule.params === "'tailwindcss/screens'" ||
|
||||
atRule.params === '"tailwindcss/variants"' ||
|
||||
atRule.params === "'tailwindcss/variants'"
|
||||
) {
|
||||
atRule.name = 'tailwind'
|
||||
atRule.params = 'variants'
|
||||
}
|
||||
}
|
||||
|
||||
if (atRule.name === 'tailwind') {
|
||||
if (atRule.params === 'screens') {
|
||||
atRule.params = 'variants'
|
||||
}
|
||||
tailwindDirectives.add(atRule.params)
|
||||
}
|
||||
|
||||
if (['layer', 'responsive', 'variants'].includes(atRule.name)) {
|
||||
layerDirectives.add(atRule)
|
||||
}
|
||||
tailwindDirectives.add(rule.params)
|
||||
})
|
||||
|
||||
if (
|
||||
!tailwindDirectives.has('base') ||
|
||||
!tailwindDirectives.has('components') ||
|
||||
!tailwindDirectives.has('utilities')
|
||||
) {
|
||||
for (let rule of layerDirectives) {
|
||||
if (rule.name === 'layer' && ['base', 'components', 'utilities'].includes(rule.params)) {
|
||||
if (!tailwindDirectives.has(rule.params)) {
|
||||
throw rule.error(
|
||||
`\`@layer ${rule.params}\` is used but no matching \`@tailwind ${rule.params}\` directive is present.`
|
||||
)
|
||||
}
|
||||
} else if (rule.name === 'responsive') {
|
||||
if (!tailwindDirectives.has('utilities')) {
|
||||
throw rule.error('`@responsive` is used but `@tailwind utilities` is missing.')
|
||||
}
|
||||
} else if (rule.name === 'variants') {
|
||||
if (!tailwindDirectives.has('utilities')) {
|
||||
throw rule.error('`@variants` is used but `@tailwind utilities` is missing.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tailwindDirectives
|
||||
}
|
||||
|
||||
124
src/jit/lib/rebootWatcher.js
Normal file
124
src/jit/lib/rebootWatcher.js
Normal file
@ -0,0 +1,124 @@
|
||||
// @ts-check
|
||||
|
||||
import chokidar from 'chokidar'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import log from '../../util/log'
|
||||
import { env } from './sharedState'
|
||||
|
||||
// Earmarks a directory for our touch files.
|
||||
// If the directory already exists we delete any existing touch files,
|
||||
// invalidating any caches associated with them.
|
||||
let touchDir =
|
||||
env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch')
|
||||
|
||||
if (!env.TAILWIND_DISABLE_TOUCH) {
|
||||
if (fs.existsSync(touchDir)) {
|
||||
for (let file of fs.readdirSync(touchDir)) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(touchDir, file))
|
||||
} catch (_err) {}
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(touchDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// This is used to trigger rebuilds. Just updating the timestamp
|
||||
// is significantly faster than actually writing to the file (10x).
|
||||
|
||||
function touch(filename) {
|
||||
let time = new Date()
|
||||
|
||||
try {
|
||||
fs.utimesSync(filename, time, time)
|
||||
} catch (err) {
|
||||
fs.closeSync(fs.openSync(filename, 'w'))
|
||||
}
|
||||
}
|
||||
|
||||
export function rebootWatcher(context) {
|
||||
if (context.touchFile === null) {
|
||||
context.touchFile = generateTouchFileName()
|
||||
touch(context.touchFile)
|
||||
}
|
||||
|
||||
if (env.TAILWIND_MODE === 'build') {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
env.TAILWIND_MODE === 'watch' ||
|
||||
(env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development')
|
||||
) {
|
||||
Promise.resolve(context.watcher ? context.watcher.close() : null).then(() => {
|
||||
log.info([
|
||||
'Tailwind CSS is watching for changes...',
|
||||
'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds',
|
||||
])
|
||||
|
||||
context.watcher = chokidar.watch([...context.candidateFiles, ...context.configDependencies], {
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
context.watcher.on('add', (file) => {
|
||||
let changedFile = path.resolve('.', file)
|
||||
let content = fs.readFileSync(changedFile, 'utf8')
|
||||
let extension = path.extname(changedFile).slice(1)
|
||||
context.changedContent.push({ content, extension })
|
||||
touch(context.touchFile)
|
||||
})
|
||||
|
||||
context.watcher.on('change', (file) => {
|
||||
// If it was a config dependency, touch the config file to trigger a new context.
|
||||
// This is not really that clean of a solution but it's the fastest, because we
|
||||
// can do a very quick check on each build to see if the config has changed instead
|
||||
// of having to get all of the module dependencies and check every timestamp each
|
||||
// time.
|
||||
if (context.configDependencies.has(file)) {
|
||||
for (let dependency of context.configDependencies) {
|
||||
delete require.cache[require.resolve(dependency)]
|
||||
}
|
||||
touch(context.configPath)
|
||||
} else {
|
||||
let changedFile = path.resolve('.', file)
|
||||
let content = fs.readFileSync(changedFile, 'utf8')
|
||||
let extension = path.extname(changedFile).slice(1)
|
||||
context.changedContent.push({ content, extension })
|
||||
touch(context.touchFile)
|
||||
}
|
||||
})
|
||||
|
||||
context.watcher.on('unlink', (file) => {
|
||||
// Touch the config file if any of the dependencies are deleted.
|
||||
if (context.configDependencies.has(file)) {
|
||||
for (let dependency of context.configDependencies) {
|
||||
delete require.cache[require.resolve(dependency)]
|
||||
}
|
||||
touch(context.configPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function generateTouchFileName() {
|
||||
let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
let randomChars = ''
|
||||
let randomCharsLength = 12
|
||||
let bytes = null
|
||||
|
||||
try {
|
||||
bytes = crypto.randomBytes(randomCharsLength)
|
||||
} catch (_error) {
|
||||
bytes = crypto.pseudoRandomBytes(randomCharsLength)
|
||||
}
|
||||
|
||||
for (let i = 0; i < randomCharsLength; i++) {
|
||||
randomChars += chars[bytes[i] % chars.length]
|
||||
}
|
||||
|
||||
return path.join(touchDir, `touch-${process.pid}-${randomChars}`)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
600
src/jit/lib/setupContextUtils.js
Normal file
600
src/jit/lib/setupContextUtils.js
Normal file
@ -0,0 +1,600 @@
|
||||
import fs from 'fs'
|
||||
import url from 'url'
|
||||
import path from 'path'
|
||||
import postcss from 'postcss'
|
||||
import dlv from 'dlv'
|
||||
import selectorParser from 'postcss-selector-parser'
|
||||
import normalizePath from 'normalize-path'
|
||||
|
||||
import transformThemeValue from '../../util/transformThemeValue'
|
||||
import parseObjectStyles from '../../util/parseObjectStyles'
|
||||
import prefixSelector from '../../util/prefixSelector'
|
||||
import isPlainObject from '../../util/isPlainObject'
|
||||
import escapeClassName from '../../util/escapeClassName'
|
||||
import nameClass from '../../util/nameClass'
|
||||
import { coerceValue } from '../../util/pluginUtils'
|
||||
import corePlugins from '../corePlugins'
|
||||
import * as sharedState from './sharedState'
|
||||
import { env } from './sharedState'
|
||||
|
||||
function toPath(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
let inBrackets = false
|
||||
let parts = []
|
||||
let chunk = ''
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
let char = value[i]
|
||||
if (char === '[') {
|
||||
inBrackets = true
|
||||
parts.push(chunk)
|
||||
chunk = ''
|
||||
continue
|
||||
}
|
||||
if (char === ']' && inBrackets) {
|
||||
inBrackets = false
|
||||
parts.push(chunk)
|
||||
chunk = ''
|
||||
continue
|
||||
}
|
||||
if (char === '.' && !inBrackets && chunk.length > 0) {
|
||||
parts.push(chunk)
|
||||
chunk = ''
|
||||
continue
|
||||
}
|
||||
chunk = chunk + char
|
||||
}
|
||||
|
||||
if (chunk.length > 0) {
|
||||
parts.push(chunk)
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function insertInto(list, value, { before = [] } = {}) {
|
||||
before = [].concat(before)
|
||||
|
||||
if (before.length <= 0) {
|
||||
list.push(value)
|
||||
return
|
||||
}
|
||||
|
||||
let idx = list.length - 1
|
||||
for (let other of before) {
|
||||
let iidx = list.indexOf(other)
|
||||
if (iidx === -1) continue
|
||||
idx = Math.min(idx, iidx)
|
||||
}
|
||||
|
||||
list.splice(idx, 0, value)
|
||||
}
|
||||
|
||||
function parseStyles(styles) {
|
||||
if (!Array.isArray(styles)) {
|
||||
return parseStyles([styles])
|
||||
}
|
||||
|
||||
return styles.flatMap((style) => {
|
||||
let isNode = !Array.isArray(style) && !isPlainObject(style)
|
||||
return isNode ? style : parseObjectStyles(style)
|
||||
})
|
||||
}
|
||||
|
||||
function getClasses(selector) {
|
||||
let parser = selectorParser((selectors) => {
|
||||
let allClasses = []
|
||||
selectors.walkClasses((classNode) => {
|
||||
allClasses.push(classNode.value)
|
||||
})
|
||||
return allClasses
|
||||
})
|
||||
return parser.transformSync(selector)
|
||||
}
|
||||
|
||||
function extractCandidates(node) {
|
||||
let classes = node.type === 'rule' ? getClasses(node.selector) : []
|
||||
|
||||
if (node.type === 'atrule') {
|
||||
node.walkRules((rule) => {
|
||||
classes = [...classes, ...getClasses(rule.selector)]
|
||||
})
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function withIdentifiers(styles) {
|
||||
return parseStyles(styles).flatMap((node) => {
|
||||
let nodeMap = new Map()
|
||||
let candidates = extractCandidates(node)
|
||||
|
||||
// If this isn't "on-demandable", assign it a universal candidate.
|
||||
if (candidates.length === 0) {
|
||||
return [['*', node]]
|
||||
}
|
||||
|
||||
return candidates.map((c) => {
|
||||
if (!nodeMap.has(node)) {
|
||||
nodeMap.set(node, node)
|
||||
}
|
||||
return [c, nodeMap.get(node)]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets }) {
|
||||
function getConfigValue(path, defaultValue) {
|
||||
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
|
||||
}
|
||||
|
||||
function applyConfiguredPrefix(selector) {
|
||||
return prefixSelector(tailwindConfig.prefix, selector)
|
||||
}
|
||||
|
||||
function prefixIdentifier(identifier, options) {
|
||||
if (identifier === '*') {
|
||||
return '*'
|
||||
}
|
||||
|
||||
if (!options.respectPrefix) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
if (typeof context.tailwindConfig.prefix === 'function') {
|
||||
return prefixSelector(context.tailwindConfig.prefix, `.${identifier}`).substr(1)
|
||||
}
|
||||
|
||||
return context.tailwindConfig.prefix + identifier
|
||||
}
|
||||
|
||||
return {
|
||||
addVariant(variantName, applyThisVariant, options = {}) {
|
||||
insertInto(variantList, variantName, options)
|
||||
variantMap.set(variantName, applyThisVariant)
|
||||
},
|
||||
postcss,
|
||||
prefix: applyConfiguredPrefix,
|
||||
e: escapeClassName,
|
||||
config: getConfigValue,
|
||||
theme(path, defaultValue) {
|
||||
const [pathRoot, ...subPaths] = toPath(path)
|
||||
const value = getConfigValue(['theme', pathRoot, ...subPaths], defaultValue)
|
||||
return transformThemeValue(pathRoot)(value)
|
||||
},
|
||||
corePlugins: (path) => {
|
||||
if (Array.isArray(tailwindConfig.corePlugins)) {
|
||||
return tailwindConfig.corePlugins.includes(path)
|
||||
}
|
||||
|
||||
return getConfigValue(['corePlugins', path], true)
|
||||
},
|
||||
variants: (path, defaultValue) => {
|
||||
if (Array.isArray(tailwindConfig.variants)) {
|
||||
return tailwindConfig.variants
|
||||
}
|
||||
|
||||
return getConfigValue(['variants', path], defaultValue)
|
||||
},
|
||||
addBase(base) {
|
||||
for (let [identifier, rule] of withIdentifiers(base)) {
|
||||
let prefixedIdentifier = prefixIdentifier(identifier, {})
|
||||
let offset = offsets.base++
|
||||
|
||||
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
|
||||
context.candidateRuleMap.set(prefixedIdentifier, [])
|
||||
}
|
||||
|
||||
context.candidateRuleMap
|
||||
.get(prefixedIdentifier)
|
||||
.push([{ sort: offset, layer: 'base' }, rule])
|
||||
}
|
||||
},
|
||||
addComponents(components, options) {
|
||||
let defaultOptions = {
|
||||
variants: [],
|
||||
respectPrefix: true,
|
||||
respectImportant: false,
|
||||
respectVariants: true,
|
||||
}
|
||||
|
||||
options = Object.assign(
|
||||
{},
|
||||
defaultOptions,
|
||||
Array.isArray(options) ? { variants: options } : options
|
||||
)
|
||||
|
||||
for (let [identifier, rule] of withIdentifiers(components)) {
|
||||
let prefixedIdentifier = prefixIdentifier(identifier, options)
|
||||
let offset = offsets.components++
|
||||
|
||||
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
|
||||
context.candidateRuleMap.set(prefixedIdentifier, [])
|
||||
}
|
||||
|
||||
context.candidateRuleMap
|
||||
.get(prefixedIdentifier)
|
||||
.push([{ sort: offset, layer: 'components', options }, rule])
|
||||
}
|
||||
},
|
||||
addUtilities(utilities, options) {
|
||||
let defaultOptions = {
|
||||
variants: [],
|
||||
respectPrefix: true,
|
||||
respectImportant: true,
|
||||
respectVariants: true,
|
||||
}
|
||||
|
||||
options = Object.assign(
|
||||
{},
|
||||
defaultOptions,
|
||||
Array.isArray(options) ? { variants: options } : options
|
||||
)
|
||||
|
||||
for (let [identifier, rule] of withIdentifiers(utilities)) {
|
||||
let prefixedIdentifier = prefixIdentifier(identifier, options)
|
||||
let offset = offsets.utilities++
|
||||
|
||||
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
|
||||
context.candidateRuleMap.set(prefixedIdentifier, [])
|
||||
}
|
||||
|
||||
context.candidateRuleMap
|
||||
.get(prefixedIdentifier)
|
||||
.push([{ sort: offset, layer: 'utilities', options }, rule])
|
||||
}
|
||||
},
|
||||
matchUtilities: function (utilities, options) {
|
||||
let defaultOptions = {
|
||||
variants: [],
|
||||
respectPrefix: true,
|
||||
respectImportant: true,
|
||||
respectVariants: true,
|
||||
}
|
||||
|
||||
options = { ...defaultOptions, ...options }
|
||||
|
||||
let offset = offsets.utilities++
|
||||
|
||||
for (let identifier in utilities) {
|
||||
let prefixedIdentifier = prefixIdentifier(identifier, options)
|
||||
let rule = utilities[identifier]
|
||||
|
||||
function wrapped(modifier) {
|
||||
let { type = 'any' } = options
|
||||
type = [].concat(type)
|
||||
let [value, coercedType] = coerceValue(type, modifier, options.values, tailwindConfig)
|
||||
|
||||
if (!type.includes(coercedType) || value === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
let includedRules = []
|
||||
let ruleSets = []
|
||||
.concat(
|
||||
rule(value, {
|
||||
includeRules(rules) {
|
||||
includedRules.push(...rules)
|
||||
},
|
||||
})
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map((declaration) => ({
|
||||
[nameClass(identifier, modifier)]: declaration,
|
||||
}))
|
||||
|
||||
return [...includedRules, ...ruleSets]
|
||||
}
|
||||
|
||||
let withOffsets = [{ sort: offset, layer: 'utilities', options }, wrapped]
|
||||
|
||||
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
|
||||
context.candidateRuleMap.set(prefixedIdentifier, [])
|
||||
}
|
||||
|
||||
context.candidateRuleMap.get(prefixedIdentifier).push(withOffsets)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let fileModifiedMap = new Map()
|
||||
|
||||
function trackModified(files) {
|
||||
let changed = false
|
||||
|
||||
for (let file of files) {
|
||||
if (!file) continue
|
||||
|
||||
let parsed = url.parse(file)
|
||||
let pathname = parsed.href.replace(parsed.hash, '').replace(parsed.search, '')
|
||||
let newModified = fs.statSync(decodeURIComponent(pathname)).mtimeMs
|
||||
|
||||
if (!fileModifiedMap.has(file) || newModified > fileModifiedMap.get(file)) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
fileModifiedMap.set(file, newModified)
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function extractVariantAtRules(node) {
|
||||
node.walkAtRules((atRule) => {
|
||||
if (['responsive', 'variants'].includes(atRule.name)) {
|
||||
extractVariantAtRules(atRule)
|
||||
atRule.before(atRule.nodes)
|
||||
atRule.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function collectLayerPlugins(root) {
|
||||
let layerPlugins = []
|
||||
|
||||
root.each((node) => {
|
||||
if (node.type === 'atrule' && ['responsive', 'variants'].includes(node.name)) {
|
||||
node.name = 'layer'
|
||||
node.params = 'utilities'
|
||||
}
|
||||
})
|
||||
|
||||
// Walk @layer rules and treat them like plugins
|
||||
root.walkAtRules('layer', (layerRule) => {
|
||||
extractVariantAtRules(layerRule)
|
||||
|
||||
if (layerRule.params === 'base') {
|
||||
for (let node of layerRule.nodes) {
|
||||
layerPlugins.push(function ({ addBase }) {
|
||||
addBase(node, { respectPrefix: false })
|
||||
})
|
||||
}
|
||||
layerRule.remove()
|
||||
} else if (layerRule.params === 'components') {
|
||||
for (let node of layerRule.nodes) {
|
||||
layerPlugins.push(function ({ addComponents }) {
|
||||
addComponents(node, { respectPrefix: false })
|
||||
})
|
||||
}
|
||||
layerRule.remove()
|
||||
} else if (layerRule.params === 'utilities') {
|
||||
for (let node of layerRule.nodes) {
|
||||
layerPlugins.push(function ({ addUtilities }) {
|
||||
addUtilities(node, { respectPrefix: false })
|
||||
})
|
||||
}
|
||||
layerRule.remove()
|
||||
}
|
||||
})
|
||||
|
||||
return layerPlugins
|
||||
}
|
||||
|
||||
function resolvePlugins(context, tailwindDirectives, root) {
|
||||
let corePluginList = Object.entries(corePlugins)
|
||||
.map(([name, plugin]) => {
|
||||
if (!context.tailwindConfig.corePlugins.includes(name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return plugin
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
let userPlugins = context.tailwindConfig.plugins.map((plugin) => {
|
||||
if (plugin.__isOptionsFunction) {
|
||||
plugin = plugin()
|
||||
}
|
||||
|
||||
return typeof plugin === 'function' ? plugin : plugin.handler
|
||||
})
|
||||
|
||||
let layerPlugins = collectLayerPlugins(root, tailwindDirectives)
|
||||
|
||||
// TODO: This is a workaround for backwards compatibility, since custom variants
|
||||
// were historically sorted before screen/stackable variants.
|
||||
let beforeVariants = [corePlugins['pseudoClassVariants']]
|
||||
let afterVariants = [
|
||||
corePlugins['directionVariants'],
|
||||
corePlugins['reducedMotionVariants'],
|
||||
corePlugins['darkVariants'],
|
||||
corePlugins['screenVariants'],
|
||||
]
|
||||
|
||||
return [...corePluginList, ...beforeVariants, ...userPlugins, ...afterVariants, ...layerPlugins]
|
||||
}
|
||||
|
||||
function registerPlugins(plugins, context) {
|
||||
let variantList = []
|
||||
let variantMap = new Map()
|
||||
let offsets = {
|
||||
base: 0n,
|
||||
components: 0n,
|
||||
utilities: 0n,
|
||||
}
|
||||
|
||||
let pluginApi = buildPluginApi(context.tailwindConfig, context, {
|
||||
variantList,
|
||||
variantMap,
|
||||
offsets,
|
||||
})
|
||||
|
||||
for (let plugin of plugins) {
|
||||
if (Array.isArray(plugin)) {
|
||||
for (let pluginItem of plugin) {
|
||||
pluginItem(pluginApi)
|
||||
}
|
||||
} else {
|
||||
plugin(pluginApi)
|
||||
}
|
||||
}
|
||||
|
||||
let highestOffset = ((args) => args.reduce((m, e) => (e > m ? e : m)))([
|
||||
offsets.base,
|
||||
offsets.components,
|
||||
offsets.utilities,
|
||||
])
|
||||
let reservedBits = BigInt(highestOffset.toString(2).length)
|
||||
|
||||
context.layerOrder = {
|
||||
base: (1n << reservedBits) << 0n,
|
||||
components: (1n << reservedBits) << 1n,
|
||||
utilities: (1n << reservedBits) << 2n,
|
||||
}
|
||||
|
||||
reservedBits += 3n
|
||||
context.variantOrder = variantList.reduce(
|
||||
(map, variant, i) => map.set(variant, (1n << BigInt(i)) << reservedBits),
|
||||
new Map()
|
||||
)
|
||||
|
||||
context.minimumScreen = [...context.variantOrder.values()].shift()
|
||||
|
||||
// Build variantMap
|
||||
for (let [variantName, variantFunction] of variantMap.entries()) {
|
||||
let sort = context.variantOrder.get(variantName)
|
||||
context.variantMap.set(variantName, [sort, variantFunction])
|
||||
}
|
||||
}
|
||||
|
||||
let contextMap = sharedState.contextMap
|
||||
let configContextMap = sharedState.configContextMap
|
||||
let contextSourcesMap = sharedState.contextSourcesMap
|
||||
|
||||
function cleanupContext(context) {
|
||||
if (context.watcher) {
|
||||
context.watcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function getContext(
|
||||
configOrPath,
|
||||
tailwindDirectives,
|
||||
registerDependency,
|
||||
root,
|
||||
result,
|
||||
getTailwindConfig
|
||||
) {
|
||||
let sourcePath = result.opts.from
|
||||
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
|
||||
getTailwindConfig(configOrPath)
|
||||
let isConfigFile = userConfigPath !== null
|
||||
|
||||
let contextDependencies = new Set(configDependencies)
|
||||
|
||||
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
|
||||
// to be dependencies of the context. Can reuse the context even if they change.
|
||||
// We may want to think about `@layer` being part of this trigger too, but it's tough
|
||||
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
|
||||
// in another file since independent sources are effectively isolated.
|
||||
if (tailwindDirectives.size > 0) {
|
||||
// Add current css file as a context dependencies.
|
||||
contextDependencies.add(sourcePath)
|
||||
|
||||
// Add all css @import dependencies as context dependencies.
|
||||
for (let message of result.messages) {
|
||||
if (message.type === 'dependency') {
|
||||
contextDependencies.add(message.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let file of configDependencies) {
|
||||
registerDependency(file)
|
||||
}
|
||||
|
||||
let contextDependenciesChanged = trackModified([...contextDependencies])
|
||||
|
||||
env.DEBUG && console.log('Source path:', sourcePath)
|
||||
|
||||
if (!contextDependenciesChanged) {
|
||||
// If this file already has a context in the cache and we don't need to
|
||||
// reset the context, return the cached context.
|
||||
if (isConfigFile && contextMap.has(sourcePath)) {
|
||||
return [contextMap.get(sourcePath), false]
|
||||
}
|
||||
|
||||
// If the config used already exists in the cache, return that.
|
||||
if (configContextMap.has(tailwindConfigHash)) {
|
||||
let context = configContextMap.get(tailwindConfigHash)
|
||||
contextSourcesMap.get(context).add(sourcePath)
|
||||
contextMap.set(sourcePath, context)
|
||||
|
||||
return [context, false]
|
||||
}
|
||||
}
|
||||
|
||||
// If this source is in the context map, get the old context.
|
||||
// Remove this source from the context sources for the old context,
|
||||
// and clean up that context if no one else is using it. This can be
|
||||
// called by many processes in rapid succession, so we check for presence
|
||||
// first because the first process to run this code will wipe it out first.
|
||||
if (contextMap.has(sourcePath)) {
|
||||
let oldContext = contextMap.get(sourcePath)
|
||||
if (contextSourcesMap.has(oldContext)) {
|
||||
contextSourcesMap.get(oldContext).delete(sourcePath)
|
||||
if (contextSourcesMap.get(oldContext).size === 0) {
|
||||
contextSourcesMap.delete(oldContext)
|
||||
cleanupContext(oldContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
env.DEBUG && console.log('Setting up new context...')
|
||||
|
||||
let purgeContent = Array.isArray(tailwindConfig.purge)
|
||||
? tailwindConfig.purge
|
||||
: tailwindConfig.purge.content
|
||||
|
||||
let context = {
|
||||
watcher: null,
|
||||
touchFile: null,
|
||||
configPath: userConfigPath,
|
||||
configDependencies: new Set(),
|
||||
candidateFiles: purgeContent
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((purgePath) =>
|
||||
normalizePath(
|
||||
path.resolve(
|
||||
userConfigPath === null ? process.cwd() : path.dirname(userConfigPath),
|
||||
purgePath
|
||||
)
|
||||
)
|
||||
),
|
||||
fileModifiedMap: new Map(),
|
||||
// ---
|
||||
ruleCache: new Set(), // Hit
|
||||
classCache: new Map(), // Hit
|
||||
applyClassCache: new Map(), // Hit
|
||||
notClassCache: new Set(), // Hit
|
||||
postCssNodeCache: new Map(), // Hit
|
||||
candidateRuleMap: new Map(), // Hit
|
||||
tailwindConfig: tailwindConfig, // Hit
|
||||
changedContent: purgeContent // Hit
|
||||
.filter((item) => typeof item.raw === 'string')
|
||||
.map(({ raw, extension }) => ({ content: raw, extension })),
|
||||
variantMap: new Map(), // Hit
|
||||
stylesheetCache: null, // Hit
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
// Update all context tracking state
|
||||
|
||||
configContextMap.set(tailwindConfigHash, context)
|
||||
contextMap.set(sourcePath, context)
|
||||
|
||||
if (!contextSourcesMap.has(context)) {
|
||||
contextSourcesMap.set(context, new Set())
|
||||
}
|
||||
|
||||
contextSourcesMap.get(context).add(sourcePath)
|
||||
|
||||
registerPlugins(resolvePlugins(context, tailwindDirectives, root), context)
|
||||
|
||||
return [context, true]
|
||||
}
|
||||
133
src/jit/lib/setupTrackingContext.js
Normal file
133
src/jit/lib/setupTrackingContext.js
Normal file
@ -0,0 +1,133 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import fastGlob from 'fast-glob'
|
||||
import parseGlob from 'parse-glob'
|
||||
import LRU from 'quick-lru'
|
||||
|
||||
import hash from '../../util/hashConfig'
|
||||
import getModuleDependencies from '../../lib/getModuleDependencies'
|
||||
|
||||
import resolveConfig from '../../../resolveConfig'
|
||||
|
||||
import resolveConfigPath from '../../util/resolveConfigPath'
|
||||
|
||||
import { env } from './sharedState'
|
||||
|
||||
import { getContext } from './setupContextUtils'
|
||||
|
||||
let configPathCache = new LRU({ maxSize: 100 })
|
||||
|
||||
// Get the config object based on a path
|
||||
function getTailwindConfig(configOrPath) {
|
||||
let userConfigPath = resolveConfigPath(configOrPath)
|
||||
|
||||
if (userConfigPath !== null) {
|
||||
let [prevConfig, prevConfigHash, prevDeps, prevModified] =
|
||||
configPathCache.get(userConfigPath) || []
|
||||
|
||||
let newDeps = getModuleDependencies(userConfigPath).map((dep) => dep.file)
|
||||
|
||||
let modified = false
|
||||
let newModified = new Map()
|
||||
for (let file of newDeps) {
|
||||
let time = fs.statSync(file).mtimeMs
|
||||
newModified.set(file, time)
|
||||
if (!prevModified || !prevModified.has(file) || time > prevModified.get(file)) {
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
// It hasn't changed (based on timestamps)
|
||||
if (!modified) {
|
||||
return [prevConfig, userConfigPath, prevConfigHash, prevDeps]
|
||||
}
|
||||
|
||||
// It has changed (based on timestamps), or first run
|
||||
for (let file of newDeps) {
|
||||
delete require.cache[file]
|
||||
}
|
||||
let newConfig = resolveConfig(require(userConfigPath))
|
||||
let newHash = hash(newConfig)
|
||||
configPathCache.set(userConfigPath, [newConfig, newHash, newDeps, newModified])
|
||||
return [newConfig, userConfigPath, newHash, newDeps]
|
||||
}
|
||||
|
||||
// It's a plain object, not a path
|
||||
let newConfig = resolveConfig(
|
||||
configOrPath.config === undefined ? configOrPath : configOrPath.config
|
||||
)
|
||||
|
||||
return [newConfig, null, hash(newConfig), []]
|
||||
}
|
||||
|
||||
function resolveChangedFiles(context) {
|
||||
let changedFiles = new Set()
|
||||
env.DEBUG && console.time('Finding changed files')
|
||||
let files = fastGlob.sync(context.candidateFiles)
|
||||
for (let file of files) {
|
||||
let prevModified = context.fileModifiedMap.has(file)
|
||||
? context.fileModifiedMap.get(file)
|
||||
: -Infinity
|
||||
let modified = fs.statSync(file).mtimeMs
|
||||
|
||||
if (modified > prevModified) {
|
||||
changedFiles.add(file)
|
||||
context.fileModifiedMap.set(file, modified)
|
||||
}
|
||||
}
|
||||
env.DEBUG && console.timeEnd('Finding changed files')
|
||||
return changedFiles
|
||||
}
|
||||
|
||||
// DISABLE_TOUCH = TRUE
|
||||
|
||||
// Retrieve an existing context from cache if possible (since contexts are unique per
|
||||
// source path), or set up a new one (including setting up watchers and registering
|
||||
// plugins) then return it
|
||||
export default function setupTrackingContext(configOrPath, tailwindDirectives, registerDependency) {
|
||||
return (result, root) => {
|
||||
let [context] = getContext(
|
||||
configOrPath,
|
||||
tailwindDirectives,
|
||||
registerDependency,
|
||||
root,
|
||||
result,
|
||||
getTailwindConfig
|
||||
)
|
||||
|
||||
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
|
||||
// to be dependencies of the context. Can reuse the context even if they change.
|
||||
// We may want to think about `@layer` being part of this trigger too, but it's tough
|
||||
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
|
||||
// in another file since independent sources are effectively isolated.
|
||||
if (tailwindDirectives.size > 0) {
|
||||
// Add template paths as postcss dependencies.
|
||||
for (let maybeGlob of context.candidateFiles) {
|
||||
let {
|
||||
is: { glob: isGlob },
|
||||
base,
|
||||
} = parseGlob(maybeGlob)
|
||||
|
||||
if (isGlob) {
|
||||
// rollup-plugin-postcss does not support dir-dependency messages
|
||||
// but directories can be watched in the same way as files
|
||||
registerDependency(
|
||||
path.resolve(base),
|
||||
env.ROLLUP_WATCH === 'true' ? 'dependency' : 'dir-dependency'
|
||||
)
|
||||
} else {
|
||||
registerDependency(path.resolve(maybeGlob))
|
||||
}
|
||||
}
|
||||
|
||||
for (let changedFile of resolveChangedFiles(context)) {
|
||||
let content = fs.readFileSync(changedFile, 'utf8')
|
||||
let extension = path.extname(changedFile).slice(1)
|
||||
context.changedContent.push({ content, extension })
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
112
src/jit/lib/setupWatchingContext.js
Normal file
112
src/jit/lib/setupWatchingContext.js
Normal file
@ -0,0 +1,112 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import fastGlob from 'fast-glob'
|
||||
import LRU from 'quick-lru'
|
||||
|
||||
import hash from '../../util/hashConfig'
|
||||
import getModuleDependencies from '../../lib/getModuleDependencies'
|
||||
|
||||
import resolveConfig from '../../../resolveConfig'
|
||||
|
||||
import resolveConfigPath from '../../util/resolveConfigPath'
|
||||
|
||||
import { rebootWatcher } from './rebootWatcher'
|
||||
import { getContext } from './setupContextUtils'
|
||||
|
||||
let configPathCache = new LRU({ maxSize: 100 })
|
||||
|
||||
// Get the config object based on a path
|
||||
function getTailwindConfig(configOrPath) {
|
||||
let userConfigPath = resolveConfigPath(configOrPath)
|
||||
|
||||
if (userConfigPath !== null) {
|
||||
let [prevConfig, prevModified = -Infinity, prevConfigHash] =
|
||||
configPathCache.get(userConfigPath) || []
|
||||
let modified = fs.statSync(userConfigPath).mtimeMs
|
||||
|
||||
// It hasn't changed (based on timestamp)
|
||||
if (modified <= prevModified) {
|
||||
return [prevConfig, userConfigPath, prevConfigHash, [userConfigPath]]
|
||||
}
|
||||
|
||||
// It has changed (based on timestamp), or first run
|
||||
delete require.cache[userConfigPath]
|
||||
let newConfig = resolveConfig(require(userConfigPath))
|
||||
let newHash = hash(newConfig)
|
||||
configPathCache.set(userConfigPath, [newConfig, modified, newHash])
|
||||
return [newConfig, userConfigPath, newHash, [userConfigPath]]
|
||||
}
|
||||
|
||||
// It's a plain object, not a path
|
||||
let newConfig = resolveConfig(
|
||||
configOrPath.config === undefined ? configOrPath : configOrPath.config
|
||||
)
|
||||
|
||||
return [newConfig, null, hash(newConfig), [userConfigPath]]
|
||||
}
|
||||
|
||||
function resolveChangedFiles(context) {
|
||||
let changedFiles = new Set()
|
||||
|
||||
// If we're not set up and watching files ourselves, we need to do
|
||||
// the work of grabbing all of the template files for candidate
|
||||
// detection.
|
||||
if (!context.scannedContent) {
|
||||
let files = fastGlob.sync(context.candidateFiles)
|
||||
for (let file of files) {
|
||||
changedFiles.add(file)
|
||||
}
|
||||
context.scannedContent = true
|
||||
}
|
||||
|
||||
return changedFiles
|
||||
}
|
||||
|
||||
// DISABLE_TOUCH = FALSE
|
||||
|
||||
// Retrieve an existing context from cache if possible (since contexts are unique per
|
||||
// source path), or set up a new one (including setting up watchers and registering
|
||||
// plugins) then return it
|
||||
export default function setupWatchingContext(configOrPath, tailwindDirectives, registerDependency) {
|
||||
return (result, root) => {
|
||||
let [context, newContext] = getContext(
|
||||
configOrPath,
|
||||
tailwindDirectives,
|
||||
registerDependency,
|
||||
root,
|
||||
result,
|
||||
getTailwindConfig
|
||||
)
|
||||
|
||||
if (context.configPath !== null) {
|
||||
for (let dependency of getModuleDependencies(context.configPath)) {
|
||||
if (dependency.file === context.configPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
context.configDependencies.add(dependency.file)
|
||||
}
|
||||
}
|
||||
|
||||
if (newContext) {
|
||||
rebootWatcher(context)
|
||||
}
|
||||
|
||||
// Register our temp file as a dependency — we write to this file
|
||||
// to trigger rebuilds.
|
||||
if (context.touchFile) {
|
||||
registerDependency(context.touchFile)
|
||||
}
|
||||
|
||||
if (tailwindDirectives.size > 0) {
|
||||
for (let changedFile of resolveChangedFiles(context)) {
|
||||
let content = fs.readFileSync(changedFile, 'utf8')
|
||||
let extension = path.extname(changedFile).slice(1)
|
||||
context.changedContent.push({ content, extension })
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
15
src/jit/processTailwindFeatures.js
Normal file
15
src/jit/processTailwindFeatures.js
Normal file
@ -0,0 +1,15 @@
|
||||
import expandTailwindAtRules from './lib/expandTailwindAtRules'
|
||||
import expandApplyAtRules from './lib/expandApplyAtRules'
|
||||
import evaluateTailwindFunctions from '../lib/evaluateTailwindFunctions'
|
||||
import substituteScreenAtRules from '../lib/substituteScreenAtRules'
|
||||
import collapseAdjacentRules from './lib/collapseAdjacentRules'
|
||||
|
||||
export default function processTailwindFeatures(context) {
|
||||
return function (root, result) {
|
||||
expandTailwindAtRules(context)(root, result)
|
||||
expandApplyAtRules(context)(root, result)
|
||||
evaluateTailwindFunctions(context)(root, result)
|
||||
substituteScreenAtRules(context)(root, result)
|
||||
collapseAdjacentRules(context)(root, result)
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
57
src/util/resolveConfigPath.js
Normal file
57
src/util/resolveConfigPath.js
Normal file
@ -0,0 +1,57 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
function isObject(value) {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isEmpty(obj) {
|
||||
return Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
function isString(value) {
|
||||
return typeof value === 'string' || value instanceof String
|
||||
}
|
||||
|
||||
export default function resolveConfigPath(pathOrConfig) {
|
||||
// require('tailwindcss')({ theme: ..., variants: ... })
|
||||
if (isObject(pathOrConfig) && pathOrConfig.config === undefined && !isEmpty(pathOrConfig)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// require('tailwindcss')({ config: 'custom-config.js' })
|
||||
if (
|
||||
isObject(pathOrConfig) &&
|
||||
pathOrConfig.config !== undefined &&
|
||||
isString(pathOrConfig.config)
|
||||
) {
|
||||
return path.resolve(pathOrConfig.config)
|
||||
}
|
||||
|
||||
// require('tailwindcss')({ config: { theme: ..., variants: ... } })
|
||||
if (
|
||||
isObject(pathOrConfig) &&
|
||||
pathOrConfig.config !== undefined &&
|
||||
isObject(pathOrConfig.config)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// require('tailwindcss')('custom-config.js')
|
||||
if (isString(pathOrConfig)) {
|
||||
return path.resolve(pathOrConfig)
|
||||
}
|
||||
|
||||
// require('tailwindcss')
|
||||
for (const configFile of ['./tailwind.config.js', './tailwind.config.cjs']) {
|
||||
try {
|
||||
const configPath = path.resolve(configFile)
|
||||
fs.accessSync(configPath)
|
||||
return configPath
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user