clsx-ruby 1.0.0 → 1.1.1

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: b9a68c03880d32a04dc1c721e0ee3009ac8a276f906da830e4866e2afae6ce92
4
- data.tar.gz: 24155294bbdca977a8358ef7f6296febfdc86c2ca61d4faaf5b7e49b7f6d0d2a
3
+ metadata.gz: 043652e6d2bc00c77d0409b45cb15f361cd1fb417af14db62892be42ed2a1815
4
+ data.tar.gz: dbacda2ec82ed045800345914fec73339d1dfee64df87e33305a5a8b5cf7c118
5
5
  SHA512:
6
- metadata.gz: 34de1db1360cae0f43ac95c1c5e1cb65d53294a313d5ad232afd79fe2b785e9ffab14f5b6bec527058f0cacc13354a000db1ea3479f43f0f6e17e5240a245c90
7
- data.tar.gz: 4236efda398f4977ebb215ef492fd3965eff434efe9a8bccb9da4fb06023bd0bf8f32c02fabc962129a0fce7cbae34950c7936e0b3334e5da48219ddb15dc7ea
6
+ metadata.gz: 26c9443192380c6b8415923df85df04d78add5cead6a676d78739410261cbeb277f8e6c4856b15761355dd61becfc09ad6aec7870d530f78bb5d3fcfbd358893
7
+ data.tar.gz: 919462b5126de045d8a2b1d63ef5e3da4fb04156ef62c9c3e4985922d05dd041997fcbc473f1aab7495d61914e87b09cdf88b2f1448f0fb80b8c3c3006115763
data/CHANGELOG.md CHANGED
@@ -1,7 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## Unreleased
9
+
10
+ ## v1.1.1
11
+
12
+ ### Added
13
+
14
+ - YARD documentation to all public and private methods
15
+ - Rails 8.1.2 `class_names` as a third benchmark competitor
16
+ - ViewComponent and Phlex examples in README
17
+ - Memory and profiling benchmark scripts (`benchmark/memory.rb`, `benchmark/profile.rb`, `benchmark/stackprof_run.rb`)
18
+
19
+ ### Changed
20
+
21
+ - Replaced `class ==` type checks with idiomatic `is_a?` early returns
22
+ - Extracted `clsx_single` method for clearer single-argument dispatch
23
+ - Simplified `Cn` alias from wrapper module to `Cn = Clsx`
24
+ - Removed redundant `seen.key?` guards in `clsx_process`
25
+ - Reduced allocations in `clsx_single`: pass arrays directly to `clsx_process` instead of wrapping, handle edge types inline
26
+ - Moved inline rubocop disables to `.rubocop.yml` config
27
+ - Updated benchmark baseline to compare against previous version, not ancient one
28
+ - Rewrote README with benchmark numbers and feature comparison table
29
+ - Process hash keys inline in `clsx_process` instead of deferring to a second pass (+23% mixed, +13% complex). Hash keys now appear in declaration order, matching JS clsx behavior.
30
+
31
+ ## v1.1.0
32
+
33
+ ### Added
34
+
35
+ - New fast path for `clsx('base', active: cond)` string + hash pattern (+69%)
36
+ - `string + hash` benchmark scenario
37
+
38
+ ### Changed
39
+
40
+ - Optimized hash-only path: skip dedup Hash since hash keys are unique by definition (+8%)
41
+ - Use `str.dup` instead of `String.new(str)` for buffer init (+12-18% on hash paths)
42
+
3
43
  ## v1.0.0
4
44
 
45
+ ### Added
46
+
5
47
  - Initial release as standalone framework-agnostic gem
6
48
  - Extracted from clsx-rails v2.0.0
7
49
  - API: `Clsx['foo', bar: true]`, `Cn['foo', bar: true]`, `include Clsx::Helper`
