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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 043652e6d2bc00c77d0409b45cb15f361cd1fb417af14db62892be42ed2a1815
4
- data.tar.gz: dbacda2ec82ed045800345914fec73339d1dfee64df87e33305a5a8b5cf7c118
3
+ metadata.gz: 7af7dbc1bf63dae86a10afc293087f0528b82d479e0f4cc2e82a6944c79c8ed4
4
+ data.tar.gz: 2ed149e30e3da918e403697ea511ca597c6bece1bbb2965ae6dd02e0d054a4b8
5
5
  SHA512:
6
- metadata.gz: 26c9443192380c6b8415923df85df04d78add5cead6a676d78739410261cbeb277f8e6c4856b15761355dd61becfc09ad6aec7870d530f78bb5d3fcfbd358893
7
- data.tar.gz: 919462b5126de045d8a2b1d63ef5e3da4fb04156ef62c9c3e4985922d05dd041997fcbc473f1aab7495d61914e87b09cdf88b2f1448f0fb80b8c3c3006115763
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 [![Gem Version](https://img.shields.io/gem/v/clsx-ruby)](https://rubygems.org/gems/clsx-ruby) [![Codecov](https://img.shields.io/codecov/c/github/svyatov/clsx-ruby)](https://app.codecov.io/gh/svyatov/clsx-ruby) [![CI](https://github.com/svyatov/clsx-ruby/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/svyatov/clsx-ruby/actions?query=workflow%3ACI) [![GitHub License](https://img.shields.io/github/license/svyatov/clsx-ruby)](LICENSE.txt)
2
2
 
3
- > The fastest Ruby utility for constructing CSS class strings conditionally. Perfect for Tailwind CSS utility classes. Zero dependencies.
3
+ > The fastest, framework-agnostic conditional CSS class builder for Ruby.
4
+ > Perfect for ViewComponent, Phlex, Tailwind CSS or just standalone.
4
5
 
5
- Ruby port of the JavaScript [clsx](https://github.com/lukeed/clsx) package — a faster, smarter alternative to Rails `class_names`. Works with Rails, Sinatra, Hanami, or plain Ruby.
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
- **3–8x faster** than Rails `class_names` across every scenario:
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
- | Single string | 7.6M i/s | 911K i/s | **8.5x** |
37
- | String + hash | 2.4M i/s | 580K i/s | **4.1x** |
38
- | String array | 1.4M i/s | 357K i/s | **4.0x** |
39
- | Multiple strings | 1.5M i/s | 414K i/s | **3.7x** |
40
- | Hash | 2.2M i/s | 670K i/s | **3.3x** |
41
- | 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** |
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–8× faster | ✅ | ❌ |
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'] # => '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 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.1'
4
+ VERSION = '1.1.3'
5
5
  end
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.1
4
+ version: 1.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: A tiny utility for constructing CSS class strings conditionally
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: clsx / classnames for Ruby
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`