rubocop-view_component 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +7 -0
- data/IMPLEMENTATION_SUMMARY.md +172 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +625 -0
- data/README.md +53 -0
- data/Rakefile +35 -0
- data/config/default.yml +33 -0
- data/lib/rubocop/cop/view_component/base.rb +41 -0
- data/lib/rubocop/cop/view_component/component_suffix.rb +33 -0
- data/lib/rubocop/cop/view_component/no_global_state.rb +58 -0
- data/lib/rubocop/cop/view_component/prefer_private_methods.rb +73 -0
- data/lib/rubocop/cop/view_component/prefer_slots.rb +105 -0
- data/lib/rubocop/cop/view_component_cops.rb +7 -0
- data/lib/rubocop/view_component/plugin.rb +31 -0
- data/lib/rubocop/view_component/version.rb +7 -0
- data/lib/rubocop/view_component.rb +10 -0
- data/lib/rubocop-view_component.rb +9 -0
- data/spec/rubocop/cop/view_component/component_suffix_spec.rb +86 -0
- data/spec/rubocop/cop/view_component/no_global_state_spec.rb +119 -0
- data/spec/rubocop/cop/view_component/prefer_private_methods_spec.rb +136 -0
- data/spec/rubocop/cop/view_component/prefer_slots_spec.rb +119 -0
- data/spec/spec_helper.rb +14 -0
- metadata +108 -0
data/PLAN.md
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
# RuboCop ViewComponent - Implementation Plan
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document outlines the plan for building `rubocop-view_component`, a RuboCop extension to enforce ViewComponent best practices based on the official [ViewComponent Best Practices](https://viewcomponent.org/best_practices.html).
|
|
6
|
+
|
|
7
|
+
## Research Summary
|
|
8
|
+
|
|
9
|
+
### Existing Work
|
|
10
|
+
|
|
11
|
+
**Primer ViewComponents** (GitHub's design system) has custom RuboCop cops for ViewComponent:
|
|
12
|
+
- Located at: `lib/rubocop/cop/primer/`
|
|
13
|
+
- Includes cops like `Primer/NoTagMemoize`
|
|
14
|
+
- Source: [Primer ViewComponents Linting Docs](https://github.com/primer/view_components/blob/main/docs/contributors/linting.md)
|
|
15
|
+
- Note: We will not inherit from their configuration, but can reference their implementation patterns
|
|
16
|
+
|
|
17
|
+
### Key Technologies
|
|
18
|
+
|
|
19
|
+
1. **RuboCop AST Processing**
|
|
20
|
+
- Cops inherit from `RuboCop::Cop::Base`
|
|
21
|
+
- Use `def_node_matcher` for declarative pattern matching
|
|
22
|
+
- Hook into callbacks like `on_send`, `on_class`, `on_def`
|
|
23
|
+
- Node patterns use syntax like `(send nil? :method_name)`
|
|
24
|
+
- Reference: [RuboCop Node Pattern Docs](https://docs.rubocop.org/rubocop-ast/node_pattern.html)
|
|
25
|
+
|
|
26
|
+
2. **Testing ViewComponents**
|
|
27
|
+
- Use `render_inline(Component.new)` in tests
|
|
28
|
+
- Assert against rendered output, not instance methods
|
|
29
|
+
- Slots defined with `renders_one`/`renders_many`
|
|
30
|
+
- Reference: [ViewComponent Testing Guide](https://viewcomponent.org/guide/testing.html)
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Proposed Cops (Priority Order)
|
|
35
|
+
|
|
36
|
+
### Phase 1: High-Value, Easy to Implement
|
|
37
|
+
|
|
38
|
+
#### 1. `ViewComponent/ComponentSuffix`
|
|
39
|
+
**Priority:** HIGH
|
|
40
|
+
**Complexity:** LOW
|
|
41
|
+
|
|
42
|
+
**Description:**
|
|
43
|
+
Enforce that all ViewComponent classes end with the `-Component` suffix to follow Rails naming conventions.
|
|
44
|
+
|
|
45
|
+
**Detection Pattern:**
|
|
46
|
+
```ruby
|
|
47
|
+
# Bad
|
|
48
|
+
class FooBar < ViewComponent::Base
|
|
49
|
+
class UserCard < ApplicationComponent
|
|
50
|
+
|
|
51
|
+
# Good
|
|
52
|
+
class FooBarComponent < ViewComponent::Base
|
|
53
|
+
class UserCardComponent < ApplicationComponent
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Implementation:**
|
|
57
|
+
- Hook: `on_class`
|
|
58
|
+
- Node Pattern: `(class (const _ !/_Component$/) (const ...) ...)`
|
|
59
|
+
- Check if superclass is `ViewComponent::Base` or inherits from it
|
|
60
|
+
- Suggest renaming to include `Component` suffix
|
|
61
|
+
|
|
62
|
+
**Autocorrect:** No
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
#### 2. `ViewComponent/NoGlobalState`
|
|
67
|
+
**Priority:** HIGH
|
|
68
|
+
**Complexity:** MEDIUM
|
|
69
|
+
|
|
70
|
+
**Description:**
|
|
71
|
+
Prevent direct access to global state (`params`, `request`, `session`, `cookies`, `flash`) within components. These should be passed as constructor arguments.
|
|
72
|
+
|
|
73
|
+
**Detection Pattern:**
|
|
74
|
+
```ruby
|
|
75
|
+
# Bad
|
|
76
|
+
class UserComponent < ViewComponent::Base
|
|
77
|
+
def initialize(user)
|
|
78
|
+
@user = user
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def admin?
|
|
82
|
+
params[:admin] == "true" # Direct access to params
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def user_agent
|
|
86
|
+
request.user_agent # Direct access to request
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Good
|
|
91
|
+
class UserComponent < ViewComponent::Base
|
|
92
|
+
def initialize(user, admin: false)
|
|
93
|
+
@user = user
|
|
94
|
+
@admin = admin
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def admin?
|
|
98
|
+
@admin
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Implementation:**
|
|
104
|
+
- Hook: `on_send`
|
|
105
|
+
- Node Pattern: `(send nil? {:params :request :session :cookies :flash})`
|
|
106
|
+
- Check if within a ViewComponent class context
|
|
107
|
+
- Suggest passing as initialization parameter
|
|
108
|
+
|
|
109
|
+
**Autocorrect:** No (requires refactoring)
|
|
110
|
+
|
|
111
|
+
**References:**
|
|
112
|
+
- [ViewComponent Best Practices - Avoid Global State](https://viewcomponent.org/best_practices.html)
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
#### 3. `ViewComponent/PreferPrivateMethods`
|
|
117
|
+
**Priority:** MEDIUM
|
|
118
|
+
**Complexity:** LOW
|
|
119
|
+
|
|
120
|
+
**Description:**
|
|
121
|
+
Suggest making helper methods private since they remain accessible in templates anyway. Only standard ViewComponent interface methods should be public.
|
|
122
|
+
|
|
123
|
+
**Detection Pattern:**
|
|
124
|
+
```ruby
|
|
125
|
+
# Bad
|
|
126
|
+
class CardComponent < ViewComponent::Base
|
|
127
|
+
def initialize(title)
|
|
128
|
+
@title = title
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def formatted_title # Should be private
|
|
132
|
+
@title.upcase
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Good
|
|
137
|
+
class CardComponent < ViewComponent::Base
|
|
138
|
+
def initialize(title)
|
|
139
|
+
@title = title
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def formatted_title
|
|
145
|
+
@title.upcase
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Implementation:**
|
|
151
|
+
- Hook: `on_def`
|
|
152
|
+
- Allowlist: `initialize`, `call`, `before_render`, `render?`
|
|
153
|
+
- Check visibility of other methods
|
|
154
|
+
- Suggest making non-interface methods private
|
|
155
|
+
|
|
156
|
+
**Autocorrect:** No
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
#### 4. `ViewComponent/PreferSlots`
|
|
161
|
+
**Priority:** MEDIUM
|
|
162
|
+
**Complexity:** MEDIUM
|
|
163
|
+
|
|
164
|
+
**Description:**
|
|
165
|
+
Detect parameters that accept HTML content and suggest using slots instead. This maintains Rails' HTML sanitization protections.
|
|
166
|
+
|
|
167
|
+
**Detection Pattern:**
|
|
168
|
+
```ruby
|
|
169
|
+
# Bad
|
|
170
|
+
class ModalComponent < ViewComponent::Base
|
|
171
|
+
def initialize(title:, body_html:) # HTML as string parameter
|
|
172
|
+
@title = title
|
|
173
|
+
@body_html = body_html
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Usage (unsafe)
|
|
178
|
+
<%= render ModalComponent.new(
|
|
179
|
+
title: "Alert",
|
|
180
|
+
body_html: "<p>#{user_input}</p>".html_safe
|
|
181
|
+
) %>
|
|
182
|
+
|
|
183
|
+
# Good
|
|
184
|
+
class ModalComponent < ViewComponent::Base
|
|
185
|
+
renders_one :body
|
|
186
|
+
|
|
187
|
+
def initialize(title:)
|
|
188
|
+
@title = title
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Usage (safe)
|
|
193
|
+
<%= render ModalComponent.new(title: "Alert") do |c| %>
|
|
194
|
+
<% c.with_body do %>
|
|
195
|
+
<p><%= user_input %></p>
|
|
196
|
+
<% end %>
|
|
197
|
+
<% end %>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Implementation:**
|
|
201
|
+
- Hook: `on_def` (check `initialize` method)
|
|
202
|
+
- Look for parameters ending in `_html`, `_content`, or types suggesting HTML
|
|
203
|
+
- Look for `.html_safe` calls in parameter defaults
|
|
204
|
+
- Suggest using `renders_one` or `renders_many` instead
|
|
205
|
+
|
|
206
|
+
**Autocorrect:** No (requires refactoring)
|
|
207
|
+
|
|
208
|
+
**Security Impact:** HIGH - prevents XSS vulnerabilities
|
|
209
|
+
|
|
210
|
+
**References:**
|
|
211
|
+
- [ViewComponent Best Practices - Prefer Slots Over HTML Arguments](https://viewcomponent.org/best_practices.html)
|
|
212
|
+
- [ViewComponent Slots Guide](https://viewcomponent.org/guide/slots.html)
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
### Phase 2: Architectural Quality
|
|
217
|
+
|
|
218
|
+
#### 5. `ViewComponent/PreferComposition`
|
|
219
|
+
**Priority:** MEDIUM
|
|
220
|
+
**Complexity:** MEDIUM
|
|
221
|
+
|
|
222
|
+
**Description:**
|
|
223
|
+
Detect inheritance chains deeper than one level and suggest composition instead. Inheriting one component from another causes confusion when each has its own template.
|
|
224
|
+
|
|
225
|
+
**Detection Pattern:**
|
|
226
|
+
```ruby
|
|
227
|
+
# Bad
|
|
228
|
+
class BaseCard < ViewComponent::Base
|
|
229
|
+
# template: base_card.html.erb
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
class UserCard < BaseCard # Inheritance from another component
|
|
233
|
+
# template: user_card.html.erb - confusing!
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Good
|
|
237
|
+
class UserCardComponent < ViewComponent::Base
|
|
238
|
+
def initialize(user)
|
|
239
|
+
@user = user
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Render BaseCardComponent within template via composition
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Implementation:**
|
|
247
|
+
- Hook: `on_class`
|
|
248
|
+
- Track inheritance chain depth
|
|
249
|
+
- Detect if superclass is a ViewComponent (not `ViewComponent::Base` or `ApplicationComponent`)
|
|
250
|
+
- Suggest wrapping pattern instead
|
|
251
|
+
|
|
252
|
+
**Autocorrect:** No (requires architectural refactoring)
|
|
253
|
+
|
|
254
|
+
**References:**
|
|
255
|
+
- [ViewComponent Best Practices - Composition Over Inheritance](https://viewcomponent.org/best_practices.html)
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
#### 6. `ViewComponent/AvoidSingleUseComponents`
|
|
260
|
+
**Priority:** LOW
|
|
261
|
+
**Complexity:** MEDIUM
|
|
262
|
+
|
|
263
|
+
**Description:**
|
|
264
|
+
Detect components that appear to be single-use (no methods, no slots, minimal logic) and suggest reconsidering if the component adds value over a partial.
|
|
265
|
+
|
|
266
|
+
**Detection Pattern:**
|
|
267
|
+
```ruby
|
|
268
|
+
# Questionable
|
|
269
|
+
class SimpleWrapperComponent < ViewComponent::Base
|
|
270
|
+
def initialize(content)
|
|
271
|
+
@content = content
|
|
272
|
+
end
|
|
273
|
+
# No other methods, no slots, no logic
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Better as partial or inline template
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Implementation:**
|
|
280
|
+
- Hook: `on_class`
|
|
281
|
+
- Analyze class body for:
|
|
282
|
+
- Number of instance methods (beyond `initialize`)
|
|
283
|
+
- Presence of slots (`renders_one`, `renders_many`)
|
|
284
|
+
- Complexity of logic
|
|
285
|
+
- Provide informational warning if component seems trivial
|
|
286
|
+
|
|
287
|
+
**Autocorrect:** No
|
|
288
|
+
|
|
289
|
+
**Severity:** Information (not error)
|
|
290
|
+
|
|
291
|
+
**References:**
|
|
292
|
+
- [ViewComponent Best Practices - Minimize One-Offs](https://viewcomponent.org/best_practices.html)
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
### Phase 3: Testing Best Practices
|
|
297
|
+
|
|
298
|
+
#### 7. `ViewComponent/TestRenderedOutput`
|
|
299
|
+
**Priority:** MEDIUM
|
|
300
|
+
**Complexity:** MEDIUM
|
|
301
|
+
|
|
302
|
+
**Description:**
|
|
303
|
+
In test files, detect assertions against component instance methods and suggest using `render_inline` with content assertions instead.
|
|
304
|
+
|
|
305
|
+
**Detection Pattern:**
|
|
306
|
+
```ruby
|
|
307
|
+
# Bad
|
|
308
|
+
test "formats title" do
|
|
309
|
+
component = TitleComponent.new("hello")
|
|
310
|
+
assert_equal "HELLO", component.formatted_title # Testing private method
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Good
|
|
314
|
+
test "renders formatted title" do
|
|
315
|
+
render_inline TitleComponent.new("hello")
|
|
316
|
+
assert_text "HELLO"
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Implementation:**
|
|
321
|
+
- Hook: `on_send`
|
|
322
|
+
- Context: Within test files (`*_test.rb`, `*_spec.rb`)
|
|
323
|
+
- Detect: Method calls on component instances (not `render_inline` results)
|
|
324
|
+
- Suggest: Using `render_inline` and asserting against rendered output
|
|
325
|
+
|
|
326
|
+
**Autocorrect:** No
|
|
327
|
+
|
|
328
|
+
**References:**
|
|
329
|
+
- [ViewComponent Best Practices - Test Rendered Output](https://viewcomponent.org/best_practices.html)
|
|
330
|
+
- [ViewComponent Testing Guide](https://viewcomponent.org/guide/testing.html)
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
## Technical Architecture
|
|
336
|
+
|
|
337
|
+
### Directory Structure
|
|
338
|
+
|
|
339
|
+
```
|
|
340
|
+
lib/
|
|
341
|
+
├── rubocop/
|
|
342
|
+
│ ├── cop/
|
|
343
|
+
│ │ ├── view_component_cops.rb # Requires all cops
|
|
344
|
+
│ │ └── view_component/
|
|
345
|
+
│ │ ├── component_suffix.rb
|
|
346
|
+
│ │ ├── no_global_state.rb
|
|
347
|
+
│ │ ├── prefer_private_methods.rb
|
|
348
|
+
│ │ ├── prefer_slots.rb
|
|
349
|
+
│ │ ├── prefer_composition.rb
|
|
350
|
+
│ │ ├── avoid_single_use_components.rb
|
|
351
|
+
│ │ └── test_rendered_output.rb
|
|
352
|
+
│ └── view_component/
|
|
353
|
+
│ ├── version.rb
|
|
354
|
+
│ ├── plugin.rb
|
|
355
|
+
│ └── inject.rb # Config injection
|
|
356
|
+
├── rubocop-view_component.rb
|
|
357
|
+
config/
|
|
358
|
+
└── default.yml # Default cop configuration
|
|
359
|
+
spec/
|
|
360
|
+
└── rubocop/
|
|
361
|
+
└── cop/
|
|
362
|
+
└── view_component/
|
|
363
|
+
├── component_suffix_spec.rb
|
|
364
|
+
└── ...
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Cop Template
|
|
368
|
+
|
|
369
|
+
Each cop should follow this structure:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
# frozen_string_literal: true
|
|
373
|
+
|
|
374
|
+
module RuboCop
|
|
375
|
+
module Cop
|
|
376
|
+
module ViewComponent
|
|
377
|
+
# Enforces [best practice name].
|
|
378
|
+
#
|
|
379
|
+
# @example
|
|
380
|
+
# # bad
|
|
381
|
+
# [bad code example]
|
|
382
|
+
#
|
|
383
|
+
# # good
|
|
384
|
+
# [good code example]
|
|
385
|
+
#
|
|
386
|
+
class CopName < Base
|
|
387
|
+
MSG = 'Explain the problem and suggest solution.'
|
|
388
|
+
RESTRICT_ON_SEND = %i[method_name].freeze # Optional optimization
|
|
389
|
+
|
|
390
|
+
def_node_matcher :pattern_name, <<~PATTERN
|
|
391
|
+
(send ...)
|
|
392
|
+
PATTERN
|
|
393
|
+
|
|
394
|
+
def on_send(node)
|
|
395
|
+
return unless pattern_name(node)
|
|
396
|
+
# Check conditions
|
|
397
|
+
|
|
398
|
+
add_offense(node)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
private
|
|
402
|
+
|
|
403
|
+
def in_view_component?(node)
|
|
404
|
+
# Helper to check if within ViewComponent class
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Configuration (config/default.yml)
|
|
413
|
+
|
|
414
|
+
```yaml
|
|
415
|
+
ViewComponent/ComponentSuffix:
|
|
416
|
+
Description: 'Enforce -Component suffix for ViewComponent classes.'
|
|
417
|
+
Enabled: true
|
|
418
|
+
VersionAdded: '0.1'
|
|
419
|
+
Severity: warning
|
|
420
|
+
|
|
421
|
+
ViewComponent/NoGlobalState:
|
|
422
|
+
Description: 'Avoid accessing global state (params, request, session, etc.) directly.'
|
|
423
|
+
Enabled: true
|
|
424
|
+
VersionAdded: '0.1'
|
|
425
|
+
Severity: warning
|
|
426
|
+
|
|
427
|
+
ViewComponent/PreferPrivateMethods:
|
|
428
|
+
Description: 'Suggest making helper methods private.'
|
|
429
|
+
Enabled: true
|
|
430
|
+
VersionAdded: '0.1'
|
|
431
|
+
Severity: convention
|
|
432
|
+
AllowedPublicMethods:
|
|
433
|
+
- initialize
|
|
434
|
+
- call
|
|
435
|
+
- before_render
|
|
436
|
+
- render?
|
|
437
|
+
|
|
438
|
+
# ... etc
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Helper Modules
|
|
442
|
+
|
|
443
|
+
Create shared utilities:
|
|
444
|
+
|
|
445
|
+
```ruby
|
|
446
|
+
# lib/rubocop/cop/view_component/helpers.rb
|
|
447
|
+
module RuboCop
|
|
448
|
+
module Cop
|
|
449
|
+
module ViewComponent
|
|
450
|
+
module Helpers
|
|
451
|
+
def view_component_class?(node)
|
|
452
|
+
# Check if node is within a ViewComponent class
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def inherits_from_view_component?(class_node)
|
|
456
|
+
# Check inheritance chain
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Implementation Phases
|
|
467
|
+
|
|
468
|
+
### Phase 1: Foundation (Week 1-2)
|
|
469
|
+
- [ ] Set up proper gem structure with lint_roller integration
|
|
470
|
+
- [ ] Create base helper modules
|
|
471
|
+
- [ ] Implement `ComponentSuffix` cop
|
|
472
|
+
- [ ] Implement `NoGlobalState` cop
|
|
473
|
+
- [ ] Write comprehensive tests for Phase 1 cops
|
|
474
|
+
- [ ] Update default.yml configuration
|
|
475
|
+
|
|
476
|
+
### Phase 2: Core Best Practices (Week 3-4)
|
|
477
|
+
- [ ] Implement `PreferPrivateMethods` cop
|
|
478
|
+
- [ ] Implement `PreferSlots` cop
|
|
479
|
+
- [ ] Implement `PreferComposition` cop
|
|
480
|
+
- [ ] Documentation and examples
|
|
481
|
+
|
|
482
|
+
### Phase 3: Testing & Quality (Week 5-6)
|
|
483
|
+
- [ ] Implement `TestRenderedOutput` cop
|
|
484
|
+
- [ ] Implement `AvoidSingleUseComponents` cop
|
|
485
|
+
- [ ] Add performance optimizations
|
|
486
|
+
- [ ] Integration testing with real-world ViewComponent projects
|
|
487
|
+
- [ ] Performance benchmarking
|
|
488
|
+
- [ ] Documentation site
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Testing Strategy
|
|
493
|
+
|
|
494
|
+
### Cop Testing
|
|
495
|
+
Each cop should have:
|
|
496
|
+
|
|
497
|
+
1. **Positive cases** - Code that triggers the offense
|
|
498
|
+
2. **Negative cases** - Code that should not trigger
|
|
499
|
+
3. **Edge cases** - Boundary conditions
|
|
500
|
+
|
|
501
|
+
Example test structure:
|
|
502
|
+
```ruby
|
|
503
|
+
RSpec.describe RuboCop::Cop::ViewComponent::NoGlobalState, :config do
|
|
504
|
+
it 'registers an offense when accessing params' do
|
|
505
|
+
expect_offense(<<~RUBY)
|
|
506
|
+
class MyComponent < ViewComponent::Base
|
|
507
|
+
def admin?
|
|
508
|
+
params[:admin]
|
|
509
|
+
^^^^^^ Avoid accessing global state directly. Pass as initialization parameter.
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
RUBY
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
it 'does not register offense for instance variables' do
|
|
516
|
+
expect_no_offenses(<<~RUBY)
|
|
517
|
+
class MyComponent < ViewComponent::Base
|
|
518
|
+
def initialize(admin:)
|
|
519
|
+
@admin = admin
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def admin?
|
|
523
|
+
@admin
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
RUBY
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### Integration Testing
|
|
532
|
+
- Integration testing with fixture ViewComponent projects
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Documentation Plan
|
|
537
|
+
|
|
538
|
+
### README.md Updates
|
|
539
|
+
- Clear installation instructions
|
|
540
|
+
- Quick start guide
|
|
541
|
+
- List of all cops with examples
|
|
542
|
+
- Configuration options
|
|
543
|
+
- Integration with CI/CD
|
|
544
|
+
|
|
545
|
+
### Individual Cop Documentation
|
|
546
|
+
Each cop should have:
|
|
547
|
+
- Clear description
|
|
548
|
+
- Why it exists (reference to best practice)
|
|
549
|
+
- Bad/good code examples
|
|
550
|
+
- Configuration options
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Future Enhancements
|
|
555
|
+
|
|
556
|
+
### Potential Additional Cops
|
|
557
|
+
|
|
558
|
+
1. **`ViewComponent/MinimizeTemplateLogic`**
|
|
559
|
+
- Detect complex Ruby logic in `.html.erb` templates
|
|
560
|
+
- Suggest extracting to component methods
|
|
561
|
+
- **Challenge:** Requires parsing ERB templates
|
|
562
|
+
- Reference: [ViewComponent Best Practices - Minimize Template Logic](https://viewcomponent.org/best_practices.html)
|
|
563
|
+
|
|
564
|
+
2. **`ViewComponent/NoQueryInComponent`**
|
|
565
|
+
- Detect ActiveRecord queries in components
|
|
566
|
+
- Components should receive data, not fetch it
|
|
567
|
+
|
|
568
|
+
3. **`ViewComponent/PreferStrictLocals`**
|
|
569
|
+
- Encourage use of `locals` declaration in templates
|
|
570
|
+
- Catches typos and documents component API
|
|
571
|
+
|
|
572
|
+
4. **`ViewComponent/SlotNamingConvention`**
|
|
573
|
+
- Enforce naming conventions for slots
|
|
574
|
+
- Singular for `renders_one`, plural for `renders_many`
|
|
575
|
+
|
|
576
|
+
5. **`ViewComponent/NoControllerHelpers`**
|
|
577
|
+
- Detect usage of controller-specific helpers
|
|
578
|
+
- Promotes reusability
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
## References & Resources
|
|
582
|
+
|
|
583
|
+
### Official Documentation
|
|
584
|
+
- [ViewComponent Best Practices](https://viewcomponent.org/best_practices.html)
|
|
585
|
+
- [ViewComponent Testing Guide](https://viewcomponent.org/guide/testing.html)
|
|
586
|
+
- [ViewComponent Slots Guide](https://viewcomponent.org/guide/slots.html)
|
|
587
|
+
- [RuboCop Development Guide](https://docs.rubocop.org/rubocop/development.html)
|
|
588
|
+
- [RuboCop Node Patterns](https://docs.rubocop.org/rubocop-ast/node_pattern.html)
|
|
589
|
+
|
|
590
|
+
### Community Resources
|
|
591
|
+
- [Custom Cops for RuboCop - Evil Martians](https://evilmartians.com/chronicles/custom-cops-for-rubocop-an-emergency-service-for-your-codebase)
|
|
592
|
+
- [Create a Custom RuboCop Cop - FastRuby.io](https://www.fastruby.io/blog/rubocop/code-quality/create-a-custom-rubocop-cop.html)
|
|
593
|
+
- [Thoughtbot - Custom Cops](https://thoughtbot.com/blog/rubocop-custom-cops-for-custom-needs)
|
|
594
|
+
- [RuboCop RSpec Cops](https://docs.rubocop.org/rubocop-rspec/cops_rspec.html)
|
|
595
|
+
- [Shopify ERB Lint](https://github.com/Shopify/erb_lint)
|
|
596
|
+
|
|
597
|
+
### Similar Projects
|
|
598
|
+
- [rubocop-rails](https://github.com/rubocop/rubocop-rails)
|
|
599
|
+
- [rubocop-rspec](https://github.com/rubocop/rubocop-rspec)
|
|
600
|
+
- [Primer ViewComponents](https://github.com/primer/view_components) - Has custom cops
|
|
601
|
+
|
|
602
|
+
### ViewComponent Anti-Patterns
|
|
603
|
+
- [ViewComponent Tips](https://railsnotes.xyz/blog/rails-viewcomponent-tips)
|
|
604
|
+
- [Advanced ViewComponent Patterns](https://dev.to/abeidahmed/advanced-viewcomponent-patterns-in-rails-2b4m)
|
|
605
|
+
- [ViewComponent in the Wild - Evil Martians](https://evilmartians.com/chronicles/viewcomponent-in-the-wild-building-modern-rails-frontends)
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Next Steps
|
|
610
|
+
|
|
611
|
+
1. **Review this plan** with stakeholders/community
|
|
612
|
+
2. **Set up development environment** with proper test fixtures
|
|
613
|
+
3. **Start with Phase 1** - implement high-value, low-complexity cops first
|
|
614
|
+
4. **Get early feedback** from ViewComponent users
|
|
615
|
+
5. **Iterate based on real-world usage**
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Questions to Resolve
|
|
620
|
+
|
|
621
|
+
1. Should we support legacy ViewComponent versions (< 3.0)? **NO** - Only support ViewComponent 3.0+
|
|
622
|
+
2. How to handle custom base classes (e.g., `ApplicationComponent`)? **TBD** - Discuss later
|
|
623
|
+
3. Should we integrate with erb-lint or keep separate? **SEPARATE** - Keep as separate tool
|
|
624
|
+
4. What's the policy on autocorrect? **NO AUTOCORRECT** - Detection only, no automatic fixes
|
|
625
|
+
5. Should we coordinate with Primer ViewComponents team to avoid duplication? **NO** - Independent project
|
data/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# RuboCop::ViewComponent
|
|
2
|
+
|
|
3
|
+
A RuboCop extension that enforces [ViewComponent best practices](https://viewcomponent.org/best_practices.html).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'rubocop-view_component', require: false
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Add to your `.rubocop.yml`:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
require:
|
|
17
|
+
- rubocop-view_component
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Cops
|
|
21
|
+
|
|
22
|
+
This gem provides several cops to enforce ViewComponent best practices:
|
|
23
|
+
|
|
24
|
+
- **ViewComponent/ComponentSuffix** - Enforce `-Component` suffix for ViewComponent classes
|
|
25
|
+
- **ViewComponent/NoGlobalState** - Prevent direct access to `params`, `request`, `session`, etc.
|
|
26
|
+
- **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private
|
|
27
|
+
- **ViewComponent/PreferSlots** - Detect HTML parameters that should be slots
|
|
28
|
+
- **ViewComponent/PreferComposition** - Discourage deep inheritance chains
|
|
29
|
+
- **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods
|
|
30
|
+
|
|
31
|
+
See [PLAN.md](PLAN.md) for detailed cop descriptions and implementation status.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
Run RuboCop as usual:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bundle exec rubocop
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
44
|
+
|
|
45
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
46
|
+
|
|
47
|
+
## Contributing
|
|
48
|
+
|
|
49
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/andyw8/rubocop-view_component.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
require "standard/rake"
|
|
9
|
+
|
|
10
|
+
task default: %i[spec standard]
|
|
11
|
+
|
|
12
|
+
require "rspec/core/rake_task"
|
|
13
|
+
|
|
14
|
+
RSpec::Core::RakeTask.new(:spec) do |spec|
|
|
15
|
+
spec.pattern = FileList["spec/**/*_spec.rb"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Generate a new cop with a template"
|
|
19
|
+
task :new_cop, [:cop] do |_task, args|
|
|
20
|
+
require "rubocop"
|
|
21
|
+
|
|
22
|
+
cop_name = args.fetch(:cop) do
|
|
23
|
+
warn "usage: bundle exec rake new_cop[Department/Name]"
|
|
24
|
+
exit!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
generator = RuboCop::Cop::Generator.new(cop_name)
|
|
28
|
+
|
|
29
|
+
generator.write_source
|
|
30
|
+
generator.write_spec
|
|
31
|
+
generator.inject_require(root_file_path: "lib/rubocop/cop/view_component_cops.rb")
|
|
32
|
+
generator.inject_config(config_file_path: "config/default.yml")
|
|
33
|
+
|
|
34
|
+
puts generator.todo
|
|
35
|
+
end
|