clsx-ruby 1.1.2 → 1.1.3

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: 7af7dbc1bf63dae86a10afc293087f0528b82d479e0f4cc2e82a6944c79c8ed4
4
+ data.tar.gz: 2ed149e30e3da918e403697ea511ca597c6bece1bbb2965ae6dd02e0d054a4b8
5
5
  SHA512:
6
- metadata.gz: 5ed8a104aac715ac054d3399f6cf0c6e15a1b5a2df6307218c5f31172ebe7c1aca261d24b8b7ed23e3a06974002c2c1b9af7f857b4a4ebf4e90eea3694e75050
7
- data.tar.gz: 83a0ea7711cab8e01b9eb8f71376af34e51d21bbbbd7da6091e1abe323d4145228c6e8ed44ec13f6421670ad1685a7857c531e198c100e553f77771d4183db45
6
+ metadata.gz: 37be556d78cb762df769303b6143e7872fd0bc67f26110720e31cf34238d3c657cab2f09b39f899163babcd7086c044d281d157261cb73736e7fcf4fe81d6b05
7
+ data.tar.gz: 6c5af47dba54f7d17fa5ba00a8f9bd2e96f34581cdd3f3544702b93699ec9bb0f5068147e294b5cff976341f64aa56353f2713b90929dbdfd443a5d83a62560e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## v1.1.3
11
+
12
+ ### Fixed
13
+
14
+ - 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"`)
15
+ - Whitespace-only strings now correctly return `nil` (e.g., `clsx(" ")` returns `nil`)
16
+ - Deduplicate class names across multi-token strings (e.g., `clsx("foo bar", "foo")` now returns `"foo bar"`)
17
+
10
18
  ## v1.1.2
11
19
 
12
20
  ### Changed
data/README.md CHANGED
@@ -34,16 +34,16 @@ 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-3x faster** than Rails `class_names` across every scenario:
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
+ | String array | 1.3M i/s | 369K i/s | **3.4x** |
42
+ | Multiple strings | 1.3M i/s | 408K i/s | **3.3x** |
43
+ | Single string | 2.4M i/s | 893K i/s | **2.7x** |
44
+ | Mixed types | 912K i/s | 358K i/s | **2.5x** |
45
+ | Hash | 1.7M i/s | 672K i/s | **2.5x** |
46
+ | String + hash | 1.2M i/s | 565K i/s | **2.1x** |
47
47
 
48
48
  <sup>Ruby 4.0.1, Apple M1 Pro. Reproduce: `bundle exec ruby benchmark/run.rb`</sup>
49
49
 
