clsx-ruby 1.1.1 → 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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +18 -16
- data/lib/clsx/helper.rb +227 -69
- data/lib/clsx/version.rb +1 -1
- metadata +6 -5
- data/CLAUDE.md +0 -138
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7af7dbc1bf63dae86a10afc293087f0528b82d479e0f4cc2e82a6944c79c8ed4
|
|
4
|
+
data.tar.gz: 2ed149e30e3da918e403697ea511ca597c6bece1bbb2965ae6dd02e0d054a4b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37be556d78cb762df769303b6143e7872fd0bc67f26110720e31cf34238d3c657cab2f09b39f899163babcd7086c044d281d157261cb73736e7fcf4fe81d6b05
|
|
7
|
+
data.tar.gz: 6c5af47dba54f7d17fa5ba00a8f9bd2e96f34581cdd3f3544702b93699ec9bb0f5068147e294b5cff976341f64aa56353f2713b90929dbdfd443a5d83a62560e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ 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
|
+
|
|
18
|
+
## v1.1.2
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Improved gemspec description and README marketing copy
|
|
23
|
+
- Switched gemspec to whitelist approach for included files
|
|
24
|
+
|
|
10
25
|
## v1.1.1
|
|
11
26
|
|
|
12
27
|
### Added
|
data/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# clsx-ruby [](https://rubygems.org/gems/clsx-ruby) [](https://app.codecov.io/gh/svyatov/clsx-ruby) [](https://github.com/svyatov/clsx-ruby/actions?query=workflow%3ACI) [](LICENSE.txt)
|
|
2
2
|
|
|
3
|
-
> The fastest
|
|
3
|
+
> The fastest, framework-agnostic conditional CSS class builder for Ruby.
|
|
4
|
+
> Perfect for ViewComponent, Phlex, Tailwind CSS or just standalone.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Inspired by the JavaScript [clsx](https://github.com/lukeed/clsx) package. Works with any Ruby codebase.
|
|
6
7
|
|
|
7
8
|
## Quick Start
|
|
8
9
|
|
|
@@ -25,20 +26,24 @@ Clsx['btn', 'btn-primary', active: is_active, disabled: is_disabled]
|
|
|
25
26
|
# => "btn btn-primary active" (when is_active is truthy, is_disabled is falsy)
|
|
26
27
|
```
|
|
27
28
|
|
|
29
|
+
## Rails Integration
|
|
30
|
+
|
|
31
|
+
For Rails integration (adds `clsx` and `cn` helpers to all views), see [clsx-rails](https://github.com/svyatov/clsx-rails).
|
|
32
|
+
|
|
28
33
|
## Why clsx-ruby?
|
|
29
34
|
|
|
30
35
|
### Blazing fast
|
|
31
36
|
|
|
32
|
-
**
|
|
37
|
+
**2-3x faster** than Rails `class_names` across every scenario:
|
|
33
38
|
|
|
34
39
|
| Scenario | clsx-ruby | Rails `class_names` | Speedup |
|
|
35
40
|
|---|---|---|---|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
| Hash |
|
|
41
|
-
|
|
|
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** |
|
|
42
47
|
|
|
43
48
|
<sup>Ruby 4.0.1, Apple M1 Pro. Reproduce: `bundle exec ruby benchmark/run.rb`</sup>
|
|
44
49
|
|
|
@@ -48,7 +53,7 @@ Clsx['btn', 'btn-primary', active: is_active, disabled: is_disabled]
|
|
|
48
53
|
|---|---|---|
|
|
49
54
|
| Conditional classes | ✅ | ✅ |
|
|
50
55
|
| Auto-deduplication | ✅ | ✅ |
|
|
51
|
-
| 3
|
|
56
|
+
| 2–3× faster | ✅ | ❌ |
|
|
52
57
|
| Returns `nil` when empty | ✅ | ❌ (returns `""`) |
|
|
53
58
|
| Complex hash keys | ✅ | ❌ |
|
|
54
59
|
| Framework-agnostic | ✅ | ❌ |
|
|
@@ -223,9 +228,10 @@ end
|
|
|
223
228
|
Clsx[nil, false] # => nil
|
|
224
229
|
```
|
|
225
230
|
|
|
226
|
-
2. **Deduplication** — Duplicate classes are automatically removed:
|
|
231
|
+
2. **Deduplication** — Duplicate classes are automatically removed, even across multi-token strings:
|
|
227
232
|
```ruby
|
|
228
|
-
Clsx['foo', 'foo']
|
|
233
|
+
Clsx['foo', 'foo'] # => 'foo'
|
|
234
|
+
Clsx['foo bar', 'foo'] # => 'foo bar'
|
|
229
235
|
```
|
|
230
236
|
|
|
231
237
|
3. **Falsy values** — In Ruby only `false` and `nil` are falsy, so `0`, `''`, `[]`, `{}` are all truthy:
|
|
@@ -243,10 +249,6 @@ end
|
|
|
243
249
|
Clsx['', proc {}, -> {}, nil, false, true] # => nil
|
|
244
250
|
```
|
|
245
251
|
|
|
246
|
-
## Rails Integration
|
|
247
|
-
|
|
248
|
-
For automatic Rails view helper integration (adds `clsx` and `cn` helpers to all views), see [clsx-rails](https://github.com/svyatov/clsx-rails).
|
|
249
|
-
|
|
250
252
|
## Development
|
|
251
253
|
|
|
252
254
|
```bash
|
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
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
seen
|
|
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
|
|
43
|
-
# a
|
|
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 [
|
|
46
|
-
# @return [String]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
64
|
-
return seen
|
|
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
|
-
|
|
71
|
-
|
|
83
|
+
s = arg.to_s
|
|
84
|
+
s.empty? ? nil : s
|
|
72
85
|
end
|
|
73
86
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
87
|
+
# Dedup and normalize a multi-token string. Handles whitespace-only
|
|
88
|
+
# input, leading/trailing whitespace, tabs, newlines, and duplicates.
|
|
76
89
|
#
|
|
77
|
-
# @param
|
|
78
|
-
# @return [String]
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
107
|
-
# @
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
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
|
|
262
|
+
# @param args [Array] nested arguments to walk
|
|
136
263
|
# @param seen [Hash{String => true}] accumulator for deduplication
|
|
137
264
|
# @return [void]
|
|
138
|
-
def
|
|
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
|
-
|
|
272
|
+
clsx_walk(arg, seen)
|
|
146
273
|
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
|
|
274
|
+
clsx_walk_hash(arg, seen)
|
|
160
275
|
elsif !arg || arg == true || arg.is_a?(Proc)
|
|
161
276
|
next
|
|
162
277
|
else
|
|
163
|
-
|
|
164
|
-
seen[
|
|
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
metadata
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clsx-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leonid Svyatov
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
12
|
+
description: Build CSS class strings from conditional expressions, hashes, arrays,
|
|
13
|
+
or nested structures. Framework-agnostic. Perfect for ViewComponent, Phlex, and
|
|
14
|
+
Tailwind CSS. Works with any Ruby codebase.
|
|
13
15
|
email:
|
|
14
16
|
- leonid@svyatov.com
|
|
15
17
|
executables: []
|
|
@@ -17,7 +19,6 @@ extensions: []
|
|
|
17
19
|
extra_rdoc_files: []
|
|
18
20
|
files:
|
|
19
21
|
- CHANGELOG.md
|
|
20
|
-
- CLAUDE.md
|
|
21
22
|
- LICENSE.txt
|
|
22
23
|
- README.md
|
|
23
24
|
- lib/clsx.rb
|
|
@@ -46,5 +47,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
46
47
|
requirements: []
|
|
47
48
|
rubygems_version: 4.0.6
|
|
48
49
|
specification_version: 4
|
|
49
|
-
summary:
|
|
50
|
+
summary: The fastest conditional CSS class builder for Ruby
|
|
50
51
|
test_files: []
|
data/CLAUDE.md
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Project Overview
|
|
6
|
-
|
|
7
|
-
clsx-ruby is a Ruby gem that provides a utility (`clsx`/`cn`) for constructing CSS class strings conditionally. It's a Ruby port of the JavaScript [clsx](https://github.com/lukeed/clsx) package, adapted for Ruby conventions. Framework-agnostic — works with Rails, Sinatra, Hanami, or plain Ruby.
|
|
8
|
-
|
|
9
|
-
## Common Commands
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
# Run all tests and linting (default rake task)
|
|
13
|
-
bundle exec rake
|
|
14
|
-
|
|
15
|
-
# Run tests only
|
|
16
|
-
bundle exec rake test
|
|
17
|
-
|
|
18
|
-
# Run a single test file
|
|
19
|
-
bundle exec ruby -Itest test/clsx/helper_test.rb
|
|
20
|
-
|
|
21
|
-
# Run a specific test method
|
|
22
|
-
bundle exec ruby -Itest test/clsx/helper_test.rb -n test_with_strings
|
|
23
|
-
|
|
24
|
-
# Run linter
|
|
25
|
-
bundle exec rake rubocop
|
|
26
|
-
|
|
27
|
-
# Run benchmark
|
|
28
|
-
bundle exec ruby benchmark/run.rb
|
|
29
|
-
|
|
30
|
-
# Install dependencies
|
|
31
|
-
bin/setup
|
|
32
|
-
|
|
33
|
-
# Release a new version (update version.rb first)
|
|
34
|
-
# Builds gem, creates git tag, pushes to rubygems.org
|
|
35
|
-
# OTP is fetched automatically from 1Password
|
|
36
|
-
bundle exec rake release
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## Architecture
|
|
40
|
-
|
|
41
|
-
The gem has a minimal structure:
|
|
42
|
-
|
|
43
|
-
- `lib/clsx.rb` - Entry point; extends `Clsx` with `Helper`, defines `Clsx[]` and `Cn[]` shortcuts
|
|
44
|
-
- `lib/clsx/helper.rb` - Core implementation with `clsx` method and `cn` alias
|
|
45
|
-
- `lib/clsx/version.rb` - Version constant
|
|
46
|
-
|
|
47
|
-
### API
|
|
48
|
-
|
|
49
|
-
- **`Clsx['foo', bar: true]`** — primary bracket API via `self.[]`
|
|
50
|
-
- **`Cn['foo', bar: true]`** — short alias (defined only if `Cn` constant is not taken)
|
|
51
|
-
- **`Clsx.clsx(...)`** / **`Clsx.cn(...)`** — module methods
|
|
52
|
-
- **`include Clsx::Helper`** — mixin giving `clsx()` and `cn()` instance methods
|
|
53
|
-
|
|
54
|
-
The helper uses an optimized algorithm with fast-paths for common cases (single string, string array, simple hash) and Hash-based deduplication for complex inputs.
|
|
55
|
-
|
|
56
|
-
## Key Behaviors
|
|
57
|
-
|
|
58
|
-
- Returns `nil` (not empty string) when no classes apply — prevents rendering empty `class=""` attributes
|
|
59
|
-
- Eliminates duplicate classes automatically
|
|
60
|
-
- Ruby falsy values are only `false` and `nil` (unlike JS, `0`, `''`, `[]`, `{}` are truthy)
|
|
61
|
-
- Ignores `Proc`/lambda objects and boolean `true` values
|
|
62
|
-
- Supports complex hash keys like `{ %w[foo bar] => true }` which resolve recursively
|
|
63
|
-
|
|
64
|
-
## Benchmarking
|
|
65
|
-
|
|
66
|
-
`benchmark/original.rb` contains the **previous version** of the algorithm for comparison. It must always reflect the last committed version from the main branch — not some ancient baseline.
|
|
67
|
-
|
|
68
|
-
**Rule:** Before making any algorithm or performance change to `lib/clsx/helper.rb`, copy the current main-branch implementation into `benchmark/original.rb` (wrapping in `module ClsxOriginal` — method names stay the same, no renaming needed). This ensures `bundle exec ruby benchmark/run.rb` compares the new code against its immediate predecessor, giving meaningful before/after numbers.
|
|
69
|
-
|
|
70
|
-
## Commit Convention
|
|
71
|
-
|
|
72
|
-
This project follows [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
73
|
-
|
|
74
|
-
Format: `<type>[optional scope]: <description>`
|
|
75
|
-
|
|
76
|
-
### Types
|
|
77
|
-
|
|
78
|
-
| Type | Description | Version bump |
|
|
79
|
-
|------|-------------|--------------|
|
|
80
|
-
| `feat` | New feature | MINOR |
|
|
81
|
-
| `fix` | Bug fix | PATCH |
|
|
82
|
-
| `docs` | Documentation only | — |
|
|
83
|
-
| `style` | Formatting, whitespace | — |
|
|
84
|
-
| `refactor` | Code change (no feature/fix) | — |
|
|
85
|
-
| `perf` | Performance improvement | — |
|
|
86
|
-
| `test` | Adding/fixing tests | — |
|
|
87
|
-
| `build` | Build system or dependencies | — |
|
|
88
|
-
| `ci` | CI configuration | — |
|
|
89
|
-
| `chore` | Maintenance tasks | — |
|
|
90
|
-
|
|
91
|
-
### Breaking Changes
|
|
92
|
-
|
|
93
|
-
Use `!` after type or add `BREAKING CHANGE:` footer. Breaking changes trigger a MAJOR version bump.
|
|
94
|
-
|
|
95
|
-
## Changelog Format
|
|
96
|
-
|
|
97
|
-
This project follows [Keep a Changelog v1.1.0](https://keepachangelog.com/en/1.1.0/).
|
|
98
|
-
|
|
99
|
-
Allowed categories in **required order**:
|
|
100
|
-
|
|
101
|
-
1. **Added** — new features
|
|
102
|
-
2. **Changed** — changes to existing functionality
|
|
103
|
-
3. **Deprecated** — soon-to-be removed features
|
|
104
|
-
4. **Removed** — removed features
|
|
105
|
-
5. **Fixed** — bug fixes
|
|
106
|
-
6. **Security** — vulnerability fixes
|
|
107
|
-
|
|
108
|
-
Rules:
|
|
109
|
-
- Categories must appear in the order listed above within each release section
|
|
110
|
-
- Each category must appear **at most once** per release section — always append to an existing category rather than creating a duplicate
|
|
111
|
-
- Do NOT use non-standard categories like "Updated", "Internal", or "Breaking changes"
|
|
112
|
-
- Breaking changes should be prefixed with **BREAKING:** within the relevant category (typically Changed or Removed)
|
|
113
|
-
|
|
114
|
-
`CHANGELOG.md` must stay current on every feature branch. After each commit, ensure the `## Unreleased` section at the top accurately reflects all user-facing changes on the branch. Add the section if it doesn't exist. Keep entries concise — one bullet per logical change. On release, the `## Unreleased` heading gets replaced with the version number.
|
|
115
|
-
|
|
116
|
-
The unreleased section describes the **net result** compared to the last release, not a history of intermediate steps. When a later change supersedes an earlier one, update or remove the stale bullet — don't accumulate entries that no longer reflect reality.
|
|
117
|
-
|
|
118
|
-
## Documentation Style
|
|
119
|
-
|
|
120
|
-
All classes and methods must have YARD documentation. Follow these conventions:
|
|
121
|
-
|
|
122
|
-
- Always leave a **blank line** between the main description and `@` attributes (params, return, etc.)
|
|
123
|
-
- Document all public methods with description, params, and return types
|
|
124
|
-
- Document all private methods with params and return types, add description for complex logic
|
|
125
|
-
- Include `@example` blocks for non-obvious usage patterns
|
|
126
|
-
- **Omit descriptions that just repeat the code** — if the method name and signature make it obvious, only include `@param`, `@return` tags without a description
|
|
127
|
-
|
|
128
|
-
## Releasing a New Version
|
|
129
|
-
|
|
130
|
-
This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html):
|
|
131
|
-
- **MAJOR** — breaking changes (incompatible API changes)
|
|
132
|
-
- **MINOR** — new features (backwards-compatible)
|
|
133
|
-
- **PATCH** — bug fixes (backwards-compatible)
|
|
134
|
-
|
|
135
|
-
1. Update `lib/clsx/version.rb` with the new version number
|
|
136
|
-
2. Update `CHANGELOG.md`: change `## Unreleased` to `## vX.Y.Z` and add new empty `## Unreleased` section
|
|
137
|
-
3. Commit changes: `chore: bump version to X.Y.Z`
|
|
138
|
-
4. Release: `bundle exec rake release`
|