view_component_css_dsl 0.1.3 → 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 +6 -6
- data/README.md +113 -0
- 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 +4 -0
- metadata +3 -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,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
## [0.1.4] - 2026-06-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
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
|
+
|
|
5
11
|
## [0.1.3] - 2026-05-18
|
|
6
12
|
|
|
7
13
|
### Changed
|
|
@@ -11,9 +17,3 @@
|
|
|
11
17
|
### Breaking
|
|
12
18
|
|
|
13
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.
|
|
14
|
-
|
|
15
|
-
## [0.1.2] - 2026-05-15
|
|
16
|
-
|
|
17
|
-
### Added
|
|
18
|
-
|
|
19
|
-
- data, aria, and attribute DSL declarators for first-class HTML attribute declarations (cf37050)
|
data/README.md
CHANGED
|
@@ -458,6 +458,119 @@ end
|
|
|
458
458
|
|
|
459
459
|
Axis, method, and proc rules are appended, not overridden.
|
|
460
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
|
+
|
|
461
574
|
## Development
|
|
462
575
|
|
|
463
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"
|
|
@@ -36,6 +36,9 @@ module ViewComponentCssDsl
|
|
|
36
36
|
|
|
37
37
|
included do
|
|
38
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: []
|
|
39
42
|
class_attribute :_css_axis_rules, instance_writer: false, default: []
|
|
40
43
|
class_attribute :_css_method_rules, instance_writer: false, default: []
|
|
41
44
|
class_attribute :_css_proc_rules, instance_writer: false, default: []
|
|
@@ -79,6 +82,7 @@ module ViewComponentCssDsl
|
|
|
79
82
|
when String
|
|
80
83
|
if options.empty?
|
|
81
84
|
# Base CSS: css "rounded p-4"
|
|
85
|
+
self._css_base_declarations = _css_base_declarations + [args.first]
|
|
82
86
|
parent_base_css = superclass.respond_to?(:_css_base) ? superclass._css_base : ""
|
|
83
87
|
self._css_base = if parent_base_css.present?
|
|
84
88
|
smart_merge(parent_base_css, args.first)
|
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
|
|
@@ -114,6 +114,8 @@ files:
|
|
|
114
114
|
- LICENSE.txt
|
|
115
115
|
- README.md
|
|
116
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
|
|
117
119
|
- lib/view_component_css_dsl/version.rb
|
|
118
120
|
homepage: https://github.com/SOFware/view_component_css_dsl
|
|
119
121
|
licenses:
|