clsx-ruby 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +78 -17
- data/lib/clsx/helper.rb +266 -70
- data/lib/clsx/tailwind_merge.rb +66 -0
- data/lib/clsx/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 102bb836f576baf8a09d29ce9eacbcfe3023ce9ba76d0099087d1978c4a6f451
|
|
4
|
+
data.tar.gz: 7d25f7931b2afad81609037e54ee004ffb02197aee6300547d8904249125a6c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 574f8ee8bc63b7d4dbeaaae07a8232ea8fc0d0459f68d60eb99b2191c35060bc200530898c940f0b6f7f91753faab898b41813227347bc8108f2b599d8eb46ed
|
|
7
|
+
data.tar.gz: d5da4ba0eef261a1e19bfa83976c83e05ad36803538dccf1a5a090c7a54516c05dbda502948aa5b52f1c8f12deedf23e08bb2b26dafb852e81827233c9a99201
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## v1.2.0
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Optional Tailwind class merging via the `tailwind_merge` gem: `require 'clsx/tailwind_merge'` adds `twm`/`Twm[]` (and `Clsx.twm`) that resolve conflicting utilities (e.g. `px-2 px-4` → `px-4`). `clsx`/`cn` stay pure; the core gem stays zero-dependency unless the integration is required. Configure with `Clsx.merger = TailwindMerge::Merger.new(config: {...})`.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Faster `clsx`/`cn` for the common single-string and hash inputs via dispatch elision, fewer allocations, and cheaper whitespace scanning (no API or output change)
|
|
19
|
+
|
|
20
|
+
## v1.1.3
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Normalize whitespace in class strings: tabs, newlines, leading/trailing spaces, and consecutive spaces are collapsed to single spaces (e.g., `clsx("foo\tbar")` returns `"foo bar"`)
|
|
25
|
+
- Whitespace-only strings now correctly return `nil` (e.g., `clsx(" ")` returns `nil`)
|
|
26
|
+
- Deduplicate class names across multi-token strings (e.g., `clsx("foo bar", "foo")` now returns `"foo bar"`)
|
|
27
|
+
|
|
10
28
|
## v1.1.2
|
|
11
29
|
|
|
12
30
|
### Changed
|
data/README.md
CHANGED
|
@@ -34,18 +34,19 @@ For Rails integration (adds `clsx` and `cn` helpers to all views), see [clsx-rai
|
|
|
34
34
|
|
|
35
35
|
### Blazing fast
|
|
36
36
|
|
|
37
|
-
**
|
|
37
|
+
**2–4x faster** than Rails `class_names` — never slower, on realistic markup:
|
|
38
38
|
|
|
39
39
|
| Scenario | clsx-ruby | Rails `class_names` | Speedup |
|
|
40
40
|
|---|---|---|---|
|
|
41
|
-
|
|
|
42
|
-
| String
|
|
43
|
-
| String
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
41
|
+
| Token | 4.7M i/s | 1.1M i/s | **4.2x** |
|
|
42
|
+
| String array | 1.3M i/s | 452K i/s | **3.0x** |
|
|
43
|
+
| String + hash | 1.8M i/s | 610K i/s | **2.9x** |
|
|
44
|
+
| Utility string | 1.0M i/s | 371K i/s | **2.7x** |
|
|
45
|
+
| Long utility | 561K i/s | 209K i/s | **2.7x** |
|
|
46
|
+
| Utility + hash | 1.1M i/s | 457K i/s | **2.3x** |
|
|
47
|
+
| Hash | 1.9M i/s | 936K i/s | **2.0x** |
|
|
47
48
|
|
|
48
|
-
<sup>Ruby 4.0.
|
|
49
|
+
<sup>Ruby 4.0.5, Apple M1 Pro. 2.8× geomean; each row verified to produce output identical to Rails `class_names`. Reproduce: `bundle exec ruby benchmark/vs_rails.rb`</sup>
|
|
49
50
|
|
|
50
51
|
### More feature-rich than `class_names`
|
|
51
52
|
|
|
@@ -53,7 +54,7 @@ For Rails integration (adds `clsx` and `cn` helpers to all views), see [clsx-rai
|
|
|
53
54
|
|---|---|---|
|
|
54
55
|
| Conditional classes | ✅ | ✅ |
|
|
55
56
|
| Auto-deduplication | ✅ | ✅ |
|
|
56
|
-
|
|
|
57
|
+
| 2–4× faster | ✅ | ❌ |
|
|
57
58
|
| Returns `nil` when empty | ✅ | ❌ (returns `""`) |
|
|
58
59
|
| Complex hash keys | ✅ | ❌ |
|
|
59
60
|
| Framework-agnostic | ✅ | ❌ |
|
|
@@ -149,6 +150,11 @@ Clsx['foo', ['bar', { baz: false, bat: nil }, ['hello', ['world']]], 'cya']
|
|
|
149
150
|
|
|
150
151
|
## Framework Examples
|
|
151
152
|
|
|
153
|
+
clsx is framework-agnostic. Any Ruby view object — ViewComponent, Phlex, or a plain object —
|
|
154
|
+
gets `clsx`/`cn` by including `Clsx::Helper`; no adapter needed. For Rails ERB views, the
|
|
155
|
+
companion [clsx-rails](https://github.com/svyatov/clsx-rails) gem auto-loads the helpers into
|
|
156
|
+
ActionView.
|
|
157
|
+
|
|
152
158
|
### Rails
|
|
153
159
|
|
|
154
160
|
```erb
|
|
@@ -165,17 +171,27 @@ erb :"<div class='#{Clsx['nav', active: @active]}'>...</div>"
|
|
|
165
171
|
|
|
166
172
|
### ViewComponent
|
|
167
173
|
|
|
174
|
+
Include the mixin once in your base component instead of per component:
|
|
175
|
+
|
|
168
176
|
```ruby
|
|
169
|
-
class
|
|
177
|
+
class ApplicationComponent < ViewComponent::Base
|
|
170
178
|
include Clsx::Helper
|
|
179
|
+
end
|
|
180
|
+
```
|
|
171
181
|
|
|
172
|
-
|
|
182
|
+
Accept a caller-supplied `class:` and merge it — clsx dedupes across every argument, so
|
|
183
|
+
callers can extend or repeat classes safely:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
class AlertComponent < ApplicationComponent
|
|
187
|
+
def initialize(variant: :info, dismissible: false, class: nil)
|
|
173
188
|
@variant = variant
|
|
174
189
|
@dismissible = dismissible
|
|
190
|
+
@html_class = binding.local_variable_get(:class) # `class` is a Ruby keyword
|
|
175
191
|
end
|
|
176
192
|
|
|
177
193
|
def classes
|
|
178
|
-
clsx("alert", "alert-#{@variant}", dismissible: @dismissible)
|
|
194
|
+
clsx("alert", "alert-#{@variant}", @html_class, dismissible: @dismissible)
|
|
179
195
|
end
|
|
180
196
|
end
|
|
181
197
|
```
|
|
@@ -204,23 +220,67 @@ class NavLink < ViewComponent::Base
|
|
|
204
220
|
end
|
|
205
221
|
```
|
|
206
222
|
|
|
223
|
+
#### Merging conflicting utilities
|
|
224
|
+
|
|
225
|
+
`clsx`/`cn` keep every class, so conflicting Tailwind utilities both survive:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
Clsx['px-2 px-4'] # => "px-2 px-4"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For conflict resolution, opt into the [`tailwind_merge`](https://github.com/gjtorikian/tailwind_merge)
|
|
232
|
+
gem. Add it to your `Gemfile` (clsx-ruby itself stays dependency-free), then require the
|
|
233
|
+
integration once at boot:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# config/initializers/clsx.rb
|
|
237
|
+
require 'clsx/tailwind_merge'
|
|
238
|
+
|
|
239
|
+
# Optional: configure the merger (prefix, cache size, custom theme, …)
|
|
240
|
+
Clsx.merger = TailwindMerge::Merger.new(config: { prefix: 'tw' })
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
This adds a merged variant — `twm` / `Twm[]` — the last conflicting utility wins.
|
|
244
|
+
`clsx`/`cn` stay pure; only `twm`/`Twm` merge:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
Twm['px-2 px-4'] # => "px-4"
|
|
248
|
+
Twm['p-4', 'p-2', 'bg-red', 'bg-blue'] # => "p-2 bg-blue"
|
|
249
|
+
Clsx['px-2 px-4'] # => "px-2 px-4" (unchanged)
|
|
250
|
+
|
|
251
|
+
# Also available as a mixin method and a module method:
|
|
252
|
+
include Clsx::Helper
|
|
253
|
+
twm('px-2 px-4') # => "px-4"
|
|
254
|
+
Clsx.twm('px-2 px-4') # => "px-4"
|
|
255
|
+
```
|
|
256
|
+
|
|
207
257
|
### Phlex
|
|
208
258
|
|
|
259
|
+
Include the mixin once in your base component, then merge caller-supplied attributes —
|
|
260
|
+
clsx dedupes across every argument:
|
|
261
|
+
|
|
209
262
|
```ruby
|
|
210
|
-
class
|
|
263
|
+
class ApplicationComponent < Phlex::HTML
|
|
211
264
|
include Clsx::Helper
|
|
265
|
+
end
|
|
212
266
|
|
|
213
|
-
|
|
267
|
+
class Badge < ApplicationComponent
|
|
268
|
+
def initialize(color: :blue, pill: false, **attributes)
|
|
214
269
|
@color = color
|
|
215
270
|
@pill = pill
|
|
271
|
+
@attributes = attributes
|
|
216
272
|
end
|
|
217
273
|
|
|
218
274
|
def view_template
|
|
219
|
-
span(class: clsx("badge", "badge-#{@color}", pill: @pill)) { yield }
|
|
275
|
+
span(class: clsx("badge", "badge-#{@color}", @attributes[:class], pill: @pill)) { yield }
|
|
220
276
|
end
|
|
221
277
|
end
|
|
222
278
|
```
|
|
223
279
|
|
|
280
|
+
Phlex's own `class: [...]` arrays and `mix` cover simple cases. Reach for clsx when you want
|
|
281
|
+
hash-conditional syntax (`pill: @pill`) or cross-argument dedup when merging caller-supplied
|
|
282
|
+
classes.
|
|
283
|
+
|
|
224
284
|
## Differences from JavaScript clsx
|
|
225
285
|
|
|
226
286
|
1. **Returns `nil`** when no classes apply (not an empty string). This prevents rendering empty `class=""` attributes in template engines that skip `nil`:
|
|
@@ -228,9 +288,10 @@ end
|
|
|
228
288
|
Clsx[nil, false] # => nil
|
|
229
289
|
```
|
|
230
290
|
|
|
231
|
-
2. **Deduplication** — Duplicate classes are automatically removed:
|
|
291
|
+
2. **Deduplication** — Duplicate classes are automatically removed, even across multi-token strings:
|
|
232
292
|
```ruby
|
|
233
|
-
Clsx['foo', 'foo']
|
|
293
|
+
Clsx['foo', 'foo'] # => 'foo'
|
|
294
|
+
Clsx['foo bar', 'foo'] # => 'foo bar'
|
|
234
295
|
```
|
|
235
296
|
|
|
236
297
|
3. **Falsy values** — In Ruby only `false` and `nil` are falsy, so `0`, `''`, `[]`, `{}` are all truthy:
|
data/lib/clsx/helper.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Clsx
|
|
|
7
7
|
# include Clsx::Helper
|
|
8
8
|
# clsx('btn', active: @active) # => "btn active"
|
|
9
9
|
module Helper
|
|
10
|
+
# Single-pass scan for tab/newline. Collapses two consecutive `include?`
|
|
11
|
+
# scans into one regex match (no MatchData alloc). Space is checked
|
|
12
|
+
# separately first so the common "has a space" case still short-circuits.
|
|
13
|
+
TAB_OR_NEWLINE = /[\t\n]/
|
|
14
|
+
|
|
10
15
|
# Build a CSS class string from an arbitrary mix of arguments.
|
|
11
16
|
#
|
|
12
17
|
# Falsy values (+nil+, +false+) and standalone +true+ are discarded.
|
|
@@ -26,12 +31,35 @@ module Clsx
|
|
|
26
31
|
# clsx(%w[foo bar], hidden: true) # => "foo bar hidden"
|
|
27
32
|
def clsx(*args)
|
|
28
33
|
return nil if args.empty?
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
|
|
35
|
+
if args.size == 1
|
|
36
|
+
arg = args[0]
|
|
37
|
+
# Inline the two dominant single-arg shapes (String, Hash) so they skip
|
|
38
|
+
# the clsx_one dispatch. Other types fall through to clsx_one, which no
|
|
39
|
+
# longer re-checks String or Hash (already ruled out here).
|
|
40
|
+
if arg.is_a?(String)
|
|
41
|
+
return nil if arg.empty?
|
|
42
|
+
return arg unless arg.include?(' ') || arg.match?(TAB_OR_NEWLINE)
|
|
43
|
+
|
|
44
|
+
return clsx_dedup_str(arg)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return clsx_hash(arg) if arg.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
return clsx_one(arg)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if args.size == 2 && args[0].is_a?(String) && args[1].is_a?(Hash)
|
|
53
|
+
str = args[0]
|
|
54
|
+
return clsx_hash(args[1]) if str.empty?
|
|
55
|
+
return clsx_str_hash_full(str, args[1]) if str.include?(' ') || str.match?(TAB_OR_NEWLINE)
|
|
56
|
+
|
|
57
|
+
return clsx_str_hash(str, args[1])
|
|
58
|
+
end
|
|
31
59
|
|
|
32
60
|
seen = {}
|
|
33
|
-
|
|
34
|
-
seen
|
|
61
|
+
clsx_walk(args, seen)
|
|
62
|
+
clsx_join(seen)
|
|
35
63
|
end
|
|
36
64
|
|
|
37
65
|
# (see #clsx)
|
|
@@ -39,131 +67,299 @@ module Clsx
|
|
|
39
67
|
|
|
40
68
|
private
|
|
41
69
|
|
|
42
|
-
# Single-argument fast path
|
|
43
|
-
#
|
|
70
|
+
# Single-argument fast path for descriptors other than String and Hash,
|
|
71
|
+
# both of which are handled inline in {#clsx} — this method never sees them.
|
|
44
72
|
#
|
|
45
|
-
# @param arg [String,
|
|
46
|
-
# @return [String]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
# @param arg [Object] single non-String, non-Hash class descriptor
|
|
74
|
+
# @return [String, nil]
|
|
75
|
+
def clsx_one(arg)
|
|
76
|
+
if arg.is_a?(Symbol)
|
|
77
|
+
s = arg.name
|
|
78
|
+
return s unless s.include?(' ') || s.match?(TAB_OR_NEWLINE)
|
|
79
|
+
|
|
80
|
+
return clsx_dedup_str(s)
|
|
81
|
+
end
|
|
52
82
|
|
|
53
83
|
if arg.is_a?(Array)
|
|
54
84
|
return nil if arg.empty?
|
|
55
85
|
|
|
56
|
-
if arg.all?(String)
|
|
57
|
-
seen = {}
|
|
58
|
-
arg.each { |s| seen[s] = true unless s.empty? }
|
|
59
|
-
return seen.empty? ? nil : seen.keys.join(' ')
|
|
60
|
-
end
|
|
61
|
-
|
|
62
86
|
seen = {}
|
|
63
|
-
|
|
64
|
-
return seen
|
|
87
|
+
clsx_walk(arg, seen)
|
|
88
|
+
return clsx_join(seen)
|
|
65
89
|
end
|
|
66
90
|
|
|
67
|
-
return arg.to_s if arg.is_a?(Numeric)
|
|
68
91
|
return nil if !arg || arg == true || arg.is_a?(Proc)
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
93
|
+
s = arg.to_s
|
|
94
|
+
s.empty? ? nil : s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Dedup and normalize a multi-token string. Handles whitespace-only
|
|
98
|
+
# input, leading/trailing whitespace, tabs, newlines, and duplicates.
|
|
99
|
+
#
|
|
100
|
+
# @param str [String] space-separated class string
|
|
101
|
+
# @return [String, nil]
|
|
102
|
+
def clsx_dedup_str(str)
|
|
103
|
+
parts = str.split
|
|
104
|
+
return nil if parts.empty?
|
|
105
|
+
return parts[0] if parts.length == 1
|
|
106
|
+
# Already canonical (single-spaced, no dup/leading/trailing/tab/newline)
|
|
107
|
+
# iff uniq! removed nothing and token count == space count + 1; then
|
|
108
|
+
# return str as-is and skip the join allocation.
|
|
109
|
+
return str if !parts.uniq! && parts.length == str.count(' ') + 1
|
|
110
|
+
|
|
111
|
+
parts.join(' ')
|
|
72
112
|
end
|
|
73
113
|
|
|
74
|
-
# Hash-only fast path
|
|
75
|
-
#
|
|
114
|
+
# Hash-only fast path using a string buffer, skipping cross-key dedup.
|
|
115
|
+
# Hash keys are unique, so the only way two distinct keys collide on a
|
|
116
|
+
# class name is a Symbol and a String of the same name (+:foo+ and
|
|
117
|
+
# +"foo"+ both yield "foo"). Hence on mixed key types — plus multi-token
|
|
118
|
+
# or complex (Array/Hash) keys — it falls back to {#clsx_hash_full}, which
|
|
119
|
+
# deduplicates.
|
|
76
120
|
#
|
|
77
|
-
# @param hash [Hash
|
|
78
|
-
# @return [String]
|
|
79
|
-
|
|
80
|
-
def clsx_simple_hash(hash)
|
|
121
|
+
# @param hash [Hash] class-name => condition pairs
|
|
122
|
+
# @return [String, nil]
|
|
123
|
+
def clsx_hash(hash)
|
|
81
124
|
return nil if hash.empty?
|
|
82
125
|
|
|
83
126
|
buf = nil
|
|
127
|
+
owned = false
|
|
128
|
+
key_type = nil
|
|
129
|
+
|
|
84
130
|
hash.each do |key, value|
|
|
85
131
|
next unless value
|
|
86
132
|
|
|
87
133
|
if key.is_a?(Symbol)
|
|
88
|
-
|
|
89
|
-
|
|
134
|
+
return clsx_hash_full(hash) if key_type == :string
|
|
135
|
+
|
|
136
|
+
key_type = :symbol
|
|
137
|
+
s = key.name
|
|
138
|
+
return clsx_hash_full(hash) if s.include?(' ')
|
|
139
|
+
|
|
140
|
+
# Defer the dup: hold the first token by reference (frozen name or the
|
|
141
|
+
# caller's key) and only copy when a second token must be appended.
|
|
142
|
+
if buf
|
|
143
|
+
buf = buf.dup unless owned
|
|
144
|
+
owned = true
|
|
145
|
+
buf << ' ' << s
|
|
146
|
+
else
|
|
147
|
+
buf = s
|
|
148
|
+
end
|
|
90
149
|
elsif key.is_a?(String)
|
|
91
150
|
next if key.empty?
|
|
92
151
|
|
|
93
|
-
|
|
152
|
+
return clsx_hash_full(hash) if key_type == :symbol
|
|
153
|
+
return clsx_hash_full(hash) if key.include?(' ')
|
|
154
|
+
|
|
155
|
+
key_type = :string
|
|
156
|
+
if buf
|
|
157
|
+
buf = buf.dup unless owned
|
|
158
|
+
owned = true
|
|
159
|
+
buf << ' ' << key
|
|
160
|
+
else
|
|
161
|
+
buf = key
|
|
162
|
+
end
|
|
94
163
|
else
|
|
95
|
-
|
|
96
|
-
clsx_process([hash], seen)
|
|
97
|
-
return seen.empty? ? nil : seen.keys.join(' ')
|
|
164
|
+
return clsx_hash_full(hash)
|
|
98
165
|
end
|
|
99
166
|
end
|
|
167
|
+
|
|
168
|
+
return nil unless buf
|
|
169
|
+
return clsx_dedup_str(buf) if buf.match?(TAB_OR_NEWLINE)
|
|
170
|
+
|
|
100
171
|
buf
|
|
101
172
|
end
|
|
102
173
|
|
|
103
|
-
#
|
|
104
|
-
# the base string. Falls back to {#clsx_process} on non-String/Symbol keys.
|
|
174
|
+
# Hash fallback with full dedup via walker.
|
|
105
175
|
#
|
|
106
|
-
# @param
|
|
107
|
-
# @
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
176
|
+
# @param hash [Hash] class-name => condition pairs
|
|
177
|
+
# @return [String, nil]
|
|
178
|
+
def clsx_hash_full(hash)
|
|
179
|
+
seen = {}
|
|
180
|
+
clsx_walk_hash(hash, seen)
|
|
181
|
+
clsx_join(seen)
|
|
182
|
+
end
|
|
112
183
|
|
|
184
|
+
# Fast path for +clsx('base', active: cond)+ pattern where base is a
|
|
185
|
+
# single token. Deduplicates via direct string comparison against +str+.
|
|
186
|
+
#
|
|
187
|
+
# A key containing a space forces the {#clsx_str_hash_full} fallback —
|
|
188
|
+
# whole-key comparison can't dedup tokens *inside* a multi-token key, and
|
|
189
|
+
# the trailing normalization only rescans for tab/newline. Tab/newline keys
|
|
190
|
+
# are kept and normalized by that trailing {#clsx_dedup_str} pass, which
|
|
191
|
+
# splits on all whitespace.
|
|
192
|
+
#
|
|
193
|
+
# @param str [String] base class name
|
|
194
|
+
# @param hash [Hash] class-name => condition pairs
|
|
195
|
+
# @return [String, nil]
|
|
196
|
+
def clsx_str_hash(str, hash)
|
|
113
197
|
buf = str.dup
|
|
198
|
+
key_type = nil
|
|
199
|
+
|
|
114
200
|
hash.each do |key, value|
|
|
115
201
|
next unless value
|
|
116
202
|
|
|
117
203
|
if key.is_a?(Symbol)
|
|
204
|
+
return clsx_str_hash_full(str, hash) if key_type == :string
|
|
205
|
+
|
|
206
|
+
key_type = :symbol
|
|
118
207
|
s = key.name
|
|
119
|
-
|
|
208
|
+
return clsx_str_hash_full(str, hash) if s.include?(' ')
|
|
209
|
+
|
|
210
|
+
next if s == str
|
|
211
|
+
|
|
212
|
+
buf << ' ' << s
|
|
120
213
|
elsif key.is_a?(String)
|
|
121
|
-
|
|
214
|
+
return clsx_str_hash_full(str, hash) if key_type == :symbol
|
|
215
|
+
|
|
216
|
+
key_type = :string
|
|
217
|
+
next if key.empty?
|
|
218
|
+
|
|
219
|
+
return clsx_str_hash_full(str, hash) if key.include?(' ')
|
|
220
|
+
|
|
221
|
+
next if key == str
|
|
222
|
+
|
|
223
|
+
buf << ' ' << key
|
|
122
224
|
else
|
|
123
|
-
|
|
124
|
-
clsx_process([hash], seen)
|
|
125
|
-
return seen.size == 1 ? str : seen.keys.join(' ')
|
|
225
|
+
return clsx_str_hash_full(str, hash)
|
|
126
226
|
end
|
|
127
227
|
end
|
|
228
|
+
|
|
229
|
+
return clsx_dedup_str(buf) if buf.match?(TAB_OR_NEWLINE)
|
|
230
|
+
|
|
128
231
|
buf
|
|
129
232
|
end
|
|
130
233
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
234
|
+
# Full str+hash dedup using array lookup. Splits the base string once,
|
|
235
|
+
# then checks hash keys against the parts array via linear search.
|
|
236
|
+
# Falls back to Hash dedup on mixed key types or complex keys.
|
|
134
237
|
#
|
|
135
|
-
# @param
|
|
238
|
+
# @param str [String] base class name (contains spaces)
|
|
239
|
+
# @param hash [Hash] class-name => condition pairs
|
|
240
|
+
# @return [String, nil]
|
|
241
|
+
def clsx_str_hash_full(str, hash)
|
|
242
|
+
parts = str.split
|
|
243
|
+
return clsx_hash(hash) if parts.empty?
|
|
244
|
+
return clsx_str_hash_full_walk(parts, hash) if parts.length == 1
|
|
245
|
+
|
|
246
|
+
buf = if parts.uniq! || parts.length != str.count(' ') + 1
|
|
247
|
+
parts.join(' ')
|
|
248
|
+
else
|
|
249
|
+
str.dup
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
key_type = nil
|
|
253
|
+
|
|
254
|
+
hash.each do |key, value|
|
|
255
|
+
next unless value
|
|
256
|
+
|
|
257
|
+
if key.is_a?(Symbol)
|
|
258
|
+
return clsx_str_hash_full_walk(parts, hash) if key_type == :string
|
|
259
|
+
|
|
260
|
+
key_type = :symbol
|
|
261
|
+
s = key.name
|
|
262
|
+
return clsx_str_hash_full_walk(parts, hash) if s.include?(' ') || s.match?(TAB_OR_NEWLINE)
|
|
263
|
+
|
|
264
|
+
next if parts.include?(s)
|
|
265
|
+
|
|
266
|
+
buf << ' ' << s
|
|
267
|
+
elsif key.is_a?(String)
|
|
268
|
+
return clsx_str_hash_full_walk(parts, hash) if key_type == :symbol
|
|
269
|
+
|
|
270
|
+
key_type = :string
|
|
271
|
+
next if key.empty?
|
|
272
|
+
return clsx_str_hash_full_walk(parts, hash) if key.include?(' ') || key.match?(TAB_OR_NEWLINE)
|
|
273
|
+
|
|
274
|
+
next if parts.include?(key)
|
|
275
|
+
|
|
276
|
+
buf << ' ' << key
|
|
277
|
+
else
|
|
278
|
+
return clsx_str_hash_full_walk(parts, hash)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
buf
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Hash-based fallback for str+hash when array lookup can't handle it.
|
|
286
|
+
#
|
|
287
|
+
# @param parts [Array<String>] pre-split base tokens
|
|
288
|
+
# @param hash [Hash] class-name => condition pairs
|
|
289
|
+
# @return [String, nil]
|
|
290
|
+
def clsx_str_hash_full_walk(parts, hash)
|
|
291
|
+
seen = {}
|
|
292
|
+
parts.each { |s| seen[s] = true }
|
|
293
|
+
clsx_walk_hash(hash, seen)
|
|
294
|
+
clsx_join(seen)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# General-purpose recursive walker. Stores strings as-is; normalization
|
|
298
|
+
# and dedup are handled by {#clsx_join} after walking.
|
|
299
|
+
#
|
|
300
|
+
# @param args [Array] nested arguments to walk
|
|
136
301
|
# @param seen [Hash{String => true}] accumulator for deduplication
|
|
137
302
|
# @return [void]
|
|
138
|
-
def
|
|
303
|
+
def clsx_walk(args, seen)
|
|
139
304
|
args.each do |arg|
|
|
140
305
|
if arg.is_a?(String)
|
|
141
306
|
seen[arg] = true unless arg.empty?
|
|
142
307
|
elsif arg.is_a?(Symbol)
|
|
143
308
|
seen[arg.name] = true
|
|
144
309
|
elsif arg.is_a?(Array)
|
|
145
|
-
|
|
310
|
+
clsx_walk(arg, seen)
|
|
146
311
|
elsif arg.is_a?(Hash)
|
|
147
|
-
arg
|
|
148
|
-
next unless value
|
|
149
|
-
|
|
150
|
-
if key.is_a?(Symbol)
|
|
151
|
-
seen[key.name] = true
|
|
152
|
-
elsif key.is_a?(String)
|
|
153
|
-
seen[key] = true unless key.empty?
|
|
154
|
-
else
|
|
155
|
-
clsx_process([key], seen)
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
elsif arg.is_a?(Numeric)
|
|
159
|
-
seen[arg.to_s] = true
|
|
312
|
+
clsx_walk_hash(arg, seen)
|
|
160
313
|
elsif !arg || arg == true || arg.is_a?(Proc)
|
|
161
314
|
next
|
|
162
315
|
else
|
|
163
|
-
|
|
164
|
-
seen[
|
|
316
|
+
s = arg.to_s
|
|
317
|
+
seen[s] = true unless s.empty?
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Hash-specific walker — avoids wrapping hash in an array for recursion.
|
|
323
|
+
#
|
|
324
|
+
# @param hash [Hash] hash to walk
|
|
325
|
+
# @param seen [Hash{String => true}] accumulator
|
|
326
|
+
# @return [void]
|
|
327
|
+
def clsx_walk_hash(hash, seen)
|
|
328
|
+
return if hash.empty?
|
|
329
|
+
|
|
330
|
+
hash.each do |key, value|
|
|
331
|
+
next unless value
|
|
332
|
+
|
|
333
|
+
if key.is_a?(Symbol)
|
|
334
|
+
seen[key.name] = true
|
|
335
|
+
elsif key.is_a?(String)
|
|
336
|
+
seen[key] = true unless key.empty?
|
|
337
|
+
elsif key.is_a?(Array)
|
|
338
|
+
clsx_walk(key, seen)
|
|
339
|
+
elsif key.is_a?(Hash)
|
|
340
|
+
clsx_walk_hash(key, seen)
|
|
341
|
+
elsif !key || key == true || key.is_a?(Proc)
|
|
342
|
+
next
|
|
343
|
+
else
|
|
344
|
+
s = key.to_s
|
|
345
|
+
seen[s] = true unless s.empty?
|
|
165
346
|
end
|
|
166
347
|
end
|
|
167
348
|
end
|
|
349
|
+
|
|
350
|
+
# Post-join dedup and normalization: detects multi-token entries via
|
|
351
|
+
# space count mismatch or tab/newline presence, then splits and rebuilds.
|
|
352
|
+
#
|
|
353
|
+
# @param seen [Hash{String => true}] token accumulator
|
|
354
|
+
# @return [String, nil] space-joined class string
|
|
355
|
+
def clsx_join(seen)
|
|
356
|
+
return nil if seen.empty?
|
|
357
|
+
|
|
358
|
+
result = seen.keys.join(' ')
|
|
359
|
+
return result if result.count(' ') + 1 == seen.size && !result.match?(TAB_OR_NEWLINE)
|
|
360
|
+
|
|
361
|
+
normalized = result.split.uniq.join(' ')
|
|
362
|
+
normalized.empty? ? nil : normalized
|
|
363
|
+
end
|
|
168
364
|
end
|
|
169
365
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'clsx'
|
|
4
|
+
require 'tailwind_merge'
|
|
5
|
+
|
|
6
|
+
# Opt-in Tailwind merging. Requiring this file (once, at boot) pulls in the
|
|
7
|
+
# {https://github.com/gjtorikian/tailwind_merge tailwind_merge} gem and adds a
|
|
8
|
+
# merged variant of +clsx+ — {Clsx.twm}, the {Helper#twm} mixin method, and the
|
|
9
|
+
# +Twm[]+ shortcut — that resolves conflicting Tailwind utilities
|
|
10
|
+
# (e.g. +"px-2 px-4"+ becomes +"px-4"+).
|
|
11
|
+
#
|
|
12
|
+
# +clsx+/+cn+ are left untouched and stay pure; only +twm+/+Twm+ merge. Without
|
|
13
|
+
# requiring this file the core gem carries no dependency.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# require 'clsx/tailwind_merge'
|
|
17
|
+
# Twm['px-2 px-4'] # => "px-4"
|
|
18
|
+
#
|
|
19
|
+
# @example Custom merger (configure once at boot)
|
|
20
|
+
# Clsx.merger = TailwindMerge::Merger.new(config: { prefix: 'tw' })
|
|
21
|
+
module Clsx
|
|
22
|
+
@merger_mutex = Mutex.new
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# @param merger [#merge] a preconfigured merger (or +nil+ to reset to the lazy default)
|
|
26
|
+
# @return [#merge, nil]
|
|
27
|
+
attr_writer :merger
|
|
28
|
+
|
|
29
|
+
# Process-wide merger, built once. Building a {TailwindMerge::Merger} is
|
|
30
|
+
# expensive, so double-checked locking constructs it exactly once even under
|
|
31
|
+
# concurrent first use; after that the lock is never taken again.
|
|
32
|
+
#
|
|
33
|
+
# @return [#merge]
|
|
34
|
+
def merger
|
|
35
|
+
@merger || @merger_mutex.synchronize { @merger ||= TailwindMerge::Merger.new }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Adds the merged {#twm} variant to the {Helper} mixin.
|
|
40
|
+
module Helper
|
|
41
|
+
# Like {#clsx}, but pipes the result through {Clsx.merger} to resolve
|
|
42
|
+
# conflicting Tailwind utilities. Returns +nil+ (skipping the merger) when no
|
|
43
|
+
# classes apply, matching {#clsx}.
|
|
44
|
+
#
|
|
45
|
+
# @return [String, nil]
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# twm('px-2 px-4') # => "px-4"
|
|
49
|
+
# twm('px-2', 'px-4', hidden: c) # => "px-4"
|
|
50
|
+
def twm(*)
|
|
51
|
+
classes = clsx(*)
|
|
52
|
+
classes && Clsx.merger.merge(classes)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Bracket shortcut for {Helper#twm}, mirroring {Clsx.[]}.
|
|
57
|
+
module Twm
|
|
58
|
+
# (see Helper#twm)
|
|
59
|
+
def self.[](*)
|
|
60
|
+
Clsx.twm(*)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Short top-level shortcut — only defined if +Twm+ is not already taken.
|
|
66
|
+
Twm = Clsx::Twm unless Object.const_defined?(:Twm)
|
data/lib/clsx/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clsx-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leonid Svyatov
|
|
@@ -23,6 +23,7 @@ files:
|
|
|
23
23
|
- README.md
|
|
24
24
|
- lib/clsx.rb
|
|
25
25
|
- lib/clsx/helper.rb
|
|
26
|
+
- lib/clsx/tailwind_merge.rb
|
|
26
27
|
- lib/clsx/version.rb
|
|
27
28
|
homepage: https://github.com/svyatov/clsx-ruby
|
|
28
29
|
licenses:
|
|
@@ -45,7 +46,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
45
46
|
- !ruby/object:Gem::Version
|
|
46
47
|
version: '0'
|
|
47
48
|
requirements: []
|
|
48
|
-
rubygems_version: 4.0.
|
|
49
|
+
rubygems_version: 4.0.12
|
|
49
50
|
specification_version: 4
|
|
50
51
|
summary: The fastest conditional CSS class builder for Ruby
|
|
51
52
|
test_files: []
|