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 +4 -4
- data/CHANGELOG.md +8 -5
- data/README.md +135 -1
- data/lib/view_component_css_dsl/verifier/compiled_css_oracle.rb +46 -0
- data/lib/view_component_css_dsl/verifier.rb +344 -0
- data/lib/view_component_css_dsl/version.rb +1 -1
- data/lib/view_component_css_dsl.rb +16 -177
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 494769078a576e9a1f7d3be9b2819ed2c93bf148da58154c68356c01410d9e8f
|
|
4
|
+
data.tar.gz: 529f35969651591c73f2c21012ab828c84305f94df5317f612fc4dc13b75cc39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
5
|
+
## [0.1.4] - 2026-06-04
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
-
-
|
|
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.
|
|
11
|
+
## [0.1.3] - 2026-05-18
|
|
12
12
|
|
|
13
13
|
### Changed
|
|
14
14
|
|
|
15
|
-
- `
|
|
16
|
-
|
|
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"
|
|
@@ -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
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|