data/CLAUDE.md CHANGED
@@ -61,6 +61,78 @@ The helper uses an optimized algorithm with fast-paths for common cases (single
61
61
  - Ignores `Proc`/lambda objects and boolean `true` values
62
62
  - Supports complex hash keys like `{ %w[foo bar] => true }` which resolve recursively
63
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
+
64
70
  ## Commit Convention
65
71
 
66
- Uses [Conventional Commits](https://www.conventionalcommits.org/): `feat`, `fix`, `perf`, `chore`, `docs`, `refactor`
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`
data/README.md CHANGED
@@ -1,16 +1,10 @@
1
- # clsx-ruby [![Gem Version](https://img.shields.io/gem/v/clsx-ruby)](https://rubygems.org/gems/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)
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
- > A tiny, framework-agnostic utility for constructing CSS class strings conditionally.
3
+ > The fastest Ruby utility for constructing CSS class strings conditionally. Perfect for Tailwind CSS utility classes. Zero dependencies.
4
4
 
5
- Ruby port of the JavaScript [clsx](https://github.com/lukeed/clsx) package. Works with Rails, Sinatra, Hanami, or plain Ruby.
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
6
 
7
- For automatic Rails view helper integration, see [clsx-rails](https://github.com/svyatov/clsx-rails).
8
-
9
- ## Requirements
10
-
11
- Ruby 3.2+. No runtime dependencies.
12
-
13
- ## Installation
7
+ ## Quick Start
14
8
 
15
9
  ```bash
16
10
  bundle add clsx-ruby
@@ -19,9 +13,51 @@ bundle add clsx-ruby
19
13
  Or add it manually to the Gemfile:
20
14
 
21
15
  ```ruby
22
- gem 'clsx-ruby', '~> 1.0'
16
+ gem 'clsx-ruby', '~> 1.1'
17
+ ```
18
+
19
+ Then use it:
20
+
21
+ ```ruby
22
+ require 'clsx'
23
+
24
+ Clsx['btn', 'btn-primary', active: is_active, disabled: is_disabled]
25
+ # => "btn btn-primary active" (when is_active is truthy, is_disabled is falsy)
23
26
  ```
24
27
 
28
+ ## Why clsx-ruby?
29
+
30
+ ### Blazing fast
31
+
32
+ **3–8x faster** than Rails `class_names` across every scenario:
33
+
34
+ | Scenario | clsx-ruby | Rails `class_names` | Speedup |
35
+ |---|---|---|---|
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** |
42
+
43
+ <sup>Ruby 4.0.1, Apple M1 Pro. Reproduce: `bundle exec ruby benchmark/run.rb`</sup>
44
+
45
+ ### More feature-rich than `class_names`
46
+
47
+ | Feature | clsx-ruby | Rails `class_names` |
48
+ |---|---|---|
49
+ | Conditional classes | ✅ | ✅ |
50
+ | Auto-deduplication | ✅ | ✅ |
51
+ | 3–8× faster | ✅ | ❌ |
52
+ | Returns `nil` when empty | ✅ | ❌ (returns `""`) |
53
+ | Complex hash keys | ✅ | ❌ |
54
+ | Framework-agnostic | ✅ | ❌ |
55
+ | Zero dependencies | ✅ | ❌ |
56
+
57
+ ### Tiny footprint
58
+
59
+ ~100 lines of code. Zero runtime dependencies. Ruby 3.2+.
60
+
25
61
  ## Usage
26
62
 
27
63
  ### Bracket API (recommended)
@@ -93,63 +129,131 @@ Clsx[['foo', nil, false, 'bar']]
93
129
  Clsx[['foo'], ['', nil, false, 'bar'], [['baz', [['hello'], 'there']]]]
94
130
  # => 'foo bar baz hello there'
95
131
 
132
+ # Symbols
133
+ Clsx[:foo, :'bar-baz']
134
+ # => 'foo bar-baz'
135
+
136
+ # Numbers
137
+ Clsx[1, 2, 3]
138
+ # => '1 2 3'
139
+
96
140
  # Kitchen sink (with nesting)
97
141
  Clsx['foo', ['bar', { baz: false, bat: nil }, ['hello', ['world']]], 'cya']
98
142
  # => 'foo bar hello world cya'
99
143
  ```
100
144
 
101
- ### Framework examples
145
+ ## Framework Examples
146
+
147
+ ### Rails
102
148
 
103
149
  ```erb
104
- <%# Rails %>
105
150
  <%= tag.div class: Clsx['foo', 'baz', 'is-active': @active] do %>
106
151
  Hello, world!
107
152
  <% end %>
108
153
  ```
109
154
 
155
+ ### Sinatra
156
+
110
157
  ```ruby
111
- # Sinatra
112
158
  erb :"<div class='#{Clsx['nav', active: @active]}'>...</div>"
113
159
  ```
114
160
 
161
+ ### ViewComponent
162
+
163
+ ```ruby
164
+ class AlertComponent < ViewComponent::Base
165
+ include Clsx::Helper
166
+
167
+ def initialize(variant: :info, dismissible: false)
168
+ @variant = variant
169
+ @dismissible = dismissible
170
+ end
171
+
172
+ def classes
173
+ clsx("alert", "alert-#{@variant}", dismissible: @dismissible)
174
+ end
175
+ end
176
+ ```
177
+
178
+ ```erb
179
+ <div class="<%= classes %>">...</div>
180
+ ```
181
+
182
+ ### Tailwind CSS
183
+
184
+ ```ruby
185
+ class NavLink < ViewComponent::Base
186
+ include Clsx::Helper
187
+
188
+ def initialize(active: false)
189
+ @active = active
190
+ end
191
+
192
+ def classes
193
+ clsx(
194
+ 'px-3 py-2 rounded-md text-sm font-medium transition-colors',
195
+ 'text-white bg-indigo-600': @active,
196
+ 'text-gray-300 hover:text-white hover:bg-gray-700': !@active
197
+ )
198
+ end
199
+ end
200
+ ```
201
+
202
+ ### Phlex
203
+
204
+ ```ruby
205
+ class Badge < Phlex::HTML
206
+ include Clsx::Helper
207
+
208
+ def initialize(color: :blue, pill: false)
209
+ @color = color
210
+ @pill = pill
211
+ end
212
+
213
+ def view_template
214
+ span(class: clsx("badge", "badge-#{@color}", pill: @pill)) { yield }
215
+ end
216
+ end
217
+ ```
218
+
115
219
  ## Differences from JavaScript clsx
116
220
 
117
- 1. **Falsy values** In Ruby only `false` and `nil` are falsy, so `0`, `''`, `[]`, `{}` are all truthy:
221
+ 1. **Returns `nil`** when no classes apply (not an empty string). This prevents rendering empty `class=""` attributes in template engines that skip `nil`:
118
222
  ```ruby
119
- Clsx['foo' => 0, bar: []] # => 'foo bar'
223
+ Clsx[nil, false] # => nil
120
224
  ```
121
225
 
122
- 2. **Complex hash keys** — Any valid `clsx` input works as a hash key:
226
+ 2. **Deduplication** — Duplicate classes are automatically removed:
123
227
  ```ruby
124
- Clsx[[{ foo: true }, 'bar'] => true] # => 'foo bar'
228
+ Clsx['foo', 'foo'] # => 'foo'
125
229
  ```
126
230
 
127
- 3. **Ignored values** — Boolean `true` and `Proc`/lambda objects are silently ignored:
231
+ 3. **Falsy values** — In Ruby only `false` and `nil` are falsy, so `0`, `''`, `[]`, `{}` are all truthy:
128
232
  ```ruby
129
- Clsx['', proc {}, -> {}, nil, false, true] # => nil
233
+ Clsx['foo' => 0, bar: []] # => 'foo bar'
130
234
  ```
131
235
 
132
- 4. **Returns `nil`** when no classes apply (not an empty string). This prevents rendering empty `class=""` attributes in template engines that skip `nil`:
236
+ 4. **Complex hash keys** Any valid `clsx` input works as a hash key:
133
237
  ```ruby
134
- Clsx[nil, false] # => nil
238
+ Clsx[[{ foo: true }, 'bar'] => true] # => 'foo bar'
135
239
  ```
136
240
 
137
- 5. **Deduplication** — Duplicate classes are automatically removed:
241
+ 5. **Ignored values** — Boolean `true` and `Proc`/lambda objects are silently ignored:
138
242
  ```ruby
139
- Clsx['foo', 'foo'] # => 'foo'
243
+ Clsx['', proc {}, -> {}, nil, false, true] # => nil
140
244
  ```
141
245
 
142
- ## Development
143
-
144
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt.
145
-
146
- There is a benchmark suite in the `benchmark` directory. Run it with `bundle exec ruby benchmark/run.rb`.
246
+ ## Rails Integration
147
247
 
148
- ## Conventional Commits
248
+ For automatic Rails view helper integration (adds `clsx` and `cn` helpers to all views), see [clsx-rails](https://github.com/svyatov/clsx-rails).
149
249
 
150
- This project uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages.
250
+ ## Development
151
251
 
152
- Types: `feat`, `fix`, `perf`, `chore`, `docs`, `refactor`
252
+ ```bash
253
+ bin/setup # install dependencies
254
+ bundle exec rake test # run tests
255
+ bundle exec ruby benchmark/run.rb # run benchmarks
256
+ ```
153
257
 
154
258
  ## Contributing
155
259
 
data/lib/clsx/helper.rb CHANGED
@@ -1,120 +1,169 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :nodoc:
4
3
  module Clsx
5
- # :nodoc:
4
+ # Mixin providing {#clsx} and {#cn} instance methods.
5
+ #
6
+ # @example
7
+ # include Clsx::Helper
8
+ # clsx('btn', active: @active) # => "btn active"
6
9
  module Helper
7
- # The clsx function can take any number of arguments,
8
- # each of which can be Hash, Array, Boolean, String, or Symbol.
10
+ # Build a CSS class string from an arbitrary mix of arguments.
9
11
  #
10
- # **Important**
11
- # Any falsy values are discarded! Standalone Boolean values are discarded as well.
12
+ # Falsy values (+nil+, +false+) and standalone +true+ are discarded.
13
+ # Duplicates are eliminated. Returns +nil+ (not +""+) when no classes apply.
12
14
  #
13
- # @param [Mixed] args
15
+ # @param args [String, Symbol, Hash, Array, Numeric, nil, false] class descriptors
16
+ # to merge into a single space-separated string
17
+ # @return [String] space-joined class string
18
+ # @return [nil] when no classes apply
14
19
  #
15
- # @return [String] the joined class names
20
+ # @example Strings and hashes
21
+ # clsx('foo', 'bar') # => "foo bar"
22
+ # clsx(foo: true, bar: false, baz: true) # => "foo baz"
16
23
  #
17
- # @example
18
- # clsx('foo', 'bar') # => 'foo bar'
19
- # clsx(true, { bar: true }) # => 'bar'
20
- # clsx('foo', { bar: false }) # => 'foo'
21
- # clsx({ bar: true }, 'baz', { bat: false }) # => 'bar baz'
22
- #
23
- # @example within a view
24
- # <div class="<%= clsx('foo', 'bar') %>">
25
- # <div class="<%= clsx('foo', active: @is_active, 'another-class' => @condition) %>">
26
- # <%= tag.div class: clsx(%w[foo bar], hidden: @condition) do ... end %>
27
- #
28
- # @note Implementation prioritizes performance over readability.
29
- # Direct class comparisons and explicit conditionals are used
30
- # instead of more idiomatic Ruby patterns for speed.
31
-
32
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
24
+ # @example Nested arrays
25
+ # clsx('a', ['b', nil, ['c']]) # => "a b c"
26
+ # clsx(%w[foo bar], hidden: true) # => "foo bar hidden"
33
27
  def clsx(*args)
34
28
  return nil if args.empty?
35
-
36
- # Fast path: single argument (most common cases)
37
- if args.size == 1
38
- arg = args[0]
39
- klass = arg.class
40
-
41
- if klass == String
42
- return arg.empty? ? nil : arg
43
- elsif klass == Symbol
44
- return arg.name
45
- elsif klass == Array && arg.all?(String)
46
- seen = {}
47
- arg.each { |s| seen[s] = true unless s.empty? || seen.key?(s) }
48
- return seen.empty? ? nil : seen.keys.join(' ')
49
- elsif klass == Hash
50
- return clsx_simple_hash(arg)
51
- end
52
- end
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)
53
31
 
54
32
  seen = {}
55
33
  clsx_process(args, seen)
56
34
  seen.empty? ? nil : seen.keys.join(' ')
57
35
  end
58
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
59
36
 
37
+ # (see #clsx)
60
38
  alias cn clsx
61
39
 
62
40
  private
63
41
 
64
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
42
+ # Single-argument fast path dispatches by type to avoid allocating
43
+ # a +seen+ hash when possible.
44
+ #
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)
52
+
53
+ if arg.is_a?(Array)
54
+ return nil if arg.empty?
55
+
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
+ seen = {}
63
+ clsx_process(arg, seen)
64
+ return seen.empty? ? nil : seen.keys.join(' ')
65
+ end
66
+
67
+ return arg.to_s if arg.is_a?(Numeric)
68
+ return nil if !arg || arg == true || arg.is_a?(Proc)
69
+
70
+ str = arg.to_s
71
+ str.empty? ? nil : str
72
+ end
73
+
74
+ # Hash-only fast path — no dedup needed since hash keys are unique.
75
+ # Falls back to {#clsx_process} on non-String/Symbol keys.
76
+ #
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
65
80
  def clsx_simple_hash(hash)
66
81
  return nil if hash.empty?
67
82
 
68
- seen = {}
83
+ buf = nil
69
84
  hash.each do |key, value|
70
85
  next unless value
71
86
 
72
- klass = key.class
87
+ if key.is_a?(Symbol)
88
+ str = key.name
89
+ buf ? (buf << ' ' << str) : (buf = str.dup)
90
+ elsif key.is_a?(String)
91
+ next if key.empty?
73
92
 
74
- if klass == Symbol
75
- seen[key.name] = true
76
- elsif klass == String
77
- seen[key] = true unless key.empty?
93
+ buf ? (buf << ' ' << key) : (buf = key.dup)
78
94
  else
79
- # Complex key - fall back to full processing
80
95
  seen = {}
81
96
  clsx_process([hash], seen)
82
97
  return seen.empty? ? nil : seen.keys.join(' ')
83
98
  end
84
99
  end
100
+ buf
101
+ end
85
102
 
86
- seen.empty? ? nil : seen.keys.join(' ')
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.
105
+ #
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?
112
+
113
+ buf = str.dup
114
+ hash.each do |key, value|
115
+ next unless value
116
+
117
+ if key.is_a?(Symbol)
118
+ s = key.name
119
+ buf << ' ' << s unless s == str
120
+ elsif key.is_a?(String)
121
+ buf << ' ' << key unless key.empty? || key == str
122
+ else
123
+ seen = { str => true }
124
+ clsx_process([hash], seen)
125
+ return seen.size == 1 ? str : seen.keys.join(' ')
126
+ end
127
+ end
128
+ buf
87
129
  end
88
130
 
89
- # rubocop:disable Style/MultipleComparison
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.
134
+ #
135
+ # @param args [Array<String, Symbol, Hash, Array, Numeric, nil, false>] nested arguments
136
+ # @param seen [Hash{String => true}] accumulator for deduplication
137
+ # @return [void]
90
138
  def clsx_process(args, seen)
91
- deferred = nil
92
-
93
139
  args.each do |arg|
94
- klass = arg.class
95
-
96
- if klass == String
97
- seen[arg] = true unless arg.empty? || seen.key?(arg)
98
- elsif klass == Symbol
99
- str = arg.name
100
- seen[str] = true unless seen.key?(str)
101
- elsif klass == Array
140
+ if arg.is_a?(String)
141
+ seen[arg] = true unless arg.empty?
142
+ elsif arg.is_a?(Symbol)
143
+ seen[arg.name] = true
144
+ elsif arg.is_a?(Array)
102
145
  clsx_process(arg, seen)
103
- elsif klass == Hash
104
- arg.each { |key, value| (deferred ||= []) << key if value }
105
- elsif klass == Integer || klass == Float
106
- str = arg.to_s
107
- seen[str] = true unless seen.key?(str)
108
- elsif klass == NilClass || klass == FalseClass || klass == TrueClass || klass == Proc
146
+ 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
160
+ elsif !arg || arg == true || arg.is_a?(Proc)
109
161
  next
110
162
  else
111
163
  str = arg.to_s
112
- seen[str] = true unless str.empty? || seen.key?(str)
164
+ seen[str] = true unless str.empty?
113
165
  end
114
166
  end
115
-
116
- clsx_process(deferred, seen) if deferred
117
167
  end
118
- # rubocop:enable Style/MultipleComparison, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
119
168
  end
120
169
  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.0.0'
4
+ VERSION = '1.1.1'
5
5
  end
data/lib/clsx.rb CHANGED
@@ -3,21 +3,21 @@
3
3
  require_relative 'clsx/version'
4
4
  require_relative 'clsx/helper'
5
5
 
6
- # :nodoc:
6
+ # Construct CSS class strings conditionally.
7
+ # Ruby port of the JavaScript {https://github.com/lukeed/clsx clsx} package.
8
+ #
9
+ # @example
10
+ # Clsx['foo', 'bar'] # => "foo bar"
11
+ # Clsx['btn', active: true] # => "btn active"
12
+ # Cn['hidden', visible: false] # => "hidden"
7
13
  module Clsx
8
14
  extend Helper
9
15
 
16
+ # (see Helper#clsx)
10
17
  def self.[](*)
11
18
  clsx(*)
12
19
  end
13
20
  end
14
21
 
15
- # Short alias — only defined if `Cn` is not already taken
16
- unless Object.const_defined?(:Cn)
17
- # :nodoc:
18
- module Cn
19
- def self.[](*)
20
- Clsx[*]
21
- end
22
- end
23
- end
22
+ # Short alias — only defined if +Cn+ is not already taken.
23
+ Cn = Clsx unless Object.const_defined?(:Cn)
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.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov
@@ -44,7 +44,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
44
44
  - !ruby/object:Gem::Version
45
45
  version: '0'
46
46
  requirements: []
47
- rubygems_version: 4.0.4
47
+ rubygems_version: 4.0.6
48
48
  specification_version: 4
49
49
  summary: clsx / classnames for Ruby
50
50
  test_files: []