evc_rails 0.2.0 → 0.2.2
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/README.md +84 -48
- data/lib/evc_rails/template_handler.rb +76 -70
- data/lib/evc_rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fb8d4d59a3526f01f08e3c152ff5abe083e1a2bf1e66ab929db733356b56c822
|
4
|
+
data.tar.gz: 07c8c5688a9ad4ddb78f34ca174f478e9103b656c09223c86aed522746b0b299
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6e429ae91cf3b0e3ac7b47001e164e65f6c4c6bd45f6c11f4258bb0a3c7cf77fa04594a4f493b887f434809ff11174996b09099273ac1bfac50897b002489158
|
7
|
+
data.tar.gz: 57c5fb478a987d55dd24e22afda9198f9af0b11056ac3ab2bbf66a52ec420251cae33cfa596caa5074015e241cd5d6754b2f95a04c46ba1951c0190694f92e64
|
data/README.md
CHANGED
@@ -154,6 +154,48 @@ This maps to:
|
|
154
154
|
|
155
155
|
### Slot Support
|
156
156
|
|
157
|
+
EVC provides a powerful and intuitive way to work with ViewComponent slots. To populate a slot, you use a corresponding `<With...>` tag that matches the **method name** provided by `renders_one` or `renders_many`.
|
158
|
+
|
159
|
+
When you use slots, EVC automatically makes the component's instance available in a block variable. By default, this variable is named after the component itself in snake_case (e.g., `<Accordion>` yields an `accordion` variable). This allows you to easily call component methods like `<%= accordion.arrow %>` from within the block. This variable is even available in deeply nested components, and you can provide a custom name to avoid ambiguity when nesting components of the same type.
|
160
|
+
|
161
|
+
#### Slot Naming Convention
|
162
|
+
|
163
|
+
The key to understanding slot tags in `evc_rails` is that they map directly to the **methods** generated by ViewComponent, not the `renders_...` declaration itself.
|
164
|
+
|
165
|
+
For both `renders_one` and `renders_many`, ViewComponent always generates a singular `with_*` method.
|
166
|
+
|
167
|
+
- `renders_one :header` provides a `with_header` method. You use `<WithHeader>`.
|
168
|
+
- `renders_many :items` provides a singular `with_item` method. You use the singular `<WithItem>` tag for each item you want to render.
|
169
|
+
|
170
|
+
This design provides maximum flexibility, allowing you to pass content as a block or make multiple self-closing calls, just like you would in standard ERB.
|
171
|
+
|
172
|
+
#### Attributes vs. Slots
|
173
|
+
|
174
|
+
There are two ways to pass information to a component:
|
175
|
+
|
176
|
+
- **As attributes:** Data passed as attributes on the main component tag (e.g. `<Card title="...">`) is sent to its `initialize` method.
|
177
|
+
- **As slot content:** Rich content passed via `<With...>` tags is used to populate the component's named slots.
|
178
|
+
|
179
|
+
#### Boolean Attribute Shorthand
|
180
|
+
|
181
|
+
You can use HTML-style boolean attributes in EVC. If you specify an attribute with no value, it will be passed as `true` to your component initializer. This makes templates more concise and readable:
|
182
|
+
|
183
|
+
```erb
|
184
|
+
<Button disabled required />
|
185
|
+
```
|
186
|
+
|
187
|
+
is equivalent to:
|
188
|
+
|
189
|
+
```erb
|
190
|
+
<%= render ButtonComponent.new(disabled: true, required: true) %>
|
191
|
+
```
|
192
|
+
|
193
|
+
This works for any boolean parameter your component defines.
|
194
|
+
|
195
|
+
#### When a Block Variable is Yielded
|
196
|
+
|
197
|
+
The contextual variable (e.g., `|card|`) is only yielded if one or more `<With...>` slot tags are present inside the component block. If you render a component like `<Card></Card>` with no slots inside, `evc_rails` is smart enough to render it without the `do |card|` part.
|
198
|
+
|
157
199
|
#### Single Slots (`renders_one`)
|
158
200
|
|
159
201
|
```ruby
|
@@ -178,11 +220,11 @@ end
|
|
178
220
|
Becomes:
|
179
221
|
|
180
222
|
```erb
|
181
|
-
<%= render CardComponent.new do |
|
182
|
-
<%
|
223
|
+
<%= render CardComponent.new do |card| %>
|
224
|
+
<% card.with_header do %>
|
183
225
|
<h1>Welcome</h1>
|
184
226
|
<% end %>
|
185
|
-
<%
|
227
|
+
<% card.with_body do %>
|
186
228
|
<p>This is the body content.</p>
|
187
229
|
<% end %>
|
188
230
|
<% end %>
|
@@ -199,68 +241,62 @@ end
|
|
199
241
|
|
200
242
|
```erb
|
201
243
|
<List>
|
202
|
-
|
203
|
-
|
204
|
-
|
244
|
+
<% @todo_items.each do |item| %>
|
245
|
+
<WithItem>
|
246
|
+
<span class="<%= item.completed? ? 'line-through' : '' %>">
|
247
|
+
<%= item.title %>
|
248
|
+
</span>
|
249
|
+
</WithItem>
|
250
|
+
<% end %>
|
205
251
|
</List>
|
206
252
|
```
|
207
253
|
|
208
254
|
Becomes:
|
209
255
|
|
210
256
|
```erb
|
211
|
-
<%= render ListComponent.new do |
|
212
|
-
<%
|
213
|
-
|
214
|
-
|
257
|
+
<%= render ListComponent.new do |list| %>
|
258
|
+
<% @todo_items.each do |item| %>
|
259
|
+
<% list.with_item do %>
|
260
|
+
<span class="<%= item.completed? ? 'line-through' : '' %>">
|
261
|
+
<%= item.title %>
|
262
|
+
</span>
|
263
|
+
<% end %>
|
264
|
+
<% end %>
|
215
265
|
<% end %>
|
216
266
|
```
|
217
267
|
|
218
|
-
####
|
268
|
+
#### Custom Variable Naming with `as`
|
219
269
|
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
<WithSublink href={reports_activity_path} text="Activity" />
|
227
|
-
</WithLink>
|
228
|
-
<WithFooter>
|
229
|
-
<div>Footer content</div>
|
230
|
-
</WithFooter>
|
231
|
-
</Navigation>
|
232
|
-
```
|
270
|
+
For clarity or to resolve ambiguity when nesting components of the same type, you can provide a custom variable name with the `as` attribute.
|
271
|
+
|
272
|
+
```ruby
|
273
|
+
# app/components/card_component.rb
|
274
|
+
class CardComponent < ViewComponent::Base
|
275
|
+
renders_one :header
|
233
276
|
|
234
|
-
|
277
|
+
attr_reader :title
|
235
278
|
|
236
|
-
|
279
|
+
def initialize(title: "Default Title")
|
280
|
+
@title = title
|
281
|
+
end
|
282
|
+
end
|
283
|
+
```
|
237
284
|
|
238
285
|
```erb
|
239
|
-
<Card>
|
240
|
-
<
|
241
|
-
|
286
|
+
<Card as="outer_card" title="Outer Card">
|
287
|
+
<WithHeader>
|
288
|
+
<h2><%= outer_card.title %></h2>
|
289
|
+
<Card as="inner_card" title="Inner Card">
|
290
|
+
<WithHeader>
|
291
|
+
<h3><%= inner_card.title %></h3>
|
292
|
+
<p>Outer card title from inner scope: <%= outer_card.title %></p>
|
293
|
+
</WithHeader>
|
294
|
+
</Card>
|
295
|
+
</WithHeader>
|
242
296
|
</Card>
|
243
297
|
```
|
244
298
|
|
245
|
-
|
246
|
-
|
247
|
-
```erb
|
248
|
-
<UI::Card>
|
249
|
-
<h2 class="text-2xl font-semibold">Dashboard</h2>
|
250
|
-
|
251
|
-
<UI::Grid cols="3" gap="md">
|
252
|
-
<UI::Card shadow="sm">
|
253
|
-
<p class="text-center">Widget 1</p>
|
254
|
-
</UI::Card>
|
255
|
-
<UI::Card shadow="sm">
|
256
|
-
<p class="text-center">Widget 2</p>
|
257
|
-
</UI::Card>
|
258
|
-
<UI::Card shadow="sm">
|
259
|
-
<p class="text-center">Widget 3</p>
|
260
|
-
</UI::Card>
|
261
|
-
</UI::Grid>
|
262
|
-
</UI::Card>
|
263
|
-
```
|
299
|
+
This generates distinct variables, `outer_card` and `inner_card`, allowing you to access the context of each component without collision.
|
264
300
|
|
265
301
|
### Mixed Content
|
266
302
|
|
@@ -13,7 +13,7 @@ module EvcRails
|
|
13
13
|
CLOSE_TAG_REGEX = %r{</([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)>}
|
14
14
|
|
15
15
|
# Regex for attributes
|
16
|
-
ATTRIBUTE_REGEX = /(\w+)
|
16
|
+
ATTRIBUTE_REGEX = /(\w+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/
|
17
17
|
|
18
18
|
# Cache for compiled templates
|
19
19
|
@template_cache = {}
|
@@ -104,49 +104,35 @@ module EvcRails
|
|
104
104
|
match = next_open
|
105
105
|
tag_name = match[1]
|
106
106
|
attributes_str = match[2].to_s.strip
|
107
|
+
params, as_variable = parse_attributes(attributes_str, attribute_regex)
|
108
|
+
param_str = params.join(", ")
|
107
109
|
is_self_closing = match[0].end_with?("/>")
|
108
110
|
|
109
111
|
# Add content before the tag
|
110
112
|
result << source[pos...match.begin(0)] if pos < match.begin(0)
|
111
113
|
|
112
|
-
# Determine if this is a slot (e.g., WithHeader, WithPost
|
113
|
-
|
114
|
+
# Determine if this is a slot (e.g., WithHeader, WithPost)
|
115
|
+
parent_component = stack.reverse.find { |item| item[4] == :component }
|
114
116
|
is_slot = false
|
115
117
|
slot_name = nil
|
116
|
-
|
117
|
-
if
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
is_slot = true
|
123
|
-
slot_name = tag_name[4..-1].downcase # Remove "With" prefix and convert to lowercase
|
124
|
-
slot_parent = parent_tag
|
125
|
-
# Mark parent as having a slot
|
126
|
-
parent[6] = true
|
127
|
-
# Check for Component::slotname syntax (backward compatibility)
|
128
|
-
elsif tag_name.start_with?("#{parent_tag}::")
|
129
|
-
is_slot = true
|
130
|
-
slot_name = tag_name.split("::").last.downcase
|
131
|
-
slot_parent = parent_tag
|
132
|
-
# Mark parent as having a slot
|
133
|
-
parent[6] = true
|
134
|
-
end
|
118
|
+
|
119
|
+
if parent_component && tag_name.start_with?("With")
|
120
|
+
is_slot = true
|
121
|
+
# Convert CamelCase slot name to snake_case
|
122
|
+
slot_name = tag_name[4..].gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
123
|
+
parent_component[6] = true # Mark parent as having a slot
|
135
124
|
end
|
136
125
|
|
137
126
|
if is_self_closing
|
138
127
|
if is_slot
|
139
|
-
|
140
|
-
param_str = params.join(", ")
|
128
|
+
parent_variable = parent_component[8]
|
141
129
|
result << if param_str.empty?
|
142
|
-
"<%
|
130
|
+
"<% #{parent_variable}.with_#{slot_name} do %><% end %>"
|
143
131
|
else
|
144
|
-
"<%
|
132
|
+
"<% #{parent_variable}.with_#{slot_name}(#{param_str}) do %><% end %>"
|
145
133
|
end
|
146
134
|
else
|
147
135
|
component_class = "#{tag_name}Component"
|
148
|
-
params = parse_attributes(attributes_str, attribute_regex)
|
149
|
-
param_str = params.join(", ")
|
150
136
|
result << if param_str.empty?
|
151
137
|
"<%= render #{component_class}.new %>"
|
152
138
|
else
|
@@ -154,35 +140,23 @@ module EvcRails
|
|
154
140
|
end
|
155
141
|
end
|
156
142
|
elsif is_slot
|
157
|
-
|
158
|
-
param_str
|
159
|
-
stack << [tag_name, nil, param_str, result.length, :slot, slot_name, false, match.begin(0)]
|
143
|
+
parent_variable = parent_component[8]
|
144
|
+
stack << [tag_name, nil, param_str, result.length, :slot, slot_name, false, match.begin(0), nil]
|
160
145
|
result << if param_str.empty?
|
161
|
-
"<%
|
146
|
+
"<% #{parent_variable}.with_#{slot_name} do %>"
|
162
147
|
else
|
163
|
-
"<%
|
148
|
+
"<% #{parent_variable}.with_#{slot_name}(#{param_str}) do %>"
|
164
149
|
end
|
165
150
|
else
|
166
151
|
component_class = "#{tag_name}Component"
|
167
|
-
|
168
|
-
param_str
|
169
|
-
|
170
|
-
if
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
else
|
176
|
-
"<%= render #{component_class}.new(#{param_str}) do %>"
|
177
|
-
end
|
178
|
-
else
|
179
|
-
stack << [tag_name, component_class, param_str, result.length, :component, nil, false, match.begin(0)]
|
180
|
-
result << if param_str.empty?
|
181
|
-
"<%= render #{component_class}.new do %>"
|
182
|
-
else
|
183
|
-
"<%= render #{component_class}.new(#{param_str}) do %>"
|
184
|
-
end
|
185
|
-
end
|
152
|
+
variable_name = as_variable || component_variable_name(tag_name)
|
153
|
+
stack << [tag_name, component_class, param_str, result.length, :component, nil, false, match.begin(0),
|
154
|
+
variable_name]
|
155
|
+
result << if param_str.empty?
|
156
|
+
"<%= render #{component_class}.new do %>"
|
157
|
+
else
|
158
|
+
"<%= render #{component_class}.new(#{param_str}) do %>"
|
159
|
+
end
|
186
160
|
end
|
187
161
|
|
188
162
|
pos = match.end(0)
|
@@ -202,7 +176,7 @@ module EvcRails
|
|
202
176
|
end
|
203
177
|
|
204
178
|
# Find the matching opening tag (from the end)
|
205
|
-
matching_index = stack.rindex { |(tag_name,
|
179
|
+
matching_index = stack.rindex { |(tag_name, *)| tag_name == closing_tag_name }
|
206
180
|
if matching_index.nil?
|
207
181
|
line = line_number_at_position(source, match.begin(0))
|
208
182
|
col = column_number_at_position(source, match.begin(0))
|
@@ -210,18 +184,30 @@ module EvcRails
|
|
210
184
|
end
|
211
185
|
|
212
186
|
# Pop the matching opening tag
|
213
|
-
|
187
|
+
open_tag_data = stack.delete_at(matching_index)
|
188
|
+
tag_type = open_tag_data[4]
|
189
|
+
|
190
|
+
if tag_type == :component
|
191
|
+
_tag_name, component_class, param_str, start_pos, _type, _slot_name, slot_used, _open_pos, variable_name = open_tag_data
|
192
|
+
|
193
|
+
# Patch in |variable_name| for component if a slot was used
|
194
|
+
if slot_used
|
195
|
+
# More robustly find the end of the `do` block to insert the variable.
|
196
|
+
# This avoids faulty regex matching on complex parameters.
|
197
|
+
relevant_part = result[start_pos..-1]
|
198
|
+
match_for_insertion = /( do)( %>)/.match(relevant_part)
|
199
|
+
if match_for_insertion
|
200
|
+
# Insert the variable name just before the ` do`
|
201
|
+
insertion_point = start_pos + match_for_insertion.begin(1)
|
202
|
+
result.insert(insertion_point, " |#{variable_name}|")
|
203
|
+
end
|
204
|
+
end
|
214
205
|
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
open_block_regex = /(<%= render #{component_class}\.new(?:\(.*?\))? do)( %>)/
|
219
|
-
result.sub!(open_block_regex) { "#{::Regexp.last_match(1)} |c|#{::Regexp.last_match(2)}" }
|
206
|
+
result << "<% end %>"
|
207
|
+
else # It's a slot
|
208
|
+
result << "<% end %>"
|
220
209
|
end
|
221
210
|
|
222
|
-
# Add closing block
|
223
|
-
result << (type == :slot ? "<% end %>" : "<% end %>")
|
224
|
-
|
225
211
|
pos = match.end(0)
|
226
212
|
else
|
227
213
|
# No more tags, add remaining content
|
@@ -248,18 +234,38 @@ module EvcRails
|
|
248
234
|
erb_string
|
249
235
|
end
|
250
236
|
|
237
|
+
def component_variable_name(tag_name)
|
238
|
+
# Simplified version of ActiveSupport's underscore
|
239
|
+
name = tag_name.gsub(/::/, "_")
|
240
|
+
name.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
241
|
+
name.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
242
|
+
name.tr!("-", "_")
|
243
|
+
name.downcase!
|
244
|
+
name
|
245
|
+
end
|
246
|
+
|
251
247
|
def parse_attributes(attributes_str, attribute_regex = ATTRIBUTE_REGEX)
|
248
|
+
as_variable = nil
|
249
|
+
# Find and remove the `as` attribute, storing its value.
|
250
|
+
attributes_str = attributes_str.gsub(/\bas=(?:"([^"]*)"|'([^']*)')/) do |_match|
|
251
|
+
as_variable = Regexp.last_match(1) || Regexp.last_match(2)
|
252
|
+
""
|
253
|
+
end.strip
|
254
|
+
|
252
255
|
params = []
|
253
256
|
attributes_str.scan(attribute_regex) do |key, quoted_value, single_quoted_value, ruby_expression|
|
254
|
-
if ruby_expression
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
257
|
+
params << if ruby_expression
|
258
|
+
"#{key}: #{ruby_expression}"
|
259
|
+
elsif quoted_value
|
260
|
+
"#{key}: \"#{quoted_value.gsub('"', '\\"')}\""
|
261
|
+
elsif single_quoted_value
|
262
|
+
"#{key}: \"#{single_quoted_value.gsub("'", "\\'")}\""
|
263
|
+
else
|
264
|
+
# Standalone attribute (no value) - treat as boolean true
|
265
|
+
"#{key}: true"
|
266
|
+
end
|
261
267
|
end
|
262
|
-
params
|
268
|
+
[params, as_variable]
|
263
269
|
end
|
264
270
|
end
|
265
271
|
end
|
data/lib/evc_rails/version.rb
CHANGED