-
Notifications
You must be signed in to change notification settings - Fork 0
/
merge.go
347 lines (306 loc) · 12 KB
/
merge.go
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
package merge
import (
"cmp"
"io"
"log"
"regexp"
"slices"
"sort"
"strings"
"sync"
"github.com/tylantz/go-tailwind-merge/internal/cascadia"
"github.com/tylantz/go-tailwind-merge/internal/props"
)
// Cache is an interface for any cache implementation that can be used to store the results of the Merge function.
type Cache interface {
Get(key string) (string, bool) // Get a value from the cache
Set(key string, data string) // Set a value in the cache
Clear() // Clear the cache
}
// Merger is a struct that resolves conflicting css rules.
type Merger struct {
mu sync.Mutex // mutex is only used when adding rules
rules map[string]cascadia.CssRule
cache Cache
properties map[string]props.Property
keepSort bool // keep the original sort order of the classes
}
// NewMerger creates a new instance of Merger.
// cache is a Cache interface that can be used to store the results of the Merge function.
// keepSort is a boolean value indicating whether to keep the order of the classes in the class list.
// This is useful for debugging, but it is not necessary in production.
// Returns a pointer to the created Merger.
func NewMerger(cache Cache, keepSort bool) *Merger {
p, err := props.GetProperties()
if err != nil {
log.Fatal(err)
}
return &Merger{
rules: make(map[string]cascadia.CssRule),
cache: cache,
properties: p,
keepSort: keepSort,
}
}
// Rules returns the map of css class rules with class names as keys and CssRule structs as values
func (r *Merger) Rules() map[string]cascadia.CssRule {
return r.rules
}
// walk recursively walks a selector and returns a slice of component selectors.
// It may return a single selector in a slice or many in a slice.
func walk(selector cascadia.Sel) []cascadia.Sel {
var selectors []cascadia.Sel
switch t := selector.(type) {
case cascadia.CombinedSelector:
subSelectors := walk(t.First())
selectors = append(selectors, subSelectors...)
subSelectors = walk(t.Second())
selectors = append(selectors, subSelectors...)
case cascadia.CompoundSelector:
for _, subSelector := range t.Selectors() {
subSelectors := walk(subSelector)
selectors = append(selectors, subSelectors...)
}
case cascadia.IsPseudoClassSelector:
for _, subSelector := range t.Selectors() {
subSelectors := walk(subSelector)
selectors = append(selectors, subSelectors...)
}
case cascadia.WherePseudoClassSelector:
for _, subSelector := range t.Selectors() {
subSelectors := walk(subSelector)
selectors = append(selectors, subSelectors...)
}
default:
selectors = append(selectors, t)
}
return selectors
}
// AddRules adds rules to the Merger from a reader.
// It takes a reader and a boolean value indicating whether the rules are inline.
// Returns an error if the rules could not be parsed.
// If the cache is not nil, it is cleared.
// New rules with the same class will overwrite existing rules.
func (r *Merger) AddRules(reader io.Reader, inline bool) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.cache != nil {
r.cache.Clear()
}
rules, err := cascadia.ExtractRules(reader, inline)
if err != nil {
return err
}
for _, rule := range rules {
selectors := walk(rule.Selector)
for _, selector := range selectors {
if t, ok := selector.(cascadia.ClassSelector); ok {
r.rules[t.Class] = rule
}
}
}
return nil
}
func (r *Merger) getAffectedProps(rule cascadia.CssRule) []string {
affectedProps := make([]string, 0, len(rule.Declarations)*4) // 4 is arbitrary. reducing allocations
for _, dec := range rule.Declarations {
prop, ok := r.properties[dec.Property]
if !ok {
// Allowing these through, maybe they shouldn't be but
// this allows properties like stroke, fill, etc. to be used
// which are not in the mdn official list of props
// IMPORTANT: we are relying on this to let custom properties through
affectedProps = append(affectedProps, dec.Property)
}
affectedProps = append(affectedProps, prop.ComputedProps()...)
}
return unique(affectedProps)
}
var importantRegex = regexp.MustCompile(`!important`) // matches !important in a css declaration value
var customVarRegex = regexp.MustCompile(`var\((--[\w-]+)`) // matches custom variables in a css declaration value
func getCustomVarsInDec(dec cascadia.CssDeclaration) []string {
matches := customVarRegex.FindAllStringSubmatch(dec.Value, -1)
if matches == nil {
return nil
}
vars := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
vars = append(vars, match[1])
}
return vars
}
// classWithPseudo checks if a selector is a compound selector
// with a class and one or more pseudo element or class.
// It returns true if the selector is a class with pseudo elements or classes.
func classWithPseudo(sel cascadia.Sel) bool {
_, ok := sel.(cascadia.CompoundSelector)
if !ok {
return false
}
selectors := walk(sel)
for i, s := range selectors {
if i == 0 {
_, firstIsClass := s.(cascadia.ClassSelector)
if !firstIsClass {
return false
}
continue
}
ok := cascadia.IsPseudoElement(s)
if !ok {
return false
}
}
return true
}
func isClass(sel cascadia.Sel) bool {
if _, ok := sel.(cascadia.ClassSelector); ok {
return true
}
return false
}
// propModifier returns a string representing the circumstance in which the class applies.
// This may be pseudo elements like hover, focus, etc. or, in the case of a combination selector,
// it can be the selector with the class name removed
func propModifier(class string, rule cascadia.CssRule) string {
if isClass(rule.Selector) || classWithPseudo(rule.Selector) {
// if the rule has a condition (:hover, :focus, etc.), add the condition to the property name
return rule.GetCondition()
} else {
// use the selector with the class name removed to codify what the selector is being applied to
s := cascadia.CssUnescape([]byte(rule.Selector.String()))
return strings.Replace(s, class, "", 1)
}
}
// Merge resolves conflicting css class rules.
// It takes a string of space-separated class names.
// Returns a string of space-separated class names with the conflicting classes removed.
// It prioritises the last class in the list for each property.
// If a class name is not found in the rules, it is kept in the output.
// Important properties are prioritised over non-important properties.
// If the cache is not nil, it will store the result of the merge to skip re-calculating the merge later.
func (r *Merger) Merge(inClass string) string {
if r.cache != nil {
val, ok := r.cache.Get(inClass)
if ok {
return val
}
}
split := strings.Split(strings.TrimSpace(inClass), " ")
if len(split) < 2 {
return inClass
}
keepClasses := make([]string, 0, len(split))
selectorToClass := make(map[string]string, len(split))
// propsToClasses is a map of properties that are shared between classes
// The property name may have a condition (pseudo or media) appended to it (e.g., "height:hover": ["h-10", "h-20"])
propsToSelectors := make(map[string]string, len(split))
importantPropsToSelectors := make(map[string]string, len(split))
customVarsToSelectors := make(map[string]string, len(split)) // map of custom vars to the class that set them
propsToCustomVars := make(map[string][]string, len(split)) // map of props to the custom vars that it uses
for _, class := range split {
rule, ok := r.rules[class]
if !ok {
// log.Println("rule not found for class:", class)
keepClasses = append(keepClasses, class)
continue
}
selectorToClass[rule.Selector.String()] = class
propMod := propModifier(class, rule)
for _, prop := range r.getAffectedProps(rule) {
prop = prop + propMod
for _, dec := range rule.Declarations {
if !strings.HasPrefix(prop, "--") {
// if the property has a custom var, add it to the propsToCustomVars map
customVars := getCustomVarsInDec(dec)
// overwrite the customVars so we prioritize the last class that sets the property
propsToCustomVars[prop] = customVars
}
// if the property is marked !important, add the class to the importantProps map
if importantRegex.MatchString(dec.Value) {
importantPropsToSelectors[prop] = rule.Selector.String()
}
}
// if it has a custom prop, add it to the customVars map
if strings.HasPrefix(prop, "--") {
customVarsToSelectors[prop] = rule.Selector.String()
continue
}
// add all classes to the propsToClasses map
propsToSelectors[prop] = rule.Selector.String()
}
}
// keep the last class in the list for each property
// importantly, this keeps classes that uniquely define a property, even if it has properties that conflict with other classes
for _, sel := range propsToSelectors {
class := selectorToClass[sel]
keepClasses = append(keepClasses, class)
}
// If a class has an !important property, it is kept unless another class comes later in the class string and it is marked !important on the same property.
for _, sel := range importantPropsToSelectors {
// This does not remove the class that the the important class is overriding,
// but it shouldn't matter because the important class will override the other,
// and the other class may have other properties that are not being overridden
class := selectorToClass[sel]
keepClasses = append(keepClasses, class)
}
// keep the class that sets the last definition of each custom property if that custom property is actually used
for custVar, sel := range customVarsToSelectors {
for _, vars := range propsToCustomVars {
// if the custom property is actually used, keep the class that sets it
if !slices.Contains(vars, custVar) {
continue
}
class := selectorToClass[sel]
keepClasses = append(keepClasses, class)
break
}
}
keepClasses = unique(keepClasses)
if r.keepSort {
sortSubset(keepClasses, split)
}
out := strings.Join(keepClasses, " ")
if r.cache != nil {
r.cache.Set(inClass, out)
}
return out
}
// unique returns a slice with all duplicate elements removed.
// The sort order of the elements is not preserved
// unless the elements are already sorted. It does not zero pointers so it can leak.
// Lazy? Yes. But it's a good way to remove duplicates from a slice.
func unique[S ~[]E, E cmp.Ordered](x S) S {
slices.Sort(x)
return slices.Compact(x)
}
// sortSubset sorts a subset of strings based on the order of the full set of strings.
// if duplicates are present in the full, this function takes the final position for a value (left-to-right).
func sortSubset(sub []string, full []string) {
indexMap := make(map[string]int, len(full))
for i, v := range full {
indexMap[v] = i
}
// Sort subset based on the indices in indexMap
sort.Slice(sub, func(i, j int) bool {
return indexMap[sub[i]] < indexMap[sub[j]]
})
}
/*
The CSS properties that can accept multiple values and layer them, rather than overriding them, are:
box-shadow: Applies one or more shadows to an element. Shadows are applied in the order specified and layered on top of each other,
with the first shadow on top.
text-shadow: Applies one or more shadows to the text content of an element.
Like box-shadow, shadows are layered with the first shadow on top.
background: When using multiple backgrounds, they are layered atop one another with the first background you provide on top (closest to the viewer)
and the last one specified underneath.
transform: Multiple transform functions can be applied to an element. They are applied in the order specified, from left to right.
However, if they are applied by different selectors with the same specificity, the one that comes later in the CSS will override the earlier one.
transition: Multiple transitions can be applied to an element. They are applied in the order specified, from left to right.
However, like transform, if they are applied by different selectors with the same specificity, the later one will override the earlier one.
animation: Multiple animations can be applied to an element. They are applied in the order specified, from left to right.
Again, like transform and transition, if they are applied by different selectors with the same specificity, the later one will override the earlier one.
*/