astro-ghostcms/.pnpm-store/v3/files/17/006ae6dce7526367d592e9f5c7e...

621 lines
18 KiB
Plaintext

import postcss from 'postcss'
import parser from 'postcss-selector-parser'
import { resolveMatches } from './generateRules'
import escapeClassName from '../util/escapeClassName'
import { applyImportantSelector } from '../util/applyImportantSelector'
import { movePseudos } from '../util/pseudoElements'
/** @typedef {Map<string, [any, import('postcss').Rule[]]>} ApplyCache */
function extractClasses(node) {
/** @type {Map<string, Set<string>>} */
let groups = new Map()
let container = postcss.root({ nodes: [node.clone()] })
container.walkRules((rule) => {
parser((selectors) => {
selectors.walkClasses((classSelector) => {
let parentSelector = classSelector.parent.toString()
let classes = groups.get(parentSelector)
if (!classes) {
groups.set(parentSelector, (classes = new Set()))
}
classes.add(classSelector.value)
})
}).processSync(rule.selector)
})
let normalizedGroups = Array.from(groups.values(), (classes) => Array.from(classes))
let classes = normalizedGroups.flat()
return Object.assign(classes, { groups: normalizedGroups })
}
let selectorExtractor = parser()
/**
* @param {string} ruleSelectors
*/
function extractSelectors(ruleSelectors) {
return selectorExtractor.astSync(ruleSelectors)
}
function extractBaseCandidates(candidates, separator) {
let baseClasses = new Set()
for (let candidate of candidates) {
baseClasses.add(candidate.split(separator).pop())
}
return Array.from(baseClasses)
}
function prefix(context, selector) {
let prefix = context.tailwindConfig.prefix
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
}
function* pathToRoot(node) {
yield node
while (node.parent) {
yield node.parent
node = node.parent
}
}
/**
* Only clone the node itself and not its children
*
* @param {*} node
* @param {*} overrides
* @returns
*/
function shallowClone(node, overrides = {}) {
let children = node.nodes
node.nodes = []
let tmp = node.clone(overrides)
node.nodes = children
return tmp
}
/**
* Clone just the nodes all the way to the top that are required to represent
* this singular rule in the tree.
*
* For example, if we have CSS like this:
* ```css
* @media (min-width: 768px) {
* @supports (display: grid) {
* .foo {
* display: grid;
* grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
* }
* }
*
* @supports (backdrop-filter: blur(1px)) {
* .bar {
* backdrop-filter: blur(1px);
* }
* }
*
* .baz {
* color: orange;
* }
* }
* ```
*
* And we're cloning `.bar` it'll return a cloned version of what's required for just that single node:
*
* ```css
* @media (min-width: 768px) {
* @supports (backdrop-filter: blur(1px)) {
* .bar {
* backdrop-filter: blur(1px);
* }
* }
* }
* ```
*
* @param {import('postcss').Node} node
*/
function nestedClone(node) {
for (let parent of pathToRoot(node)) {
if (node === parent) {
continue
}
if (parent.type === 'root') {
break
}
node = shallowClone(parent, {
nodes: [node],
})
}
return node
}
/**
* @param {import('postcss').Root} root
*/
function buildLocalApplyCache(root, context) {
/** @type {ApplyCache} */
let cache = new Map()
root.walkRules((rule) => {
// Ignore rules generated by Tailwind
for (let node of pathToRoot(rule)) {
if (node.raws.tailwind?.layer !== undefined) {
return
}
}
// Clone what's required to represent this singular rule in the tree
let container = nestedClone(rule)
let sort = context.offsets.create('user')
for (let className of extractClasses(rule)) {
let list = cache.get(className) || []
cache.set(className, list)
list.push([
{
layer: 'user',
sort,
important: false,
},
container,
])
}
})
return cache
}
/**
* @returns {ApplyCache}
*/
function buildApplyCache(applyCandidates, context) {
for (let candidate of applyCandidates) {
if (context.notClassCache.has(candidate) || context.applyClassCache.has(candidate)) {
continue
}
if (context.classCache.has(candidate)) {
context.applyClassCache.set(
candidate,
context.classCache.get(candidate).map(([meta, rule]) => [meta, rule.clone()])
)
continue
}
let matches = Array.from(resolveMatches(candidate, context))
if (matches.length === 0) {
context.notClassCache.add(candidate)
continue
}
context.applyClassCache.set(candidate, matches)
}
return context.applyClassCache
}
/**
* Build a cache only when it's first used
*
* @param {() => ApplyCache} buildCacheFn
* @returns {ApplyCache}
*/
function lazyCache(buildCacheFn) {
let cache = null
return {
get: (name) => {
cache = cache || buildCacheFn()
return cache.get(name)
},
has: (name) => {
cache = cache || buildCacheFn()
return cache.has(name)
},
}
}
/**
* Take a series of multiple caches and merge
* them so they act like one large cache
*
* @param {ApplyCache[]} caches
* @returns {ApplyCache}
*/
function combineCaches(caches) {
return {
get: (name) => caches.flatMap((cache) => cache.get(name) || []),
has: (name) => caches.some((cache) => cache.has(name)),
}
}
function extractApplyCandidates(params) {
let candidates = params.split(/[\s\t\n]+/g)
if (candidates[candidates.length - 1] === '!important') {
return [candidates.slice(0, -1), true]
}
return [candidates, false]
}
function processApply(root, context, localCache) {
let applyCandidates = new Set()
// Collect all @apply rules and candidates
let applies = []
root.walkAtRules('apply', (rule) => {
let [candidates] = extractApplyCandidates(rule.params)
for (let util of candidates) {
applyCandidates.add(util)
}
applies.push(rule)
})
// Start the @apply process if we have rules with @apply in them
if (applies.length === 0) {
return
}
// Fill up some caches!
let applyClassCache = combineCaches([localCache, buildApplyCache(applyCandidates, context)])
/**
* When we have an apply like this:
*
* .abc {
* @apply hover:font-bold;
* }
*
* What we essentially will do is resolve to this:
*
* .abc {
* @apply .hover\:font-bold:hover {
* font-weight: 500;
* }
* }
*
* Notice that the to-be-applied class is `.hover\:font-bold:hover` and that the utility candidate was `hover:font-bold`.
* What happens in this function is that we prepend a `.` and escape the candidate.
* This will result in `.hover\:font-bold`
* Which means that we can replace `.hover\:font-bold` with `.abc` in `.hover\:font-bold:hover` resulting in `.abc:hover`
*
* @param {string} selector
* @param {string} utilitySelectors
* @param {string} candidate
*/
function replaceSelector(selector, utilitySelectors, candidate) {
let selectorList = extractSelectors(selector)
let utilitySelectorsList = extractSelectors(utilitySelectors)
let candidateList = extractSelectors(`.${escapeClassName(candidate)}`)
let candidateClass = candidateList.nodes[0].nodes[0]
selectorList.each((sel) => {
/** @type {Set<import('postcss-selector-parser').Selector>} */
let replaced = new Set()
utilitySelectorsList.each((utilitySelector) => {
let hasReplaced = false
utilitySelector = utilitySelector.clone()
utilitySelector.walkClasses((node) => {
if (node.value !== candidateClass.value) {
return
}
// Don't replace multiple instances of the same class
// This is theoretically correct but only partially
// We'd need to generate every possible permutation of the replacement
// For example with `.foo + .foo { … }` and `section { @apply foo; }`
// We'd need to generate all of these:
// - `.foo + .foo`
// - `.foo + section`
// - `section + .foo`
// - `section + section`
if (hasReplaced) {
return
}
// Since you can only `@apply` class names this is sufficient
// We want to replace the matched class name with the selector the user is using
// Ex: Replace `.text-blue-500` with `.foo.bar:is(.something-cool)`
node.replaceWith(...sel.nodes.map((node) => node.clone()))
// Record that we did something and we want to use this new selector
replaced.add(utilitySelector)
hasReplaced = true
})
})
// Sort tag names before class names (but only sort each group (separated by a combinator)
// separately and not in total)
// This happens when replacing `.bar` in `.foo.bar` with a tag like `section`
for (let sel of replaced) {
let groups = [[]]
for (let node of sel.nodes) {
if (node.type === 'combinator') {
groups.push(node)
groups.push([])
} else {
let last = groups[groups.length - 1]
last.push(node)
}
}
sel.nodes = []
for (let group of groups) {
if (Array.isArray(group)) {
group.sort((a, b) => {
if (a.type === 'tag' && b.type === 'class') {
return -1
} else if (a.type === 'class' && b.type === 'tag') {
return 1
} else if (a.type === 'class' && b.type === 'pseudo' && b.value.startsWith('::')) {
return -1
} else if (a.type === 'pseudo' && a.value.startsWith('::') && b.type === 'class') {
return 1
}
return 0
})
}
sel.nodes = sel.nodes.concat(group)
}
}
sel.replaceWith(...replaced)
})
return selectorList.toString()
}
let perParentApplies = new Map()
// Collect all apply candidates and their rules
for (let apply of applies) {
let [candidates] = perParentApplies.get(apply.parent) || [[], apply.source]
perParentApplies.set(apply.parent, [candidates, apply.source])
let [applyCandidates, important] = extractApplyCandidates(apply.params)
if (apply.parent.type === 'atrule') {
if (apply.parent.name === 'screen') {
let screenType = apply.parent.params
throw apply.error(
`@apply is not supported within nested at-rules like @screen. We suggest you write this as @apply ${applyCandidates
.map((c) => `${screenType}:${c}`)
.join(' ')} instead.`
)
}
throw apply.error(
`@apply is not supported within nested at-rules like @${apply.parent.name}. You can fix this by un-nesting @${apply.parent.name}.`
)
}
for (let applyCandidate of applyCandidates) {
if ([prefix(context, 'group'), prefix(context, 'peer')].includes(applyCandidate)) {
// TODO: Link to specific documentation page with error code.
throw apply.error(`@apply should not be used with the '${applyCandidate}' utility`)
}
if (!applyClassCache.has(applyCandidate)) {
throw apply.error(
`The \`${applyCandidate}\` class does not exist. If \`${applyCandidate}\` is a custom class, make sure it is defined within a \`@layer\` directive.`
)
}
let rules = applyClassCache.get(applyCandidate)
candidates.push([applyCandidate, important, rules])
}
}
for (let [parent, [candidates, atApplySource]] of perParentApplies) {
let siblings = []
for (let [applyCandidate, important, rules] of candidates) {
let potentialApplyCandidates = [
applyCandidate,
...extractBaseCandidates([applyCandidate], context.tailwindConfig.separator),
]
for (let [meta, node] of rules) {
let parentClasses = extractClasses(parent)
let nodeClasses = extractClasses(node)
// When we encounter a rule like `.dark .a, .b { … }` we only want to be left with `[.dark, .a]` if the base applyCandidate is `.a` or with `[.b]` if the base applyCandidate is `.b`
// So we've split them into groups
nodeClasses = nodeClasses.groups
.filter((classList) =>
classList.some((className) => potentialApplyCandidates.includes(className))
)
.flat()
// Add base utility classes from the @apply node to the list of
// classes to check whether it intersects and therefore results in a
// circular dependency or not.
//
// E.g.:
// .foo {
// @apply hover:a; // This applies "a" but with a modifier
// }
//
// We only have to do that with base classes of the `node`, not of the `parent`
// E.g.:
// .hover\:foo {
// @apply bar;
// }
// .bar {
// @apply foo;
// }
//
// This should not result in a circular dependency because we are
// just applying `.foo` and the rule above is `.hover\:foo` which is
// unrelated. However, if we were to apply `hover:foo` then we _did_
// have to include this one.
nodeClasses = nodeClasses.concat(
extractBaseCandidates(nodeClasses, context.tailwindConfig.separator)
)
let intersects = parentClasses.some((selector) => nodeClasses.includes(selector))
if (intersects) {
throw node.error(
`You cannot \`@apply\` the \`${applyCandidate}\` utility here because it creates a circular dependency.`
)
}
let root = postcss.root({ nodes: [node.clone()] })
// Make sure every node in the entire tree points back at the @apply rule that generated it
root.walk((node) => {
node.source = atApplySource
})
let canRewriteSelector =
node.type !== 'atrule' || (node.type === 'atrule' && node.name !== 'keyframes')
if (canRewriteSelector) {
root.walkRules((rule) => {
// Let's imagine you have the following structure:
//
// .foo {
// @apply bar;
// }
//
// @supports (a: b) {
// .bar {
// color: blue
// }
//
// .something-unrelated {}
// }
//
// In this case we want to apply `.bar` but it happens to be in
// an atrule node. We clone that node instead of the nested one
// because we still want that @supports rule to be there once we
// applied everything.
//
// However it happens to be that the `.something-unrelated` is
// also in that same shared @supports atrule. This is not good,
// and this should not be there. The good part is that this is
// a clone already and it can be safely removed. The question is
// how do we know we can remove it. Basically what we can do is
// match it against the applyCandidate that you want to apply. If
// it doesn't match the we can safely delete it.
//
// If we didn't do this, then the `replaceSelector` function
// would have replaced this with something that didn't exist and
// therefore it removed the selector altogether. In this specific
// case it would result in `{}` instead of `.something-unrelated {}`
if (!extractClasses(rule).some((candidate) => candidate === applyCandidate)) {
rule.remove()
return
}
// Strip the important selector from the parent selector if at the beginning
let importantSelector =
typeof context.tailwindConfig.important === 'string'
? context.tailwindConfig.important
: null
// We only want to move the "important" selector if this is a Tailwind-generated utility
// We do *not* want to do this for user CSS that happens to be structured the same
let isGenerated = parent.raws.tailwind !== undefined
let parentSelector =
isGenerated && importantSelector && parent.selector.indexOf(importantSelector) === 0
? parent.selector.slice(importantSelector.length)
: parent.selector
// If the selector becomes empty after replacing the important selector
// This means that it's the same as the parent selector and we don't want to replace it
// Otherwise we'll crash
if (parentSelector === '') {
parentSelector = parent.selector
}
rule.selector = replaceSelector(parentSelector, rule.selector, applyCandidate)
// And then re-add it if it was removed
if (importantSelector && parentSelector !== parent.selector) {
rule.selector = applyImportantSelector(rule.selector, importantSelector)
}
rule.walkDecls((d) => {
d.important = meta.important || important
})
// Move pseudo elements to the end of the selector (if necessary)
let selector = parser().astSync(rule.selector)
selector.each((sel) => movePseudos(sel))
rule.selector = selector.toString()
})
}
// It could be that the node we were inserted was removed because the class didn't match
// If that was the *only* rule in the parent, then we have nothing add so we skip it
if (!root.nodes[0]) {
continue
}
// Insert it
siblings.push([meta.sort, root.nodes[0]])
}
}
// Inject the rules, sorted, correctly
let nodes = context.offsets.sort(siblings).map((s) => s[1])
// `parent` refers to the node at `.abc` in: .abc { @apply mt-2 }
parent.after(nodes)
}
for (let apply of applies) {
// If there are left-over declarations, just remove the @apply
if (apply.parent.nodes.length > 1) {
apply.remove()
} else {
// The node is empty, drop the full node
apply.parent.remove()
}
}
// Do it again, in case we have other `@apply` rules
processApply(root, context, localCache)
}
export default function expandApplyAtRules(context) {
return (root) => {
// Build a cache of the user's CSS so we can use it to resolve classes used by @apply
let localCache = lazyCache(() => buildLocalApplyCache(root, context))
processApply(root, context, localCache)
}
}