diff options
| author | Philipp Tanlak <philipp.tanlak@gmail.com> | 2025-11-24 20:54:57 +0100 |
|---|---|---|
| committer | Philipp Tanlak <philipp.tanlak@gmail.com> | 2025-11-24 20:57:48 +0100 |
| commit | b1e2c8fd5cb5dfa46bc440a12eafaf56cd844b1c (patch) | |
| tree | 49d360fd6cbc6a2754efe93524ac47ff0fbe0f7d /node_modules/tailwindcss/src/lib/expandApplyAtRules.js | |
Docs
Diffstat (limited to 'node_modules/tailwindcss/src/lib/expandApplyAtRules.js')
| -rw-r--r-- | node_modules/tailwindcss/src/lib/expandApplyAtRules.js | 620 |
1 files changed, 620 insertions, 0 deletions
diff --git a/node_modules/tailwindcss/src/lib/expandApplyAtRules.js b/node_modules/tailwindcss/src/lib/expandApplyAtRules.js new file mode 100644 index 0000000..ed48dbc --- /dev/null +++ b/node_modules/tailwindcss/src/lib/expandApplyAtRules.js @@ -0,0 +1,620 @@ +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) + } +} |