@@ -53,7 +53,7 @@ For Rails integration (adds `clsx` and `cn` helpers to all views), see [clsx-rai
53
53
  |---|---|---|
54
54
  | Conditional classes | ✅ | ✅ |
55
55
  | Auto-deduplication | ✅ | ✅ |
56
- | 3–8× faster | ✅ | ❌ |
56
+ | 2–3× faster | ✅ | ❌ |
57
57
  | Returns `nil` when empty | ✅ | ❌ (returns `""`) |
58
58
  | Complex hash keys | ✅ | ❌ |
59
59
  | Framework-agnostic | ✅ | ❌ |
@@ -228,9 +228,10 @@ end
228
228
  Clsx[nil, false] # => nil
229
229
  ```
230
230
 
231
- 2. **Deduplication** — Duplicate classes are automatically removed:
231
+ 2. **Deduplication** — Duplicate classes are automatically removed, even across multi-token strings:
232
232
  ```ruby
233
- Clsx['foo', 'foo'] # => 'foo'
233
+ Clsx['foo', 'foo'] # => 'foo'
234
+ Clsx['foo bar', 'foo'] # => 'foo bar'
234
235
  ```
235
236
 
236
237
  3. **Falsy values** — In Ruby only `false` and `nil` are falsy, so `0`, `''`, `[]`, `{}` are all truthy:
data/lib/clsx/helper.rb CHANGED
@@ -26,12 +26,18 @@ module Clsx
26
26
  # clsx(%w[foo bar], hidden: true) # => "foo bar hidden"
27
27
  def clsx(*args)
28
28
  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)
29
+ return clsx_one(args[0]) if args.size == 1
30
+
31
+ if args.size == 2 && args[0].is_a?(String) && args[1].is_a?(Hash)
32
+ str = args[0]
33
+ return clsx_hash(args[1]) if str.empty?
34
+ return clsx_str_hash_full(str, args[1]) if str.include?(' ') || str.include?("\t") || str.include?("\n") # rubocop:disable Layout/EmptyLineAfterGuardClause
35
+ return clsx_str_hash(str, args[1])
36
+ end
31
37
 
32
38
  seen = {}
33
- clsx_process(args, seen)
34
- seen.empty? ? nil : seen.keys.join(' ')
39
+ clsx_walk(args, seen)
40
+ clsx_join(seen)
35
41
  end
36
42
 
37
43
  # (see #clsx)
@@ -39,131 +45,283 @@ module Clsx
39
45
 
40
46
  private
41
47
 
42
- # Single-argument fast path — dispatches by type to avoid allocating
43
- # a +seen+ hash when possible.
48
+ # Single-argument fast path — dispatches by type, handles multi-token
49
+ # string dedup without allocating a walker Hash for simple cases.
44
50
  #
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)
51
+ # @param arg [Object] single class descriptor
52
+ # @return [String, nil]
53
+ def clsx_one(arg)
54
+ if arg.is_a?(String)
55
+ return nil if arg.empty?
56
+ return arg unless arg.include?(' ') || arg.include?("\t") || arg.include?("\n")
57
+
58
+ parts = arg.split
59
+ return nil if parts.empty?
60
+ return parts[0] if parts.length == 1
61
+ return arg if !parts.uniq! && parts.length == arg.count(' ') + 1 # rubocop:disable Layout/EmptyLineAfterGuardClause
62
+ return parts.join(' ')
63
+ end
64
+
65
+ if arg.is_a?(Symbol)
66
+ s = arg.name
67
+ return s unless s.include?(' ') || s.include?("\t") || s.include?("\n") # rubocop:disable Layout/EmptyLineAfterGuardClause
68
+ return clsx_dedup_str(s)
69
+ end
70
+
71
+ return clsx_hash(arg) if arg.is_a?(Hash)
52
72
 
53
73
  if arg.is_a?(Array)
54
74
  return nil if arg.empty?
55
75
 
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
76
  seen = {}
63
- clsx_process(arg, seen)
64
- return seen.empty? ? nil : seen.keys.join(' ')
77
+ clsx_walk(arg, seen)
78
+ return clsx_join(seen)
65
79
  end
66
80
 
67
- return arg.to_s if arg.is_a?(Numeric)
68
81
  return nil if !arg || arg == true || arg.is_a?(Proc)
69
82
 
70
- str = arg.to_s
71
- str.empty? ? nil : str
83
+ s = arg.to_s
84
+ s.empty? ? nil : s
72
85
  end
73
86
 
74
- # Hash-only fast path no dedup needed since hash keys are unique.
75
- # Falls back to {#clsx_process} on non-String/Symbol keys.
87
+ # Dedup and normalize a multi-token string. Handles whitespace-only
88
+ # input, leading/trailing whitespace, tabs, newlines, and duplicates.
76
89
  #
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)
90
+ # @param str [String] space-separated class string
91
+ # @return [String, nil]
92
+ def clsx_dedup_str(str)
93
+ parts = str.split
94
+ return nil if parts.empty?
95
+ return parts[0] if parts.length == 1
96
+ return str if !parts.uniq! && parts.length == str.count(' ') + 1
97
+
98
+ parts.join(' ')
99
+ end
100
+
101
+ # Hash-only fast path using string buffer. Falls back to Hash dedup
102
+ # on mixed key types, multi-token keys, or complex keys.
103
+ #
104
+ # @param hash [Hash] class-name => condition pairs
105
+ # @return [String, nil]
106
+ def clsx_hash(hash)
81
107
  return nil if hash.empty?
82
108
 
83
109
  buf = nil
110
+ key_type = nil
111
+
84
112
  hash.each do |key, value|
85
113
  next unless value
86
114
 
87
115
  if key.is_a?(Symbol)
88
- str = key.name
89
- buf ? (buf << ' ' << str) : (buf = str.dup)
116
+ return clsx_hash_full(hash) if key_type == :string
117
+
118
+ key_type = :symbol
119
+ s = key.name
120
+ return clsx_hash_full(hash) if s.include?(' ')
121
+
122
+ buf ? (buf << ' ' << s) : (buf = s.dup)
90
123
  elsif key.is_a?(String)
91
124
  next if key.empty?
92
125
 
126
+ return clsx_hash_full(hash) if key_type == :symbol
127
+ return clsx_hash_full(hash) if key.include?(' ')
128
+
129
+ key_type = :string
93
130
  buf ? (buf << ' ' << key) : (buf = key.dup)
94
131
  else
95
- seen = {}
96
- clsx_process([hash], seen)
97
- return seen.empty? ? nil : seen.keys.join(' ')
132
+ return clsx_hash_full(hash)
98
133
  end
99
134
  end
135
+
136
+ return nil unless buf
137
+ return clsx_dedup_str(buf) if buf.include?("\t") || buf.include?("\n")
138
+
100
139
  buf
101
140
  end
102
141
 
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.
142
+ # Hash fallback with full dedup via walker.
105
143
  #
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?
144
+ # @param hash [Hash] class-name => condition pairs
145
+ # @return [String, nil]
146
+ def clsx_hash_full(hash)
147
+ seen = {}
148
+ clsx_walk_hash(hash, seen)
149
+ clsx_join(seen)
150
+ end
112
151
 
152
+ # Fast path for +clsx('base', active: cond)+ pattern where base is a
153
+ # single token. Deduplicates via direct string comparison.
154
+ #
155
+ # @param str [String] base class name
156
+ # @param hash [Hash] class-name => condition pairs
157
+ # @return [String, nil]
158
+ def clsx_str_hash(str, hash)
113
159
  buf = str.dup
160
+ key_type = nil
161
+
162
+ hash.each do |key, value|
163
+ next unless value
164
+
165
+ if key.is_a?(Symbol)
166
+ return clsx_str_hash_full(str, hash) if key_type == :string
167
+
168
+ key_type = :symbol
169
+ s = key.name
170
+ return clsx_str_hash_full(str, hash) if s.include?(' ')
171
+
172
+ next if s == str
173
+
174
+ buf << ' ' << s
175
+ elsif key.is_a?(String)
176
+ return clsx_str_hash_full(str, hash) if key_type == :symbol
177
+
178
+ key_type = :string
179
+ next if key.empty?
180
+
181
+ return clsx_str_hash_full(str, hash) if key.include?(' ')
182
+
183
+ next if key == str
184
+
185
+ buf << ' ' << key
186
+ else
187
+ return clsx_str_hash_full(str, hash)
188
+ end
189
+ end
190
+
191
+ return clsx_dedup_str(buf) if buf.include?("\t") || buf.include?("\n")
192
+
193
+ buf
194
+ end
195
+
196
+ # Full str+hash dedup using array lookup. Splits the base string once,
197
+ # then checks hash keys against the parts array via linear search.
198
+ # Falls back to Hash dedup on mixed key types or complex keys.
199
+ #
200
+ # @param str [String] base class name (contains spaces)
201
+ # @param hash [Hash] class-name => condition pairs
202
+ # @return [String, nil]
203
+ def clsx_str_hash_full(str, hash)
204
+ parts = str.split
205
+ return clsx_hash(hash) if parts.empty?
206
+ return clsx_str_hash_full_walk(parts, hash) if parts.length == 1
207
+
208
+ buf = if parts.uniq! || parts.length != str.count(' ') + 1
209
+ parts.join(' ')
210
+ else
211
+ str.dup
212
+ end
213
+
214
+ key_type = nil
215
+
114
216
  hash.each do |key, value|
115
217
  next unless value
116
218
 
117
219
  if key.is_a?(Symbol)
220
+ return clsx_str_hash_full_walk(parts, hash) if key_type == :string
221
+
222
+ key_type = :symbol
118
223
  s = key.name
119
- buf << ' ' << s unless s == str
224
+ return clsx_str_hash_full_walk(parts, hash) if s.include?(' ') || s.include?("\t") || s.include?("\n")
225
+
226
+ next if parts.include?(s)
227
+
228
+ buf << ' ' << s
120
229
  elsif key.is_a?(String)
121
- buf << ' ' << key unless key.empty? || key == str
230
+ return clsx_str_hash_full_walk(parts, hash) if key_type == :symbol
231
+
232
+ key_type = :string
233
+ next if key.empty?
234
+ return clsx_str_hash_full_walk(parts, hash) if key.include?(' ') || key.include?("\t") || key.include?("\n")
235
+
236
+ next if parts.include?(key)
237
+
238
+ buf << ' ' << key
122
239
  else
123
- seen = { str => true }
124
- clsx_process([hash], seen)
125
- return seen.size == 1 ? str : seen.keys.join(' ')
240
+ return clsx_str_hash_full_walk(parts, hash)
126
241
  end
127
242
  end
243
+
128
244
  buf
129
245
  end
130
246
 
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.
247
+ # Hash-based fallback for str+hash when array lookup can't handle it.
248
+ #
249
+ # @param parts [Array<String>] pre-split base tokens
250
+ # @param hash [Hash] class-name => condition pairs
251
+ # @return [String, nil]
252
+ def clsx_str_hash_full_walk(parts, hash)
253
+ seen = {}
254
+ parts.each { |s| seen[s] = true }
255
+ clsx_walk_hash(hash, seen)
256
+ clsx_join(seen)
257
+ end
258
+
259
+ # General-purpose recursive walker. Stores strings as-is; normalization
260
+ # and dedup are handled by {#clsx_join} after walking.
134
261
  #
135
- # @param args [Array<String, Symbol, Hash, Array, Numeric, nil, false>] nested arguments
262
+ # @param args [Array] nested arguments to walk
136
263
  # @param seen [Hash{String => true}] accumulator for deduplication
137
264
  # @return [void]
138
- def clsx_process(args, seen)
265
+ def clsx_walk(args, seen)
139
266
  args.each do |arg|
140
267
  if arg.is_a?(String)
141
268
  seen[arg] = true unless arg.empty?
142
269
  elsif arg.is_a?(Symbol)
143
270
  seen[arg.name] = true
144
271
  elsif arg.is_a?(Array)
145
- clsx_process(arg, seen)
272
+ clsx_walk(arg, seen)
146
273
  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
274
+ clsx_walk_hash(arg, seen)
160
275
  elsif !arg || arg == true || arg.is_a?(Proc)
161
276
  next
162
277
  else
163
- str = arg.to_s
164
- seen[str] = true unless str.empty?
278
+ s = arg.to_s
279
+ seen[s] = true unless s.empty?
280
+ end
281
+ end
282
+ end
283
+
284
+ # Hash-specific walker — avoids wrapping hash in an array for recursion.
285
+ #
286
+ # @param hash [Hash] hash to walk
287
+ # @param seen [Hash{String => true}] accumulator
288
+ # @return [void]
289
+ def clsx_walk_hash(hash, seen)
290
+ return if hash.empty?
291
+
292
+ hash.each do |key, value|
293
+ next unless value
294
+
295
+ if key.is_a?(Symbol)
296
+ seen[key.name] = true
297
+ elsif key.is_a?(String)
298
+ seen[key] = true unless key.empty?
299
+ elsif key.is_a?(Array)
300
+ clsx_walk(key, seen)
301
+ elsif key.is_a?(Hash)
302
+ clsx_walk_hash(key, seen)
303
+ elsif !key || key == true || key.is_a?(Proc)
304
+ next
305
+ else
306
+ s = key.to_s
307
+ seen[s] = true unless s.empty?
165
308
  end
166
309
  end
167
310
  end
311
+
312
+ # Post-join dedup and normalization: detects multi-token entries via
313
+ # space count mismatch or tab/newline presence, then splits and rebuilds.
314
+ #
315
+ # @param seen [Hash{String => true}] token accumulator
316
+ # @return [String, nil] space-joined class string
317
+ def clsx_join(seen)
318
+ return nil if seen.empty?
319
+
320
+ result = seen.keys.join(' ')
321
+ return result if result.count(' ') + 1 == seen.size && !result.include?("\t") && !result.include?("\n")
322
+
323
+ normalized = result.split.uniq.join(' ')
324
+ normalized.empty? ? nil : normalized
325
+ end
168
326
  end
169
327
  end
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.1.3'
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.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov