clsx-ruby 1.1.3 → 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: 7af7dbc1bf63dae86a10afc293087f0528b82d479e0f4cc2e82a6944c79c8ed4
4
- data.tar.gz: 2ed149e30e3da918e403697ea511ca597c6bece1bbb2965ae6dd02e0d054a4b8
3
+ metadata.gz: 102bb836f576baf8a09d29ce9eacbcfe3023ce9ba76d0099087d1978c4a6f451
4
+ data.tar.gz: 7d25f7931b2afad81609037e54ee004ffb02197aee6300547d8904249125a6c7
5
5
  SHA512:
6
- metadata.gz: 37be556d78cb762df769303b6143e7872fd0bc67f26110720e31cf34238d3c657cab2f09b39f899163babcd7086c044d281d157261cb73736e7fcf4fe81d6b05
7
- data.tar.gz: 6c5af47dba54f7d17fa5ba00a8f9bd2e96f34581cdd3f3544702b93699ec9bb0f5068147e294b5cff976341f64aa56353f2713b90929dbdfd443a5d83a62560e
6
+ metadata.gz: 574f8ee8bc63b7d4dbeaaae07a8232ea8fc0d0459f68d60eb99b2191c35060bc200530898c940f0b6f7f91753faab898b41813227347bc8108f2b599d8eb46ed
7
+ data.tar.gz: d5da4ba0eef261a1e19bfa83976c83e05ad36803538dccf1a5a090c7a54516c05dbda502948aa5b52f1c8f12deedf23e08bb2b26dafb852e81827233c9a99201
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ 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
+
10
20
  ## v1.1.3
11
21
 
12
22
  ### Fixed
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
- **2-3x 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
- | 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** |
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
- | 2–3× faster | ✅ | ❌ |
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 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`:
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,29 @@ 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_one(args[0]) if args.size == 1
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
30
51
 
31
52
  if args.size == 2 && args[0].is_a?(String) && args[1].is_a?(Hash)
32
53
  str = args[0]
33
54
  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
55
+ return clsx_str_hash_full(str, args[1]) if str.include?(' ') || str.match?(TAB_OR_NEWLINE)
56
+
35
57
  return clsx_str_hash(str, args[1])
36
58
  end
37
59
 
@@ -45,31 +67,19 @@ module Clsx
45
67
 
46
68
  private
47
69
 
48
- # Single-argument fast path dispatches by type, handles multi-token
49
- # string dedup without allocating a walker Hash for simple cases.
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.
50
72
  #
51
- # @param arg [Object] single class descriptor
73
+ # @param arg [Object] single non-String, non-Hash class descriptor
52
74
  # @return [String, nil]
53
75
  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
76
  if arg.is_a?(Symbol)
66
77
  s = arg.name
67
- return s unless s.include?(' ') || s.include?("\t") || s.include?("\n") # rubocop:disable Layout/EmptyLineAfterGuardClause
78
+ return s unless s.include?(' ') || s.match?(TAB_OR_NEWLINE)
79
+
68
80
  return clsx_dedup_str(s)
69
81
  end
70
82
 
71
- return clsx_hash(arg) if arg.is_a?(Hash)
72
-
73
83
  if arg.is_a?(Array)
74
84
  return nil if arg.empty?
75
85
 
@@ -93,13 +103,20 @@ module Clsx
93
103
  parts = str.split
94
104
  return nil if parts.empty?
95
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.
96
109
  return str if !parts.uniq! && parts.length == str.count(' ') + 1
97
110
 
98
111
  parts.join(' ')
99
112
  end
100
113
 
101
- # Hash-only fast path using string buffer. Falls back to Hash dedup
102
- # on mixed key types, multi-token keys, or complex 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.
103
120
  #
104
121
  # @param hash [Hash] class-name => condition pairs
105
122
  # @return [String, nil]
@@ -107,6 +124,7 @@ module Clsx
107
124
  return nil if hash.empty?
108
125
 
109
126
  buf = nil
127
+ owned = false
110
128
  key_type = nil
111
129
 
112
130
  hash.each do |key, value|
@@ -119,7 +137,15 @@ module Clsx
119
137
  s = key.name
120
138
  return clsx_hash_full(hash) if s.include?(' ')
121
139
 
122
- buf ? (buf << ' ' << s) : (buf = s.dup)
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
123
149
  elsif key.is_a?(String)
124
150
  next if key.empty?
125
151
 
@@ -127,14 +153,20 @@ module Clsx
127
153
  return clsx_hash_full(hash) if key.include?(' ')
128
154
 
129
155
  key_type = :string
130
- buf ? (buf << ' ' << key) : (buf = key.dup)
156
+ if buf
157
+ buf = buf.dup unless owned
158
+ owned = true
159
+ buf << ' ' << key
160
+ else
161
+ buf = key
162
+ end
131
163
  else
132
164
  return clsx_hash_full(hash)
133
165
  end
134
166
  end
135
167
 
136
168
  return nil unless buf
137
- return clsx_dedup_str(buf) if buf.include?("\t") || buf.include?("\n")
169
+ return clsx_dedup_str(buf) if buf.match?(TAB_OR_NEWLINE)
138
170
 
139
171
  buf
140
172
  end
@@ -150,7 +182,13 @@ module Clsx
150
182
  end
151
183
 
152
184
  # Fast path for +clsx('base', active: cond)+ pattern where base is a
153
- # single token. Deduplicates via direct string comparison.
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.
154
192
  #
155
193
  # @param str [String] base class name
156
194
  # @param hash [Hash] class-name => condition pairs
@@ -188,7 +226,7 @@ module Clsx
188
226
  end
189
227
  end
190
228
 
191
- return clsx_dedup_str(buf) if buf.include?("\t") || buf.include?("\n")
229
+ return clsx_dedup_str(buf) if buf.match?(TAB_OR_NEWLINE)
192
230
 
193
231
  buf
194
232
  end
@@ -221,7 +259,7 @@ module Clsx
221
259
 
222
260
  key_type = :symbol
223
261
  s = key.name
224
- return clsx_str_hash_full_walk(parts, hash) if s.include?(' ') || s.include?("\t") || s.include?("\n")
262
+ return clsx_str_hash_full_walk(parts, hash) if s.include?(' ') || s.match?(TAB_OR_NEWLINE)
225
263
 
226
264
  next if parts.include?(s)
227
265
 
@@ -231,7 +269,7 @@ module Clsx
231
269
 
232
270
  key_type = :string
233
271
  next if key.empty?
234
- return clsx_str_hash_full_walk(parts, hash) if key.include?(' ') || key.include?("\t") || key.include?("\n")
272
+ return clsx_str_hash_full_walk(parts, hash) if key.include?(' ') || key.match?(TAB_OR_NEWLINE)
235
273
 
236
274
  next if parts.include?(key)
237
275
 
@@ -318,7 +356,7 @@ module Clsx
318
356
  return nil if seen.empty?
319
357
 
320
358
  result = seen.keys.join(' ')
321
- return result if result.count(' ') + 1 == seen.size && !result.include?("\t") && !result.include?("\n")
359
+ return result if result.count(' ') + 1 == seen.size && !result.match?(TAB_OR_NEWLINE)
322
360
 
323
361
  normalized = result.split.uniq.join(' ')
324
362
  normalized.empty? ? nil : normalized
@@ -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.3'
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.3
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: []