-
Notifications
You must be signed in to change notification settings - Fork 4.4k
/
Copy pathformatVariantSelector.js
215 lines (180 loc) · 6.93 KB
/
formatVariantSelector.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
import selectorParser from 'postcss-selector-parser'
import unescape from 'postcss-selector-parser/dist/util/unesc'
import escapeClassName from '../util/escapeClassName'
import prefixSelector from '../util/prefixSelector'
let MERGE = ':merge'
let PARENT = '&'
export let selectorFunctions = new Set([MERGE])
export function formatVariantSelector(current, ...others) {
for (let other of others) {
let incomingValue = resolveFunctionArgument(other, MERGE)
if (incomingValue !== null) {
let existingValue = resolveFunctionArgument(current, MERGE, incomingValue)
if (existingValue !== null) {
let existingTarget = `${MERGE}(${incomingValue})`
let splitIdx = other.indexOf(existingTarget)
let addition = other.slice(splitIdx + existingTarget.length).split(' ')[0]
current = current.replace(existingTarget, existingTarget + addition)
continue
}
}
current = other.replace(PARENT, current)
}
return current
}
export function finalizeSelector(
format,
{
selector,
candidate,
context,
isArbitraryVariant,
// Split by the separator, but ignore the separator inside square brackets:
//
// E.g.: dark:lg:hover:[paint-order:markers]
// ┬ ┬ ┬ ┬
// │ │ │ ╰── We will not split here
// ╰──┴─────┴─────────────── We will split here
//
base = candidate
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
.pop(),
}
) {
let ast = selectorParser().astSync(selector)
// We explicitly DO NOT prefix classes in arbitrary variants
if (context?.tailwindConfig?.prefix && !isArbitraryVariant) {
format = prefixSelector(context.tailwindConfig.prefix, format)
}
format = format.replace(PARENT, `.${escapeClassName(candidate)}`)
let formatAst = selectorParser().astSync(format)
// Remove extraneous selectors that do not include the base class/candidate being matched against
// For example if we have a utility defined `.a, .b { color: red}`
// And the formatted variant is sm:b then we want the final selector to be `.sm\:b` and not `.a, .sm\:b`
ast.each((node) => {
let hasClassesMatchingCandidate = node.some((n) => n.type === 'class' && n.value === base)
if (!hasClassesMatchingCandidate) {
node.remove()
}
})
// Normalize escaped classes, e.g.:
//
// The idea would be to replace the escaped `base` in the selector with the
// `format`. However, in css you can escape the same selector in a few
// different ways. This would result in different strings and therefore we
// can't replace it properly.
//
// base: bg-[rgb(255,0,0)]
// base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\]
// escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\]
//
ast.walkClasses((node) => {
if (node.raws && node.value.includes(base)) {
node.raws.value = escapeClassName(unescape(node.raws.value))
}
})
// We can safely replace the escaped base now, since the `base` section is
// now in a normalized escaped value.
ast.walkClasses((node) => {
if (node.value === base) {
node.replaceWith(...formatAst.nodes)
}
})
// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
function collectPseudoElements(selector) {
let nodes = []
for (let node of selector.nodes) {
if (isPseudoElement(node)) {
nodes.push(node)
selector.removeChild(node)
}
if (node?.nodes) {
nodes.push(...collectPseudoElements(node))
}
}
return nodes
}
// Remove unnecessary pseudo selectors that we used as placeholders
ast.each((selector) => {
selector.walkPseudos((p) => {
if (selectorFunctions.has(p.value)) {
p.replaceWith(p.nodes)
}
})
let pseudoElements = collectPseudoElements(selector)
if (pseudoElements.length > 0) {
selector.nodes.push(pseudoElements.sort(sortSelector))
}
})
return ast.toString()
}
// Note: As a rule, double colons (::) should be used instead of a single colon
// (:). This distinguishes pseudo-classes from pseudo-elements. However, since
// this distinction was not present in older versions of the W3C spec, most
// browsers support both syntaxes for the original pseudo-elements.
let pseudoElementsBC = [':before', ':after', ':first-line', ':first-letter']
// These pseudo-elements _can_ be combined with other pseudo selectors AND the order does matter.
let pseudoElementExceptions = ['::file-selector-button']
// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work
// for you by flipping the order.
function sortSelector(a, z) {
// Both nodes are non-pseudo's so we can safely ignore them and keep
// them in the same order.
if (a.type !== 'pseudo' && z.type !== 'pseudo') {
return 0
}
// If one of them is a combinator, we need to keep it in the same order
// because that means it will start a new "section" in the selector.
if ((a.type === 'combinator') ^ (z.type === 'combinator')) {
return 0
}
// One of the items is a pseudo and the other one isn't. Let's move
// the pseudo to the right.
if ((a.type === 'pseudo') ^ (z.type === 'pseudo')) {
return (a.type === 'pseudo') - (z.type === 'pseudo')
}
// Both are pseudo's, move the pseudo elements (except for
// ::file-selector-button) to the right.
return isPseudoElement(a) - isPseudoElement(z)
}
function isPseudoElement(node) {
if (node.type !== 'pseudo') return false
if (pseudoElementExceptions.includes(node.value)) return false
return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
}
function resolveFunctionArgument(haystack, needle, arg) {
let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
if (startIdx === -1) return null
// Start inside the `(`
startIdx += needle.length + 1
let target = ''
let count = 0
for (let char of haystack.slice(startIdx)) {
if (char !== '(' && char !== ')') {
target += char
} else if (char === '(') {
target += char
count++
} else if (char === ')') {
if (--count < 0) break // unbalanced
target += char
}
}
return target
}