view_component_css_dsl 0.1.2 → 0.1.4

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: eba29c79b834ffc0651fc3ff6b244e860de04218b6b29ca31df90634211fc6a6
4
- data.tar.gz: fb7183fa91dc0bb773d61dc0953df0cf17fed601d39609cc1b2e2d44da725f51
3
+ metadata.gz: 494769078a576e9a1f7d3be9b2819ed2c93bf148da58154c68356c01410d9e8f
4
+ data.tar.gz: 529f35969651591c73f2c21012ab828c84305f94df5317f612fc4dc13b75cc39
5
5
  SHA512:
6
- metadata.gz: d28bfc76e89df1e4899fa396f0d45be296bbef32bed288d4893f908ad9038f28bc3f3e444675acf1408e01859f2cd6b007cb4fc346bad8389377765768952ff9
7
- data.tar.gz: 9353657fc812e95881ec400152852886d05f2292f978d590dc0475abb64cb87dfe61ba38c1cde972d8c2c6f22eb74d0e88bd2f2c632455d1caa5d368f6d230e4
6
+ metadata.gz: 365ce4701b070176cfda8d7a7dcb8005011326d3b888315ce0d37ba80cd2026bc8b5e3dcf8833747911e1ad5e5ca4d752ec28cf6dfa6b058240b847950bd1ca3
7
+ data.tar.gz: fc2ff970cc016c1fe8c788c622af0894c8d0c701afa9861fc40bc34c7926c3d2c19912c3d51287cf43da6d68c826fb4f4163e75ccb1714b874fb7c0621ff48ac
data/CHANGELOG.md CHANGED
@@ -2,15 +2,18 @@
2
2
 
3
3
 
4
4
 
5
- ## [0.1.2] - 2026-05-15
5
+ ## [0.1.4] - 2026-06-04
6
6
 
7
7
  ### Added
8
8
 
9
- - data, aria, and attribute DSL declarators for first-class HTML attribute declarations (cf37050)
9
+ - ViewComponentCssDsl::Verifier six static checks for component declarations: Tailwind class validity (via a compiled-CSS oracle), self-conflicting declarations, method-rule resolution, axis settability, variant-matrix smoke, and template html_attrs splat coverage (4be2024)
10
10
 
11
- ## [0.1.1] - 2026-05-15
11
+ ## [0.1.3] - 2026-05-18
12
12
 
13
13
  ### Changed
14
14
 
15
- - `view_component` dependency pinned to `~> 4.0` (was `>= 4.0`) RubyGems-recommended SemVer-aware constraint
16
- - Minimum Ruby version raised to 3.2 (was 3.1), matching the floor for `view_component >= 4.0`
15
+ - `smart_merge` now delegates to the `tailwind_merge` gem. The public API is unchanged, but conflict resolution now matches upstream tailwind-merge semantics across every Tailwind utility group (previously only spacing, sizing, colors, display, justify, align, font-weight, rounded, and position were handled — shadow, ring, gap, space, divide, z, opacity, leading, tracking, transition, transform, blur, etc. now merge correctly too).
16
+
17
+ ### Breaking
18
+
19
+ - `hidden` is now treated as part of the display-class conflict group. Strings like `"flex hidden"` collapse to `"hidden"` rather than keeping both. Previous behavior was non-standard — `tailwind-merge` (JS) and `tailwind_merge` (Ruby) have always treated `hidden` as a display utility. For JS-toggle visibility patterns, use the HTML5 `hidden` attribute (e.g., `attribute hidden: -> { … }` or pass `hidden: true` as an html_attr) and toggle it with `element.toggleAttribute('hidden')` instead of relying on the class merger.
data/README.md CHANGED
@@ -47,6 +47,10 @@ class ButtonComponent < ViewComponent::Base
47
47
  when :danger then "bg-red-500 text-white"
48
48
  end
49
49
  end
50
+
51
+ def data_attrs
52
+ {variant: @variant, controller: "button-component"}
53
+ end
50
54
  end
51
55
  ```
52
56
 
@@ -59,6 +63,8 @@ class ButtonComponent < ApplicationComponent
59
63
  css variant: :danger, style: "bg-red-500 text-white"
60
64
  css :disabled?, style: "opacity-50"
61
65
 
66
+ data variant: :variant, controller: "button-component"
67
+
62
68
  def initialize(variant: :primary, disabled: false)
63
69
  @variant = variant
64
70
  @disabled = disabled
@@ -66,6 +72,7 @@ class ButtonComponent < ApplicationComponent
66
72
 
67
73
  private
68
74
 
75
+ attr_reader :variant
69
76
  def disabled? = @disabled
70
77
  end
71
78
  ```
@@ -73,6 +80,7 @@ end
73
80
  - Variant validation is automatic; passing `:unknown` raises an `ArgumentError`.
74
81
  - Declarations are easy to scan, easy to extend.
75
82
  - A caller's `class: "..."` is smart-merged with the component's defaults: `bg-black` from the caller wins over the component's `bg-blue-500`, but `rounded` and `px-4` stick.
