tailwindcss/src/jit/lib/setupContextUtils.js
2021-05-26 18:20:29 +01:00

602 lines
17 KiB
JavaScript

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
)
)
),
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
}
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]
}