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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f20a56982120e4142d5932688887cc7f4b38a3596e01ce8aacf1f7b5b15f8d4
4
- data.tar.gz: ebbd199c2d2ad139c2dda52680953778092b21708e01aef7b09d4619d932333a
3
+ metadata.gz: 102bb836f576baf8a09d29ce9eacbcfe3023ce9ba76d0099087d1978c4a6f451
4
+ data.tar.gz: 7d25f7931b2afad81609037e54ee004ffb02197aee6300547d8904249125a6c7
5
5
  SHA512:
6
- metadata.gz: 5ed8a104aac715ac054d3399f6cf0c6e15a1b5a2df6307218c5f31172ebe7c1aca261d24b8b7ed23e3a06974002c2c1b9af7f857b4a4ebf4e90eea3694e75050
7
- data.tar.gz: 83a0ea7711cab8e01b9eb8f71376af34e51d21bbbbd7da6091e1abe323d4145228c6e8ed44ec13f6421670ad1685a7857c531e198c100e553f77771d4183db45
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
- **3-8x faster** than Rails `class_names` across every scenario:
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
- | Single string | 7.6M i/s | 911K i/s | **8.5x** |
42
- | String + hash | 2.4M i/s | 580K i/s | **4.1x** |
43
- | String array | 1.4M i/s | 357K i/s | **4.0x** |
44
- | Multiple strings | 1.5M i/s | 414K i/s | **3.7x** |
45
- | Hash | 2.2M i/s | 670K i/s | **3.3x** |
46
- | Mixed types | 852K i/s | 367K i/s | **2.3x** |
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.1, Apple M1 Pro. Reproduce: `bundle exec ruby benchmark/run.rb`</sup>
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
- | 38× faster | ✅ | ❌ |
57
+ | 24× 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 AlertComponent < ViewComponent::Base
177
+ class ApplicationComponent < ViewComponent::Base
170
178
  include Clsx::Helper
179
+ end
180
+ ```
171
181
 
172
- def initialize(variant: :info, dismissible: false)
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 Badge < Phlex::HTML
263
+ class ApplicationComponent < Phlex::HTML
211
264
  include Clsx::Helper
265
+ end
212
266
 
213
- def initialize(color: :blue, pill: false)
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'] # => '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
- return clsx_single(args[0]) if args.size == 1
30
- return clsx_string_hash(args[0], args[1]) if args.size == 2 && args[0].is_a?(String) && args[1].is_a?(Hash)
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
- clsx_process(args, seen)
34
- seen.empty? ? nil : seen.keys.join(' ')
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 dispatches by type to avoid allocating
43
- # a +seen+ hash when possible.
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, Symbol, Hash, Array] single class descriptor
46
- # @return [String] resolved class string
47
- # @return [nil] when the argument produces no classes
48
- def clsx_single(arg)
49
- return (arg.empty? ? nil : arg) if arg.is_a?(String)
50
- return arg.name if arg.is_a?(Symbol)
51
- return clsx_simple_hash(arg) if arg.is_a?(Hash)
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
- clsx_process(arg, seen)
64
- return seen.empty? ? nil : seen.keys.join(' ')
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
- str = arg.to_s
71
- str.empty? ? nil : str
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 no dedup needed since hash keys are unique.
75
- # Falls back to {#clsx_process} on non-String/Symbol keys.
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{String, Symbol => Boolean}] class-name => condition pairs
78
- # @return [String] resolved class string
79
- # @return [nil] when no hash values are truthy
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
- str = key.name
89
- buf ? (buf << ' ' << str) : (buf = str.dup)
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
- buf ? (buf << ' ' << key) : (buf = key.dup)
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
- seen = {}
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
- # Fast path for +clsx('base', active: cond)+ — deduplicates only against
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 str [String] base class name
107
- # @param hash [Hash{String, Symbol => Boolean}] class-name => condition pairs
108
- # @return [String] resolved class string
109
- # @return [nil] when no classes apply
110
- def clsx_string_hash(str, hash)
111
- return clsx_simple_hash(hash) if str.empty?
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
- buf << ' ' << s unless s == str
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
- buf << ' ' << key unless key.empty? || key == str
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
- seen = { str => true }
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
- # General-purpose recursive walker. Processes hash keys inline for simple
132
- # types (String/Symbol) to avoid deferred-array allocation. Only complex
133
- # keys (Array, nested Hash) recurse.
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 args [Array<String, Symbol, Hash, Array, Numeric, nil, false>] nested arguments
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 clsx_process(args, seen)
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
- clsx_process(arg, seen)
310
+ clsx_walk(arg, seen)
146
311
  elsif arg.is_a?(Hash)
147
- arg.each do |key, value|
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
- str = arg.to_s
164
- seen[str] = true unless str.empty?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clsx
4
- VERSION = '1.1.2'
4
+ VERSION = '1.2.0'
5
5
  end
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.1.2
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.6
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: []