83
+ - Data attributes get the same declarative treatment — see [Declaring data, aria, and HTML attributes](#declaring-data-aria-and-html-attributes) below for the full pattern.
76
84
 
77
85
  ## Philosophy
78
86
 
@@ -402,7 +410,7 @@ Renders:
402
410
 
403
411
  ## Smart merge behavior
404
412
 
405
- Smart-merge handles Tailwind's conventions so caller and component CSS can coexist sensibly. In every row below, the **Component** column is what the component declared via `css`, and the **Caller** column is what was passed in `class:` at the call site.
413
+ Smart-merge handles Tailwind's conventions so caller and component CSS can coexist sensibly. Under the hood it delegates to the [`tailwind_merge`](https://github.com/gjtorikian/tailwind_merge) gem, which mirrors [tailwind-merge](https://github.com/dcastil/tailwind-merge) (JS) semantics. In every row below, the **Component** column is what the component declared via `css`, and the **Caller** column is what was passed in `class:` at the call site.
406
414
 
407
415
  | Component | Caller | Final classes | Why |
408
416
  | --- | --- | --- | --- |
@@ -419,6 +427,19 @@ Smart-merge handles Tailwind's conventions so caller and component CSS can coexi
419
427
 
420
428
  Modifier prefixes (`hover:`, `md:`, `dark:`, `group/`, `peer-checked:`, `aria-*`, arbitrary `[…]` values, etc.) form their own merge namespace, so `hover:bg-blue-500` never conflicts with a base `bg-white`.
421
429
 
430
+ ### JS-toggle visibility — use the `hidden` attribute, not the class
431
+
432
+ `hidden` is treated as part of the display group, so `"flex hidden"` collapses to `"hidden"` (the same as upstream tailwind-merge). If you need to toggle visibility from JavaScript while preserving a base display class, use the HTML5 `hidden` attribute via the `attribute` DSL:
433
+
434
+ ```ruby
435
+ class PaneComponent < ApplicationComponent
436
+ css "block"
437
+ attribute hidden: -> { collapsed? }
438
+ end
439
+ ```
440
+
441
+ Then toggle from JS with `element.toggleAttribute('hidden')` or `element.hidden = true/false`. The class merger stays out of the way and the element retains its `block`/`flex`/etc. layout when shown.
442
+
422
443
  ## Inheritance
423
444
 
424
445
  A child component's `css "..."` declaration is smart-merged with its parent's:
@@ -437,6 +458,119 @@ end
437
458
 
438
459
  Axis, method, and proc rules are appended, not overridden.
439
460
 
461
+ ## Verifier
462
+
463
+ The DSL's worst failure modes are silent: a typo'd or hallucinated Tailwind class produces no CSS at all under JIT, a self-conflicting declaration quietly drops a class, and a rule referencing a missing method only raises at render time on the code path that hits it. `ViewComponentCssDsl::Verifier` catches all of these statically — fast enough to run on every edit.
464
+
465
+ `verify(component)` returns `Finding` structs (`component`, `check`, `severity`, `message`). Six checks run:
466
+
467
+ | Check | Asserts | Catches |
468
+ | --- | --- | --- |
469
+ | `class_validity` | Every declared class exists in the compiled Tailwind output | Typos, hallucinated classes, theme values that don't exist |
470
+ | `self_conflicts` | No declaration conflicts with itself | `css "block flex"` silently dropping `block` |
471
+ | `method_rules` | Every Symbol in `css`/`data`/`aria`/`attribute` rules resolves to a method | Render-time `NoMethodError`s |
472
+ | `axes_settable` | Every axis has an initialize param or `@ivar` assignment | Variant rules that can never fire |
473
+ | `variant_matrix` | `#css` builds cleanly for every axis-value combination | Anything the static checks miss, without rendering |
474
+ | `template_splat` | Every template references `html_attrs` | Components whose DSL output never reaches the DOM |
475
+
476
+ Notes:
477
+
478
+ - **Verify every class in the hierarchy**, abstract bases included. Declaration checks only inspect what each class itself declared, so a parent's mistakes are reported once — on the parent.
479
+ - `known_classes:` is anything responding to `include?(String)`. `CompiledCssOracle` parses class selectors out of a compiled Tailwind build; since Tailwind's JIT generates a rule for every valid class found in your content globs, a declared class missing from the output is invalid. Rebuild before verifying — the oracle is only as fresh as the build. Omit `known_classes:` to skip the check.
480
+ - `template_splat` covers sidecar files, inline templates (`erb_template "..."`), and hand-written `#call` methods.
481
+ - The variant matrix smoke-tests on bare (`allocate`d) instances. Method and proc rules that need `initialize` state report as warnings rather than errors.
482
+
483
+ ### Wiring it into your app
484
+
485
+ Three layers, from "can't forget" to "instant feedback". The spec is the only one you need — it makes violations fail CI with no one having to remember anything. The other two make the feedback faster.
486
+
487
+ #### 1. A spec — the floor
488
+
489
+ Add this once and every component, present and future, is verified on every test run. `descendants` discovers components dynamically, so new ones are covered the day they're written:
490
+
491
+ ```ruby
492
+ # spec/components/css_dsl_verification_spec.rb
493
+ require "rails_helper"
494
+ require "view_component_css_dsl/verifier"
495
+
496
+ RSpec.describe "CssDsl verification" do
497
+ it "has no verifier errors in any component" do
498
+ Rails.application.eager_load!
499
+
500
+ oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new(
501
+ Rails.root.join("app/assets/builds/tailwind.css")
502
+ )
503
+ verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle)
504
+ findings = ApplicationComponent.descendants.flat_map { |c| verifier.verify(c) }
505
+
506
+ errors = findings.select(&:error?)
507
+ expect(errors).to be_empty, errors.join("\n")
508
+ end
509
+ end
510
+ ```
511
+
512
+ Point the oracle at your compiled Tailwind output and make sure your CI builds it before running specs.
513
+
514
+ #### 2. `bin/verify-css-dsl` — the edit loop
515
+
516
+ Verifies just the components you're working on (or everything, with no args) without waiting for the suite:
517
+
518
+ ```ruby
519
+ #!/usr/bin/env ruby
520
+ # Usage: bin/verify-css-dsl [path/to/component.rb ...]
521
+ # With no args, verifies every component.
522
+
523
+ require_relative "../config/environment"
524
+ require "view_component_css_dsl/verifier"
525
+
526
+ Rails.application.eager_load!
527
+
528
+ components = if ARGV.any?
529
+ components_root = Rails.root.join("app/components")
530
+ ARGV.map do |path|
531
+ rel = Pathname.new(path).expand_path.relative_path_from(components_root)
532
+ rel.to_s.delete_suffix(".rb").camelize.constantize
533
+ end
534
+ else
535
+ ApplicationComponent.descendants
536
+ end
537
+
538
+ oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new(
539
+ Rails.root.join("app/assets/builds/tailwind.css")
540
+ )
541
+ verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle)
542
+
543
+ findings = components.flat_map { |component| verifier.verify(component) }
544
+ puts findings
545
+ exit 1 if findings.any?(&:error?)
546
+ ```
547
+
548
+ Make it executable with `chmod +x bin/verify-css-dsl`.
549
+
550
+ #### 3. A Claude Code hook — the agent loop
551
+
552
+ Runs the bin script automatically after Claude Code edits a component file and feeds any findings straight back to the model, so a hallucinated class is flagged the moment it's written rather than when the suite runs. In your project's `.claude/settings.json`:
553
+
554
+ ```json
555
+ {
556
+ "hooks": {
557
+ "PostToolUse": [
558
+ {
559
+ "matcher": "Edit|Write",
560
+ "hooks": [
561
+ {
562
+ "type": "command",
563
+ "command": "f=$(jq -r .tool_input.file_path); case \"$f\" in */app/components/*.rb) bin/verify-css-dsl \"$f\" >&2 || exit 2;; esac"
564
+ }
565
+ ]
566
+ }
567
+ ]
568
+ }
569
+ }
570
+ ```
571
+
572
+ Exit code 2 returns the findings to Claude as feedback to act on; clean edits stay silent. Note the hook boots your Rails app on each component edit — if that's slow, bootsnap helps.
573
+
440
574
  ## Development
441
575
 
442
576
  ```sh
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../verifier"
4
+
5
+ # The class-validity oracle for Verifier, built from a compiled Tailwind CSS file.
6
+ # Tailwind's JIT generates a rule for every valid class it finds in your content
7
+ # globs — so as long as your component .rb files are in those globs, the compiled
8
+ # output contains exactly the valid classes among those you declared. A declared
9
+ # class missing from the output is a typo, a hallucination, or a value your theme
10
+ # doesn't define.
11
+ #
12
+ # oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new(
13
+ # "app/assets/builds/tailwind.css"
14
+ # )
15
+ # oracle.include?("bg-blue-500") # => true
16
+ # oracle.include?("bg-blurple") # => false
17
+ #
18
+ # Caveat: the oracle is only as fresh as the build. Rebuild Tailwind before
19
+ # verifying, or a just-added valid class will be flagged as unknown.
20
+ class ViewComponentCssDsl::Verifier::CompiledCssOracle
21
+ # Class selector: a dot, then word chars / hyphens / CSS escapes. Tailwind
22
+ # escapes special chars with a backslash (`.hover\:bg-blue-500`) and leading
23
+ # digits as hex (`.\32 xl\:grid`).
24
+ CLASS_SELECTOR = /\.((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w-])+)/
25
+
26
+ def initialize(css_path)
27
+ @classes = parse(File.read(css_path))
28
+ end
29
+
30
+ def include?(class_name) = @classes.include?(class_name)
31
+
32
+ def size = @classes.size
33
+
34
+ private
35
+
36
+ def parse(css)
37
+ css.scan(CLASS_SELECTOR).map { |(selector)| unescape(selector) }.to_set
38
+ end
39
+
40
+ # Hex escapes first (`\32 ` -> "2"), then simple escapes (`\:` -> ":").
41
+ def unescape(selector)
42
+ selector
43
+ .gsub(/\\([0-9a-fA-F]{1,6})\s?/) { $1.hex.chr(Encoding::UTF_8) }
44
+ .gsub(/\\(.)/, '\1')
45
+ end
46
+ end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "view_component_css_dsl"
4
+
5
+ # Static checks over a component's DSL declarations. Catches the mistakes the DSL
6
+ # itself can't surface until render time (or surfaces silently). Designed to be
7
+ # fast enough to run on every edit.
8
+ #
9
+ # The six checks:
10
+ #
11
+ # class_validity - every declared class exists in the compiled Tailwind output;
12
+ # catches typos, hallucinated classes, and theme values that
13
+ # don't exist (requires known_classes:)
14
+ # self_conflicts - no declaration conflicts with itself; catches e.g.
15
+ # css "block flex" silently dropping "block"
16
+ # method_rules - every Symbol in css/data/aria/attribute rules resolves to a
17
+ # method; catches render-time NoMethodErrors
18
+ # axes_settable - every axis has an initialize param or @ivar assignment;
19
+ # catches variant rules that can never fire
20
+ # variant_matrix - #css builds cleanly for every axis-value combination;
21
+ # smoke-catches anything the static checks miss, no rendering
22
+ # template_splat - every template (sidecar, inline erb_template, or manual
23
+ # #call) references html_attrs; catches components whose DSL
24
+ # output never reaches the DOM
25
+ #
26
+ # verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle)
27
+ # findings = verifier.verify(ButtonComponent)
28
+ #
29
+ # Verify every class in the component hierarchy: declaration-shape checks
30
+ # (class validity, self-conflicts) only inspect declarations the class itself
31
+ # added, so a parent's declarations are checked on the parent, not re-reported
32
+ # on every child.
33
+ class ViewComponentCssDsl::Verifier
34
+ Finding = Struct.new(
35
+ :component, :check, :severity, :message, keyword_init: true
36
+ ) do
37
+ def to_s
38
+ "#{component.name || component.inspect} [#{check}] #{severity}: #{message}"
39
+ end
40
+
41
+ def error? = severity == :error
42
+ end
43
+
44
+ TEMPLATE_EXTENSIONS = %w[erb haml slim].freeze
45
+
46
+ # Safety cap for pathological axis cartesian products.
47
+ VARIANT_MATRIX_CAP = 256
48
+
49
+ # Source file of the DSL itself, for classifying smoke-test backtraces.
50
+ DSL_SOURCE_FILE = File.expand_path("../view_component_css_dsl.rb", __dir__)
51
+
52
+ # known_classes: anything responding to include?(String) — a Set, or a
53
+ # CompiledCssOracle built from your app's compiled Tailwind output. When nil,
54
+ # the class-validity check is skipped.
55
+ def initialize(known_classes: nil)
56
+ @known_classes = known_classes
57
+ end
58
+
59
+ def verify(component)
60
+ check_class_validity(component) +
61
+ check_self_conflicts(component) +
62
+ check_method_rules(component) +
63
+ check_axes_settable(component) +
64
+ check_variant_matrix(component) +
65
+ check_template_splat(component)
66
+ end
67
+
68
+ private
69
+
70
+ # Every statically-declared class must exist in the known-classes oracle.
71
+ # Hallucinated or typo'd classes produce no CSS at all under Tailwind's JIT —
72
+ # no error, no warning — so this is the check that catches them.
73
+ def check_class_validity(component)
74
+ return [] unless @known_classes
75
+
76
+ own_declared_styles(component).flat_map do |label, styles|
77
+ styles.split.reject { |cls| @known_classes.include?(cls) }.map do |cls|
78
+ finding(component, :class_validity, :error,
79
+ "#{label}: unknown class \"#{cls}\" (not in the compiled Tailwind output)")
80
+ end
81
+ end
82
+ end
83
+
84
+ # A single declaration whose classes conflict with each other (e.g. "block flex")
85
+ # is almost always a mistake — smart_merge silently keeps only the last.
86
+ def check_self_conflicts(component)
87
+ own_declared_styles(component).flat_map do |label, styles|
88
+ tokens = styles.split
89
+ survivors = component.smart_merge(styles).split
90
+ results = []
91
+
92
+ duplicates = tokens.tally.select { |_cls, count| count > 1 }.keys
93
+ if duplicates.any?
94
+ results << finding(component, :self_conflicts, :error,
95
+ "#{label}: duplicate class(es) #{quote_list(duplicates)}")
96
+ end
97
+
98
+ dropped = tokens.uniq - survivors
99
+ if dropped.any?
100
+ results << finding(component, :self_conflicts, :error,
101
+ "#{label}: #{quote_list(dropped)} conflict with later classes in the " \
102
+ "same declaration and would be silently dropped")
103
+ end
104
+
105
+ results
106
+ end
107
+ end
108
+
109
+ # Symbols in css/data/aria/attribute rules are method references; each must
110
+ # resolve on the component. The DSL only raises for these at render time.
111
+ def check_method_rules(component)
112
+ method_references(component).filter_map do |label, method_name|
113
+ next if resolves?(component, method_name)
114
+
115
+ finding(component, :method_rules, :error,
116
+ "#{label} references undefined method `#{method_name}`")
117
+ end
118
+ end
119
+
120
+ # Each axis with css rules must be settable: an initialize param of the same
121
+ # name, or an @ivar assignment somewhere in the component's source. An axis
122
+ # nothing sets means its rules can never fire.
123
+ def check_axes_settable(component)
124
+ component._css_defined_axes.keys.filter_map do |axis|
125
+ next if initialize_param?(component, axis)
126
+ next if ivar_assigned_in_source?(component, axis)
127
+
128
+ finding(component, :axes_settable, :error,
129
+ "axis :#{axis} has css rules, but initialize has no #{axis} param and " \
130
+ "no @#{axis} assignment was found")
131
+ end
132
+ end
133
+
134
+ # Smoke test: build the css string for every combination of axis values
135
+ # (including unset) on a bare instance. Exercises validate_axes!, the merge
136
+ # caches, and method/proc rules end-to-end without rendering.
137
+ def check_variant_matrix(component)
138
+ combos = axis_combinations(component)
139
+ findings = []
140
+
141
+ if combos.size > VARIANT_MATRIX_CAP
142
+ findings << finding(component, :variant_matrix, :warning,
143
+ "#{combos.size} axis combinations; only the first #{VARIANT_MATRIX_CAP} " \
144
+ "were smoke-tested")
145
+ combos = combos.first(VARIANT_MATRIX_CAP)
146
+ end
147
+
148
+ combos.each do |combo|
149
+ error = smoke_css(component, combo)
150
+ next unless error
151
+
152
+ findings << finding(component, :variant_matrix, smoke_severity(error),
153
+ "#{combo_label(combo)}: #{error.class}: #{error.message}")
154
+ end
155
+
156
+ findings
157
+ end
158
+
159
+ # Every template must reference html_attrs — that splat is what carries the
160
+ # DSL's classes and attributes to the DOM. Covers sidecar files, inline
161
+ # templates (erb_template "..."), and hand-written #call methods.
162
+ def check_template_splat(component)
163
+ sources = template_sources(component)
164
+ if sources.empty?
165
+ return [finding(component, :template_splat, :warning,
166
+ "no template found (no sidecar file, inline template, or #call method)")]
167
+ end
168
+
169
+ sources.filter_map do |label, source|
170
+ next if source.match?(/\bhtml_attrs\b/)
171
+
172
+ finding(component, :template_splat, :error,
173
+ "#{label} does not reference html_attrs; DSL classes and attributes " \
174
+ "will not reach the DOM")
175
+ end
176
+ end
177
+
178
+ ##################################################################################
179
+ # Declaration collection
180
+ ##################################################################################
181
+
182
+ # [label, styles] pairs for declarations this class itself added. Inherited
183
+ # declarations are checked on the ancestor that declared them.
184
+ def own_declared_styles(component)
185
+ parent = component.superclass
186
+ base = own_rules(component, parent, :_css_base_declarations)
187
+ .map { |styles| ["css \"#{styles}\"", styles] }
188
+ axis = own_rules(component, parent, :_css_axis_rules)
189
+ .map { |rule| [axis_label(rule[:axes]), rule[:styles]] }
190
+ methods = own_rules(component, parent, :_css_method_rules)
191
+ .map { |rule| ["css :#{rule[:method]}", rule[:styles]] }
192
+ base + axis + methods
193
+ end
194
+
195
+ def own_rules(component, parent, attr)
196
+ inherited = parent.respond_to?(attr) ? parent.public_send(attr) : []
197
+ component.public_send(attr) - inherited
198
+ end
199
+
200
+ def axis_label(axes) = "css #{axes.map { |k, v| "#{k}: :#{v}" }.join(", ")}"
201
+
202
+ # [label, method_name] for every Symbol the rules will send to the instance.
203
+ # Inherited rules are included: they run on this class, so they must resolve
204
+ # on this class (a child may legitimately define the method a parent's rule
205
+ # references).
206
+ def method_references(component)
207
+ refs = component._css_method_rules
208
+ .map { |rule| ["css :#{rule[:method]}", rule[:method]] }
209
+
210
+ {data: :_data_rules, aria: :_aria_rules, attribute: :_attribute_rules}
211
+ .each do |namespace, attr|
212
+ component.public_send(attr).each do |rule|
213
+ if rule[:predicate].is_a?(Symbol)
214
+ refs << ["#{namespace} :#{rule[:predicate]} predicate", rule[:predicate]]
215
+ end
216
+ rule[:attrs].each do |key, value|
217
+ refs << ["#{namespace} #{key}: :#{value}", value] if value.is_a?(Symbol)
218
+ end
219
+ end
220
+ end
221
+
222
+ refs
223
+ end
224
+
225
+ def resolves?(component, method_name)
226
+ component.method_defined?(method_name) ||
227
+ component.private_method_defined?(method_name)
228
+ end
229
+
230
+ ##################################################################################
231
+ # Axis settability
232
+ ##################################################################################
233
+
234
+ def initialize_param?(component, axis)
235
+ params = component.instance_method(:initialize).parameters
236
+ params.any? { |_type, name| name == axis }
237
+ end
238
+
239
+ def ivar_assigned_in_source?(component, axis)
240
+ source_paths(component).any? do |path|
241
+ File.read(path).match?(/@#{axis}\b\s*(\|\|)?=[^=~]/)
242
+ end
243
+ end
244
+
245
+ # Source files of the component and its DSL-including ancestors. Stops before
246
+ # ViewComponent::Base — framework internals aren't where axes get assigned.
247
+ def source_paths(component)
248
+ component.ancestors
249
+ .take_while { |mod| !view_component_base?(mod) }
250
+ .filter_map { |mod| mod.identifier if mod.respond_to?(:identifier) }
251
+ .uniq
252
+ .select { |path| File.exist?(path) }
253
+ end
254
+
255
+ def view_component_base?(mod)
256
+ defined?(ViewComponent::Base) && mod == ViewComponent::Base
257
+ end
258
+
259
+ ##################################################################################
260
+ # Variant matrix
261
+ ##################################################################################
262
+
263
+ # Cartesian product over defined axes, each axis taking every declared value
264
+ # plus nil (axis unset). [{}] when no axes — base/method/proc paths still get
265
+ # one smoke run.
266
+ def axis_combinations(component)
267
+ component._css_defined_axes.reduce([{}]) do |combos, (axis, values)|
268
+ combos.flat_map do |combo|
269
+ (values.to_a + [nil]).map { |value| combo.merge(axis => value) }
270
+ end
271
+ end
272
+ end
273
+
274
+ def smoke_css(component, combo)
275
+ instance = component.allocate
276
+ instance.instance_variable_set(:@html_attrs, {})
277
+ combo.each { |axis, value| instance.instance_variable_set(:"@#{axis}", value) }
278
+ instance.css
279
+ nil
280
+ rescue => error
281
+ error
282
+ end
283
+
284
+ # Errors raised inside the DSL are real failures. Errors raised in component
285
+ # code usually mean a method/proc rule needs initialize state a bare instance
286
+ # doesn't have — report those as warnings, not failures.
287
+ def smoke_severity(error)
288
+ first_frame = error.backtrace&.first.to_s
289
+ first_frame.start_with?("#{DSL_SOURCE_FILE}:") ? :error : :warning
290
+ end
291
+
292
+ def combo_label(combo)
293
+ return "with no axes set" if combo.compact.empty?
294
+
295
+ "with #{combo.compact.map { |axis, value| "#{axis}: :#{value}" }.join(", ")}"
296
+ end
297
+
298
+ ##################################################################################
299
+ # Templates
300
+ ##################################################################################
301
+
302
+ def template_sources(component)
303
+ sources = []
304
+
305
+ if component.respond_to?(:__vc_inline_template)
306
+ inline = component.__vc_inline_template
307
+ sources << ["inline template", inline.source] if inline
308
+ end
309
+
310
+ if component.respond_to?(:sidecar_files)
311
+ component.sidecar_files(TEMPLATE_EXTENSIONS).each do |path|
312
+ sources << [File.basename(path), File.read(path)]
313
+ end
314
+ end
315
+
316
+ if sources.empty? && (call_path = manual_call_path(component))
317
+ sources << ["#call (#{File.basename(call_path)})", File.read(call_path)]
318
+ end
319
+
320
+ sources
321
+ end
322
+
323
+ # Path of a hand-written #call, if the component (or a DSL ancestor) defines
324
+ # one. ViewComponent compiles templates into #call too, but only at render
325
+ # time — run the verifier in a fresh process and only manual ones exist.
326
+ def manual_call_path(component)
327
+ return nil unless component.method_defined?(:call)
328
+
329
+ owner = component.instance_method(:call).owner
330
+ return nil if !owner.is_a?(Class) || view_component_base?(owner)
331
+ return nil unless owner.respond_to?(:_css_base)
332
+
333
+ path = component.instance_method(:call).source_location&.first
334
+ path if path && File.exist?(path)
335
+ end
336
+
337
+ def finding(component, check, severity, message)
338
+ Finding.new(component:, check:, severity:, message:)
339
+ end
340
+
341
+ def quote_list(classes) = classes.map { |cls| "\"#{cls}\"" }.join(", ")
342
+ end
343
+
344
+ require_relative "verifier/compiled_css_oracle"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentCssDsl
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.4"
5
5
  end
@@ -6,12 +6,17 @@ require "active_support/core_ext/object/blank"
6
6
  require "active_support/core_ext/hash/except"
7
7
  require "active_support/core_ext/hash/slice"
8
8
  require "active_support/core_ext/object/inclusion"
9
+ require "tailwind_merge"
9
10
 
10
11
  require_relative "view_component_css_dsl/version"
11
12
 
12
13
  module ViewComponentCssDsl
13
14
  extend ActiveSupport::Concern
14
15
 
16
+ # Single shared merger. tailwind_merge builds a conflict-group index on
17
+ # initialization, so we pay that cost once per process rather than per call.
18
+ MERGER = TailwindMerge::Merger.new
19
+
15
20
  HTML_ATTR_KEYS = Set[
16
21
  :alt, :aria, :autofocus,
17
22
  :class, :colspan, :contenteditable,
@@ -29,99 +34,11 @@ module ViewComponentCssDsl
29
34
  :tabindex, :target, :title, :type, :value
30
35
  ].freeze
31
36
 
32
- # Single combined regex for padding/margin spacing (replaces 14 separate patterns)
33
- # Captures: type (p/m), axis (x/y/t/r/b/l or nil for all), value
34
- SPACING_REGEX = /\b(p|m)(x|y|t|r|b|l)?-(\d+)\b/
35
-
36
- # Maps axis character to Set of affected sides
37
- SPACING_AXIS_MAP = {
38
- nil => Set[:t, :r, :b, :l], # p-4, m-4 = all sides
39
- "x" => Set[:l, :r],
40
- "y" => Set[:t, :b],
41
- "t" => Set[:t],
42
- "r" => Set[:r],
43
- "b" => Set[:b],
44
- "l" => Set[:l]
45
- }.freeze
46
-
47
- # Border width patterns (kept separate due to anchoring requirements)
48
- BORDER_REGEX = /^border(?:-(x|y|t|r|b|l))?(?:-\d+)?$/
49
-
50
- # Other category patterns (non-spacing, simple override by category)
51
- # IMPORTANT: Use anchored patterns (^/$) to avoid matching substrings within
52
- # compound classes (e.g., `h-8` within `min-h-8`, `flex` within `inline-flex`)
53
- CATEGORIES = {
54
- background: /^bg-/,
55
- text_color: /^text-((\w+-\d+)|white|black|transparent|current|inherit|action|success|danger|warning|brand)(\/\d+)?$/,
56
- text_size: /^text-(xs|sm|base|lg|xl|\d*xl)$/,
57
- border_color: /^border-(?!t|r|b|l|x|y|\d)(\w+-\d+|\w+)(\/\d+)?$/,
58
- width: /^w-/,
59
- height: /^h-/,
60
- min_width: /^min-w-/,
61
- min_height: /^min-h-/,
62
- max_width: /^max-w-/,
63
- max_height: /^max-h-/,
64
- # Display classes - note: `hidden` is intentionally excluded because it's
65
- # commonly used as a visibility toggle alongside other display classes
66
- # (e.g., "inline-flex hidden" where JS removes "hidden" to show element)
67
- display: /^(block|inline-block|inline-flex|inline-grid|inline|flex|grid|table-cell|table-row|table|contents|flow-root|list-item)$/,
68
- justify: /^justify-/,
69
- align: /^items-/,
70
- font_weight: /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,
71
- rounded: /^rounded(-none|-sm|-md|-lg|-xl|-2xl|-3xl|-full)?$/,
72
- position: /^(static|relative|absolute|fixed|sticky)$/
73
- }.freeze
74
-
75
- # Known Tailwind modifiers (prefixes like hover:, md:, first:, etc.)
76
- # Classes with different modifiers should NOT conflict with each other
77
- KNOWN_MODIFIERS = Set[
78
- # Responsive
79
- "sm", "md", "lg", "xl", "2xl",
80
- "max-sm", "max-md", "max-lg", "max-xl", "max-2xl",
81
- # Interactive state
82
- "hover", "focus", "focus-within", "focus-visible", "active", "visited", "target",
83
- # Structural
84
- "first", "last", "only", "odd", "even",
85
- "first-of-type", "last-of-type", "only-of-type", "empty",
86
- # Form state
87
- "disabled", "enabled", "checked", "indeterminate", "default",
88
- "required", "valid", "invalid", "in-range", "out-of-range",
89
- "placeholder-shown", "autofill", "read-only",
90
- # Pseudo-elements
91
- "before", "after", "first-letter", "first-line",
92
- "marker", "selection", "file", "backdrop", "placeholder",
93
- # Media/Preference
94
- "dark", "print", "portrait", "landscape",
95
- "motion-safe", "motion-reduce", "contrast-more", "contrast-less",
96
- "forced-colors",
97
- # Direction
98
- "rtl", "ltr",
99
- # Attribute
100
- "open",
101
- # Direct children
102
- "*"
103
- ].freeze
104
-
105
- # Patterns that match dynamic modifiers (with optional names/arbitrary values)
106
- # These use flexible regex to match ANY valid Tailwind modifier syntax
107
- MODIFIER_PATTERNS = [
108
- /^group(?:\/\w+)?$/, # group, group/<any-name>
109
- /^group-\w+(?:\/\w+)?$/, # group-hover, group-<state>/<any-name>
110
- /^peer(?:\/\w+)?$/, # peer, peer/<any-name>
111
- /^peer-\w+(?:\/\w+)?$/, # peer-checked, peer-<state>/<any-name>
112
- /^aria-\w+$/, # aria-checked, aria-<any-attr>
113
- /^aria-\[.+\]$/, # aria-[<arbitrary>]
114
- /^data-\[.+\]$/, # data-[<arbitrary>]
115
- /^supports-\[.+\]$/, # supports-[<arbitrary>]
116
- /^has-\[.+\]$/, # has-[<arbitrary>]
117
- /^group-has-\[.+\]$/, # group-has-[<arbitrary>]
118
- /^peer-has-\[.+\]$/, # peer-has-[<arbitrary>]
119
- /^min-\[.+\]$/, # min-[<arbitrary>]
120
- /^max-\[.+\]$/ # max-[<arbitrary>]
121
- ].freeze
122
-
123
37
  included do
124
38
  class_attribute :_css_base, instance_writer: false, default: ""
39
+ # Base css strings as written, before inheritance merging. The merged _css_base
40
+ # is conflict-free by construction; the Verifier needs the raw declarations.
41
+ class_attribute :_css_base_declarations, instance_writer: false, default: []
125
42
  class_attribute :_css_axis_rules, instance_writer: false, default: []
126
43
  class_attribute :_css_method_rules, instance_writer: false, default: []
127
44
  class_attribute :_css_proc_rules, instance_writer: false, default: []
@@ -165,6 +82,7 @@ module ViewComponentCssDsl
165
82
  when String
166
83
  if options.empty?
167
84
  # Base CSS: css "rounded p-4"
85
+ self._css_base_declarations = _css_base_declarations + [args.first]
168
86
  parent_base_css = superclass.respond_to?(:_css_base) ? superclass._css_base : ""
169
87
  self._css_base = if parent_base_css.present?
170
88
  smart_merge(parent_base_css, args.first)
@@ -341,99 +259,20 @@ module ViewComponentCssDsl
341
259
  end
342
260
  end
343
261
 
344
- # Overwrites base css with custom css from the caller, but only if they actually
345
- # interfere with each other. Modifier prefixes (hover:, md:, first:, etc.) create
346
- # separate "namespaces" so they don't conflict with base classes.
262
+ # Merges Tailwind utility classes, resolving conflicts so the last-declared
263
+ # value wins. Delegates to the `tailwind_merge` gem, which mirrors
264
+ # tailwind-merge (JS) semantics and tracks Tailwind utility groups upstream.
265
+ #
347
266
  # Examples:
348
267
  # - base: "pt-2", custom: "pt-4", result => "pt-4"
349
268
  # - base: "pb-2", custom: "pt-4", result => "pb-2 pt-4"
350
269
  # - base: "pb-2", custom: "p-4", result => "p-4"
351
270
  # - base: "block", custom: "first:hidden", result => "block first:hidden"
352
271
  def smart_merge(*css_strings)
353
- categorized = {}
354
- uncategorized = []
355
- # Store spacing classes grouped by modifier prefix
356
- # {prefix => [{class: "p-4", info: {type: :padding, axes: Set[:t,:r,:b,:l]}}, ...]}
357
- spacing_by_prefix = Hash.new { |h, k| h[k] = [] }
358
-
359
- css_strings.compact.each do |str|
360
- str.to_s.split.each do |cls|
361
- prefix, base_class = extract_modifier_prefix(cls)
362
-
363
- spacing = spacing_info(base_class)
364
- if spacing
365
- # Remove any existing spacing classes that overlap on same type and axes
366
- # within the same modifier prefix
367
- spacing_by_prefix[prefix].reject! do |existing|
368
- existing[:info][:type] == spacing[:type] &&
369
- existing[:info][:axes].subset?(spacing[:axes])
370
- end
371
- spacing_by_prefix[prefix] << {class: cls, info: spacing}
372
- else
373
- category = detect_category(base_class)
374
- if category
375
- key = "#{prefix}:#{category}"
376
- categorized[key] = cls
377
- else
378
- uncategorized << cls unless uncategorized.include?(cls)
379
- end
380
- end
381
- end
382
- end
383
-
384
- spacing_classes = spacing_by_prefix.values.flatten.map { |s| s[:class] }
385
- (uncategorized + spacing_classes + categorized.values).join(" ")
386
- end
387
-
388
- def spacing_info(css_class)
389
- # Check padding/margin with single regex (replaces 14 pattern checks)
390
- if (match = css_class.match(SPACING_REGEX))
391
- type = (match[1] == "p") ? :padding : :margin
392
- axes = SPACING_AXIS_MAP[match[2]]
393
- return {type:, axes:}
394
- end
395
-
396
- # Check border width (needs separate handling due to anchoring)
397
- if (match = css_class.match(BORDER_REGEX))
398
- axes = SPACING_AXIS_MAP[match[1]]
399
- return {type: :border, axes:}
400
- end
401
-
402
- nil
403
- end
404
-
405
- def detect_category(css_class)
406
- CATEGORIES.find { |_name, pattern| css_class.match?(pattern) }&.first
407
- end
408
-
409
- # Extracts the modifier prefix from a Tailwind class
410
- # e.g., "md:hover:bg-blue-500" → ["md:hover", "bg-blue-500"]
411
- # e.g., "bg-white" → ["", "bg-white"]
412
- def extract_modifier_prefix(css_class)
413
- # Fast path: most classes don't have modifiers
414
- return ["", css_class] unless css_class.include?(":")
415
-
416
- parts = css_class.split(":")
417
- return ["", css_class] if parts.size == 1
418
-
419
- # Find where modifiers end and the actual class begins
420
- modifier_parts = []
421
- parts.each_with_index do |part, i|
422
- if known_modifier?(part) && i < parts.size - 1
423
- modifier_parts << part
424
- else
425
- base_class = parts[i..].join(":")
426
- return [modifier_parts.join(":"), base_class]
427
- end
428
- end
429
-
430
- # Fallback (shouldn't reach here)
431
- ["", css_class]
432
- end
272
+ input = css_strings.flat_map { |s| s.to_s.split }.reject(&:empty?).join(" ")
273
+ return "" if input.empty?
433
274
 
434
- def known_modifier?(str)
435
- return true if KNOWN_MODIFIERS.include?(str)
436
- MODIFIER_PATTERNS.any? { |pattern| str.match?(pattern) }
275
+ MERGER.merge(input)
437
276
  end
438
277
  end
439
278
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component_css_dsl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Lange
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: tailwind_merge
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: view_component
34
48
  requirement: !ruby/object:Gem::Requirement
@@ -100,6 +114,8 @@ files:
100
114
  - LICENSE.txt
101
115
  - README.md
102
116
  - lib/view_component_css_dsl.rb
117
+ - lib/view_component_css_dsl/verifier.rb
118
+ - lib/view_component_css_dsl/verifier/compiled_css_oracle.rb
103
119
  - lib/view_component_css_dsl/version.rb
104
120
  homepage: https://github.com/SOFware/view_component_css_dsl
105
121
  licenses: