evc_rails 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/README.md +228 -113
- data/lib/evc_rails/railtie.rb +1 -5
- data/lib/evc_rails/template_handler.rb +198 -146
- data/lib/evc_rails/version.rb +1 -1
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e15aa81852a8394bcc2a904cff923f3d5a5f588c618a2c0c6112f33203b0bd2c
|
4
|
+
data.tar.gz: cdd1c962213f4c2a6593a522ac2aee9cc793858f4759c218242169a26fd40ace
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 91a5a791151b6e2290dc3a20a8e95980a0821bf2526314a3b07724e85dda06f11a6149bfd9d808eb4af9e67da90d80616ddb9fa799d2c61ebc0e908e4cb627ce
|
7
|
+
data.tar.gz: 0bc11c9d4026d5357b5d86089884eff7d30bf82a66823ec164aa3600cbf2ca6774c835c18175a132225acd9204cd95879f76a5698fbc80e355a46ba1ac771e85
|
data/README.md
CHANGED
@@ -1,201 +1,316 @@
|
|
1
|
-
#
|
1
|
+
# EVC Rails
|
2
2
|
|
3
|
-
|
3
|
+
Embedded ViewComponents (EVC) is a Rails template handler that brings JSX-like syntax to ViewComponent, allowing you to write PascalCase component tags directly in your `.evc` templates.
|
4
4
|
|
5
|
-
##
|
5
|
+
## Drop-in ERB Replacement
|
6
|
+
|
7
|
+
EVC templates are a **drop-in replacement** for `.erb` files. All ERB features are fully supported:
|
8
|
+
|
9
|
+
- `<%= %>` and `<% %>` tags
|
10
|
+
- Ruby expressions and control flow
|
11
|
+
- Helper methods (`link_to`, `form_with`, etc.)
|
12
|
+
- Partials (`<%= render 'partial' %>`)
|
13
|
+
- Layouts and content_for blocks
|
6
14
|
|
7
|
-
|
15
|
+
The template handler processes EVC syntax first, then passes the result to the standard ERB handler for final rendering.
|
8
16
|
|
9
|
-
|
17
|
+
## Works with Existing ViewComponents
|
10
18
|
|
11
|
-
|
19
|
+
EVC works seamlessly with **any ViewComponents you already have** in `app/components`. Simply install the gem and start using easier syntax:
|
12
20
|
|
13
|
-
|
21
|
+
```ruby
|
22
|
+
# Your existing ViewComponent (no changes needed)
|
23
|
+
class ButtonComponent < ViewComponent::Base
|
24
|
+
def initialize(variant: "default", size: "md")
|
25
|
+
@variant = variant
|
26
|
+
@size = size
|
27
|
+
end
|
28
|
+
end
|
29
|
+
```
|
14
30
|
|
15
|
-
|
31
|
+
```erb
|
32
|
+
<!-- Now you can use it with EVC syntax -->
|
33
|
+
<Button variant="primary" size="lg">Click me</Button>
|
34
|
+
```
|
16
35
|
|
17
|
-
|
36
|
+
No component modifications required - just install and enjoy easier syntax!
|
37
|
+
|
38
|
+
## Features
|
39
|
+
|
40
|
+
- **JSX-like syntax** for ViewComponent tags
|
41
|
+
- **Self-closing components**: `<Button />`
|
42
|
+
- **Block components**: `<Container>content</Container>`
|
43
|
+
- **Attributes**: String, Ruby expressions, and multiple attributes
|
44
|
+
- **Namespaced components**: `<UI::Button />`, `<Forms::Fields::TextField />`
|
45
|
+
- **Slot support**: `<Card::Header>...</Card::Header>` with `renders_one` and `renders_many`
|
46
|
+
- **Deep nesting**: Complex component hierarchies
|
47
|
+
- **Production-ready caching** with Rails.cache integration
|
48
|
+
- **Better error messages** with line numbers and column positions
|
18
49
|
|
19
50
|
## Installation
|
20
51
|
|
21
52
|
Add this line to your application's Gemfile:
|
22
53
|
|
23
|
-
```
|
54
|
+
```ruby
|
24
55
|
gem 'evc_rails'
|
25
56
|
```
|
26
57
|
|
27
58
|
And then execute:
|
28
59
|
|
60
|
+
```bash
|
61
|
+
$ bundle install
|
29
62
|
```
|
30
|
-
bundle install
|
31
|
-
```
|
32
63
|
|
33
|
-
|
64
|
+
The template handler will be automatically registered for `.evc` files.
|
65
|
+
|
66
|
+
## Usage
|
67
|
+
|
68
|
+
### Basic Components
|
69
|
+
|
70
|
+
Create `.evc` files in your `app/views` directory:
|
34
71
|
|
72
|
+
```erb
|
73
|
+
<!-- app/views/pages/home.evc -->
|
74
|
+
<h1>Welcome to our app</h1>
|
75
|
+
|
76
|
+
<Button size="lg" variant="primary">Get Started</Button>
|
77
|
+
|
78
|
+
<Card>
|
79
|
+
<h2>Featured Content</h2>
|
80
|
+
<p>This is some amazing content.</p>
|
81
|
+
</Card>
|
35
82
|
```
|
36
|
-
|
83
|
+
|
84
|
+
This becomes:
|
85
|
+
|
86
|
+
```erb
|
87
|
+
<h1>Welcome to our app</h1>
|
88
|
+
|
89
|
+
<%= render ButtonComponent.new(size: "lg", variant: "primary") do %>
|
90
|
+
Get Started
|
91
|
+
<% end %>
|
92
|
+
|
93
|
+
<%= render CardComponent.new do %>
|
94
|
+
<h2>Featured Content</h2>
|
95
|
+
<p>This is some amazing content.</p>
|
96
|
+
<% end %>
|
37
97
|
```
|
38
98
|
|
39
|
-
|
99
|
+
### Self-Closing Components
|
40
100
|
|
41
|
-
|
42
|
-
|
101
|
+
```erb
|
102
|
+
<Button />
|
103
|
+
<Icon name="star" />
|
104
|
+
<Spacer height="20" />
|
105
|
+
```
|
43
106
|
|
44
|
-
|
45
|
-
# app/components/my_component_component.rb
|
46
|
-
class MyComponentComponent < ViewComponent::Base
|
47
|
-
def initialize(title:)
|
48
|
-
@title = title
|
49
|
-
end
|
107
|
+
Becomes:
|
50
108
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
109
|
+
```erb
|
110
|
+
<%= render ButtonComponent.new %>
|
111
|
+
<%= render IconComponent.new(name: "star") %>
|
112
|
+
<%= render SpacerComponent.new(height: "20") %>
|
58
113
|
```
|
59
114
|
|
60
|
-
|
115
|
+
### Attributes
|
116
|
+
|
117
|
+
#### String Attributes
|
61
118
|
|
62
119
|
```erb
|
63
|
-
|
64
|
-
<div class="my-component">
|
65
|
-
<h2><%= @title %></h2>
|
66
|
-
<%= content %>
|
67
|
-
</div>
|
120
|
+
<Button size="lg" variant="primary" />
|
68
121
|
```
|
69
122
|
|
70
|
-
|
71
|
-
Now, create a template file with the .evc extension. For instance, app/views/pages/home.html.evc:
|
123
|
+
#### Ruby Expressions
|
72
124
|
|
73
125
|
```erb
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
<MyComponent title="Hello World">
|
78
|
-
<p>This is some content passed to the component.</p>
|
79
|
-
<button text="Click Me" />
|
80
|
-
</MyComponent>
|
81
|
-
|
82
|
-
<%# Namespaced components for better organization %>
|
83
|
-
<Ui::Button text="Click Me" />
|
84
|
-
<Forms::Input name="email" placeholder="Enter your email" />
|
85
|
-
<Layout::Container>
|
86
|
-
<p>Content inside a layout container</p>
|
87
|
-
</Layout::Container>
|
126
|
+
<Button user={@current_user} count={@items.count} />
|
127
|
+
```
|
88
128
|
|
89
|
-
|
90
|
-
<DoughnutChart rings={@progress_data} />
|
129
|
+
#### Multiple Attributes
|
91
130
|
|
92
|
-
|
93
|
-
<
|
131
|
+
```erb
|
132
|
+
<Card class="shadow-lg" data-testid="featured-card" user={@user}>
|
133
|
+
Content here
|
134
|
+
</Card>
|
94
135
|
```
|
95
136
|
|
96
|
-
|
97
|
-
Make sure your app/components directory is eager-loaded in production. In config/application.rb or an initializer:
|
137
|
+
### Namespaced Components
|
98
138
|
|
99
|
-
|
100
|
-
|
101
|
-
|
139
|
+
Organize your components in subdirectories:
|
140
|
+
|
141
|
+
```erb
|
142
|
+
<UI::Button size="lg" />
|
143
|
+
<Forms::Fields::TextField value={@email} />
|
144
|
+
<Layout::Container class="max-w-4xl">
|
145
|
+
<UI::Card>Content</UI::Card>
|
146
|
+
</Layout::Container>
|
102
147
|
```
|
103
148
|
|
104
|
-
|
149
|
+
This maps to:
|
150
|
+
|
151
|
+
- `app/components/ui/button_component.rb`
|
152
|
+
- `app/components/forms/fields/text_field_component.rb`
|
153
|
+
- `app/components/layout/container_component.rb`
|
154
|
+
|
155
|
+
### Slot Support
|
105
156
|
|
106
|
-
|
157
|
+
#### Single Slots (`renders_one`)
|
107
158
|
|
159
|
+
```ruby
|
160
|
+
# app/components/card_component.rb
|
161
|
+
class CardComponent < ViewComponent::Base
|
162
|
+
renders_one :header
|
163
|
+
renders_one :body
|
164
|
+
end
|
108
165
|
```
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
│ ├── container_component.rb
|
120
|
-
│ ├── header_component.rb
|
121
|
-
│ └── footer_component.rb
|
122
|
-
└── my_component_component.rb
|
166
|
+
|
167
|
+
```erb
|
168
|
+
<Card>
|
169
|
+
<Card::Header>
|
170
|
+
<h1>Welcome</h1>
|
171
|
+
</Card::Header>
|
172
|
+
<Card::Body>
|
173
|
+
<p>This is the body content.</p>
|
174
|
+
</Card::Body>
|
175
|
+
</Card>
|
123
176
|
```
|
124
177
|
|
125
|
-
|
178
|
+
Becomes:
|
126
179
|
|
127
180
|
```erb
|
128
|
-
|
129
|
-
|
130
|
-
<
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
181
|
+
<%= render CardComponent.new do |c| %>
|
182
|
+
<% c.header do %>
|
183
|
+
<h1>Welcome</h1>
|
184
|
+
<% end %>
|
185
|
+
<% c.body do %>
|
186
|
+
<p>This is the body content.</p>
|
187
|
+
<% end %>
|
188
|
+
<% end %>
|
135
189
|
```
|
136
190
|
|
137
|
-
|
191
|
+
#### Multiple Slots (`renders_many`)
|
138
192
|
|
139
|
-
|
193
|
+
```ruby
|
194
|
+
# app/components/list_component.rb
|
195
|
+
class ListComponent < ViewComponent::Base
|
196
|
+
renders_many :items
|
197
|
+
end
|
198
|
+
```
|
140
199
|
|
141
200
|
```erb
|
142
|
-
<
|
201
|
+
<List>
|
202
|
+
<List::Item>Item 1</List::Item>
|
203
|
+
<List::Item>Item 2</List::Item>
|
204
|
+
<List::Item>Item 3</List::Item>
|
205
|
+
</List>
|
143
206
|
```
|
144
207
|
|
145
|
-
|
208
|
+
Becomes:
|
146
209
|
|
147
|
-
```
|
148
|
-
<%= render
|
149
|
-
|
210
|
+
```erb
|
211
|
+
<%= render ListComponent.new do |c| %>
|
212
|
+
<% c.item do %>Item 1<% end %>
|
213
|
+
<% c.item do %>Item 2<% end %>
|
214
|
+
<% c.item do %>Item 3<% end %>
|
150
215
|
<% end %>
|
151
216
|
```
|
152
217
|
|
218
|
+
### Complex Nesting
|
219
|
+
|
153
220
|
```erb
|
154
|
-
<
|
221
|
+
<UI::Card>
|
222
|
+
<h2 class="text-2xl font-semibold">Dashboard</h2>
|
223
|
+
|
224
|
+
<UI::Grid cols="3" gap="md">
|
225
|
+
<UI::Card shadow="sm">
|
226
|
+
<p class="text-center">Widget 1</p>
|
227
|
+
</UI::Card>
|
228
|
+
<UI::Card shadow="sm">
|
229
|
+
<p class="text-center">Widget 2</p>
|
230
|
+
</UI::Card>
|
231
|
+
<UI::Card shadow="sm">
|
232
|
+
<p class="text-center">Widget 3</p>
|
233
|
+
</UI::Card>
|
234
|
+
</UI::Grid>
|
235
|
+
</UI::Card>
|
155
236
|
```
|
156
237
|
|
157
|
-
|
238
|
+
### Mixed Content
|
158
239
|
|
159
|
-
|
160
|
-
<%= render ButtonComponent.new(text: "Click Me") %>
|
161
|
-
```
|
240
|
+
You can mix regular HTML, ERB, and component tags:
|
162
241
|
|
163
242
|
```erb
|
164
|
-
<
|
243
|
+
<div class="container">
|
244
|
+
<h1><%= @page.title %></h1>
|
245
|
+
|
246
|
+
<% if @show_featured %>
|
247
|
+
<FeaturedCard />
|
248
|
+
<% end %>
|
249
|
+
|
250
|
+
<div class="grid">
|
251
|
+
<% @posts.each do |post| %>
|
252
|
+
<PostCard post={post} />
|
253
|
+
<% end %>
|
254
|
+
</div>
|
255
|
+
</div>
|
165
256
|
```
|
166
257
|
|
167
|
-
|
258
|
+
## Error Handling
|
168
259
|
|
169
|
-
|
170
|
-
|
260
|
+
The template handler provides detailed error messages with line numbers and column positions:
|
261
|
+
|
262
|
+
```
|
263
|
+
ArgumentError: Unmatched closing tag </Button> at line 15, column 8
|
264
|
+
ArgumentError: Unclosed tag <Card> at line 10, column 1
|
265
|
+
ArgumentError: No matching opening tag for </Container> at line 20, column 5
|
171
266
|
```
|
172
267
|
|
173
|
-
|
174
|
-
|
268
|
+
## Caching
|
269
|
+
|
270
|
+
Templates are automatically cached in production environments using `Rails.cache`. The cache is keyed by template identifier and source content hash, ensuring cache invalidation when templates change.
|
271
|
+
|
272
|
+
### Cache Management
|
273
|
+
|
274
|
+
Clear the template cache:
|
275
|
+
|
276
|
+
```ruby
|
277
|
+
Rails.cache.clear
|
175
278
|
```
|
176
279
|
|
177
|
-
|
280
|
+
Or clear specific template patterns:
|
178
281
|
|
179
282
|
```ruby
|
180
|
-
|
283
|
+
Rails.cache.delete_matched("evc_rails_template:*")
|
181
284
|
```
|
182
285
|
|
183
|
-
|
286
|
+
## Development
|
287
|
+
|
288
|
+
### Running Tests
|
184
289
|
|
185
|
-
|
290
|
+
```bash
|
291
|
+
bundle exec ruby test/unit/template_handler_test.rb
|
292
|
+
```
|
186
293
|
|
187
|
-
|
294
|
+
### Building the Gem
|
188
295
|
|
189
|
-
|
296
|
+
```bash
|
297
|
+
gem build evc_rails.gemspec
|
298
|
+
```
|
190
299
|
|
191
|
-
|
300
|
+
## Requirements
|
192
301
|
|
193
|
-
|
302
|
+
- Rails 6.0+
|
303
|
+
- Ruby 3.1+
|
304
|
+
- ViewComponent 2.0+
|
194
305
|
|
195
306
|
## Contributing
|
196
307
|
|
197
|
-
|
308
|
+
1. Fork the repository
|
309
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
310
|
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
311
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
312
|
+
5. Open a Pull Request
|
198
313
|
|
199
314
|
## License
|
200
315
|
|
201
|
-
The gem is available as open source under the terms of the MIT License
|
316
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/evc_rails/railtie.rb
CHANGED
@@ -3,27 +3,23 @@
|
|
3
3
|
# This file defines a Rails::Railtie, which integrates the evc_rails gem
|
4
4
|
# with the Rails framework during its boot process.
|
5
5
|
|
6
|
-
|
6
|
+
require_relative "template_handler"
|
7
7
|
require "action_view" # Ensure ActionView is loaded for handler registration
|
8
8
|
|
9
9
|
module EvcRails
|
10
10
|
class Railtie < Rails::Railtie
|
11
11
|
# Register MIME type and template handler early in the initialization process
|
12
12
|
initializer "evc_rails.register_template_handler", before: :load_config_initializers do
|
13
|
-
Rails.logger.info "Registering EVC template handler"
|
14
13
|
# Register a unique MIME type for .evc templates
|
15
14
|
Mime::Type.register "text/evc", :evc, %w[text/evc], %w[evc]
|
16
15
|
|
17
16
|
# Register the template handler
|
18
17
|
handler = EvcRails::TemplateHandlers::Evc.new
|
19
18
|
ActionView::Template.register_template_handler(:evc, handler)
|
20
|
-
|
21
|
-
Rails.logger.info "Template handler registered: #{handler.inspect}"
|
22
19
|
end
|
23
20
|
|
24
21
|
# Finalize configuration after Rails initialization
|
25
22
|
config.after_initialize do
|
26
|
-
Rails.logger.info "Configuring EVC template handlers"
|
27
23
|
# Ensure :evc is prioritized in default handlers
|
28
24
|
config.action_view.default_template_handlers ||= []
|
29
25
|
config.action_view.default_template_handlers.prepend(:evc)
|
@@ -1,204 +1,256 @@
|
|
1
1
|
# lib/evc_rails/template_handler.rb
|
2
2
|
#
|
3
|
-
#
|
4
|
-
#
|
5
|
-
# Handles .evc templates, converting PascalCase tags (self-closing <MyComponent attr={value} />
|
6
|
-
# or container <MyComponent>content</MyComponent>) into Rails View Component renders.
|
7
|
-
# Supports string literals and Ruby expressions as attribute values. The handler is
|
8
|
-
# agnostic to specific component APIs, passing content as a block for the component
|
9
|
-
# to interpret via yield or custom methods.
|
3
|
+
# Simple template handler for .evc files
|
4
|
+
# Converts PascalCase tags into Rails View Component renders
|
10
5
|
|
11
6
|
module EvcRails
|
12
7
|
module TemplateHandlers
|
13
8
|
class Evc
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
# Regex for attributes: Supports string literals (key="value", key='value')
|
23
|
-
# and Ruby expressions (key={@variable}).
|
24
|
-
# Group 1: Attribute key, Group 2: Double-quoted value, Group 3: Single-quoted value,
|
25
|
-
# Group 4: Ruby expression.
|
9
|
+
# Simple regex to match PascalCase component tags
|
10
|
+
TAG_REGEX = %r{<([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)([^>]*)/?>}
|
11
|
+
|
12
|
+
# Regex to match closing tags
|
13
|
+
CLOSE_TAG_REGEX = %r{</([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)>}
|
14
|
+
|
15
|
+
# Regex for attributes
|
26
16
|
ATTRIBUTE_REGEX = /(\w+)=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/
|
27
17
|
|
28
|
-
# Cache for compiled templates
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
18
|
+
# Cache for compiled templates
|
19
|
+
@template_cache = {}
|
20
|
+
@cache_mutex = Mutex.new
|
21
|
+
|
22
|
+
require "active_support/cache"
|
23
|
+
|
24
|
+
def self.clear_cache
|
25
|
+
@cache_mutex.synchronize do
|
26
|
+
@template_cache.clear
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.cache_stats
|
31
|
+
@cache_mutex.synchronize do
|
32
|
+
{
|
33
|
+
size: @template_cache.size,
|
34
|
+
keys: @template_cache.keys
|
35
|
+
}
|
36
|
+
end
|
37
|
+
end
|
34
38
|
|
35
|
-
def
|
36
|
-
|
39
|
+
def cache_store
|
40
|
+
if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
|
41
|
+
Rails.cache
|
42
|
+
else
|
43
|
+
@fallback_cache ||= ActiveSupport::Cache::MemoryStore.new
|
44
|
+
end
|
37
45
|
end
|
38
46
|
|
39
47
|
def call(template, source = nil)
|
40
|
-
|
41
|
-
|
42
|
-
#
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
return self.class.instance_variable_get(:@cache)[identifier][:result]
|
48
|
+
source ||= template.source
|
49
|
+
|
50
|
+
# Check cache first (only in non-development environments)
|
51
|
+
unless Rails.env.development?
|
52
|
+
cache_key = "evc_rails_template:#{template.identifier}:#{Digest::MD5.hexdigest(source)}"
|
53
|
+
cached_result = cache_store.read(cache_key)
|
54
|
+
return cached_result if cached_result
|
48
55
|
end
|
49
56
|
|
50
|
-
# Process the template
|
57
|
+
# Process the template and convert to ERB
|
51
58
|
processed_source = process_template(source, template)
|
52
59
|
|
53
|
-
#
|
60
|
+
# Use the standard ERB handler to compile the processed source
|
54
61
|
erb_handler = ActionView::Template.registered_template_handler(:erb)
|
55
62
|
result = erb_handler.call(template, processed_source)
|
56
63
|
|
57
|
-
# Cache the result
|
58
|
-
# In development, templates change frequently, so caching would hinder development flow.
|
64
|
+
# Cache the result (only in non-development environments)
|
59
65
|
unless Rails.env.development?
|
60
|
-
|
61
|
-
|
62
|
-
}))
|
66
|
+
cache_key = "evc_rails_template:#{template.identifier}:#{Digest::MD5.hexdigest(source)}"
|
67
|
+
cache_store.write(cache_key, result)
|
63
68
|
end
|
64
|
-
result
|
65
|
-
end
|
66
|
-
|
67
|
-
private
|
68
|
-
|
69
|
-
# A memoization cache for resolved component classes within a single template processing.
|
70
|
-
# This prevents repeated `constantize` calls for the same component name within `process_template`.
|
71
|
-
attr_reader :component_class_memo
|
72
69
|
|
73
|
-
|
74
|
-
@component_class_memo = {}
|
70
|
+
normalize_whitespace(result)
|
75
71
|
end
|
76
72
|
|
77
|
-
|
78
|
-
def resolve_component_class_name(tag_name)
|
79
|
-
if tag_name.include?("::")
|
80
|
-
# For namespaced components, just append Component
|
81
|
-
"#{tag_name}Component"
|
82
|
-
elsif tag_name.end_with?("Component")
|
83
|
-
tag_name
|
84
|
-
else
|
85
|
-
"#{tag_name}Component"
|
86
|
-
end
|
87
|
-
end
|
73
|
+
private
|
88
74
|
|
89
|
-
# Processes the .evc template source, converting custom tags into Rails View Component render calls.
|
90
|
-
# This method is recursive to handle nested components.
|
91
75
|
def process_template(source, template)
|
92
|
-
#
|
93
|
-
|
94
|
-
|
76
|
+
# Pre-compile regexes for better performance
|
77
|
+
tag_regex = TAG_REGEX
|
78
|
+
close_tag_regex = CLOSE_TAG_REGEX
|
79
|
+
attribute_regex = ATTRIBUTE_REGEX
|
80
|
+
|
81
|
+
# Use String buffer for better performance
|
82
|
+
result = String.new
|
83
|
+
stack = []
|
95
84
|
pos = 0
|
96
|
-
|
85
|
+
source_length = source.length
|
97
86
|
|
98
|
-
#
|
99
|
-
|
87
|
+
# Track line numbers for better error messages
|
88
|
+
def line_number_at_position(source, pos)
|
89
|
+
source[0...pos].count("\n") + 1
|
90
|
+
end
|
100
91
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
parts << source[pos...match.begin(0)]
|
106
|
-
pos = match.end(0) # Move position past the matched tag
|
92
|
+
def column_number_at_position(source, pos)
|
93
|
+
last_newline = source[0...pos].rindex("\n")
|
94
|
+
last_newline ? pos - last_newline : pos + 1
|
95
|
+
end
|
107
96
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
# Determine the full component class name
|
113
|
-
component_class_name = resolve_component_class_name(tag_name)
|
114
|
-
|
115
|
-
# Validate if the component class exists using memoization.
|
116
|
-
# The component class will only be constantized once per unique class name
|
117
|
-
# within a single template processing call.
|
118
|
-
component_class = @component_class_memo[component_class_name] ||= begin
|
119
|
-
component_class_name.constantize
|
120
|
-
rescue NameError
|
121
|
-
raise ArgumentError, "Component #{component_class_name} not found in template #{template.identifier}"
|
122
|
-
end
|
97
|
+
while pos < source_length
|
98
|
+
# Find next opening or closing tag
|
99
|
+
next_open = tag_regex.match(source, pos)
|
100
|
+
next_close = close_tag_regex.match(source, pos)
|
123
101
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
end
|
102
|
+
if next_open && (!next_close || next_open.begin(0) < next_close.begin(0))
|
103
|
+
# Found opening tag
|
104
|
+
match = next_open
|
105
|
+
tag_name = match[1]
|
106
|
+
attributes_str = match[2].to_s.strip
|
107
|
+
is_self_closing = match[0].end_with?("/>")
|
131
108
|
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
109
|
+
# Add content before the tag
|
110
|
+
result << source[pos...match.begin(0)] if pos < match.begin(0)
|
111
|
+
|
112
|
+
# Determine if this is a slot (e.g., Card::Header inside Card)
|
113
|
+
parent = stack.last
|
114
|
+
is_slot = false
|
115
|
+
slot_name = nil
|
116
|
+
slot_parent = nil
|
117
|
+
if parent
|
118
|
+
parent_tag = parent[0]
|
119
|
+
if tag_name.start_with?("#{parent_tag}::")
|
120
|
+
is_slot = true
|
121
|
+
slot_name = tag_name.split("::").last.downcase
|
122
|
+
slot_parent = parent_tag
|
123
|
+
# Mark parent as having a slot
|
124
|
+
parent[6] = true
|
143
125
|
end
|
144
126
|
end
|
145
127
|
|
146
|
-
render_params_str = params.join(", ")
|
147
|
-
|
148
128
|
if is_self_closing
|
149
|
-
|
150
|
-
|
129
|
+
if is_slot
|
130
|
+
params = parse_attributes(attributes_str, attribute_regex)
|
131
|
+
param_str = params.join(", ")
|
132
|
+
result << if param_str.empty?
|
133
|
+
"<% c.#{slot_name} do %><% end %>"
|
134
|
+
else
|
135
|
+
"<% c.#{slot_name}(#{param_str}) do %><% end %>"
|
136
|
+
end
|
137
|
+
else
|
138
|
+
component_class = "#{tag_name}Component"
|
139
|
+
params = parse_attributes(attributes_str, attribute_regex)
|
140
|
+
param_str = params.join(", ")
|
141
|
+
result << if param_str.empty?
|
142
|
+
"<%= render #{component_class}.new %>"
|
143
|
+
else
|
144
|
+
"<%= render #{component_class}.new(#{param_str}) %>"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
elsif is_slot
|
148
|
+
params = parse_attributes(attributes_str, attribute_regex)
|
149
|
+
param_str = params.join(", ")
|
150
|
+
stack << [tag_name, nil, param_str, result.length, :slot, slot_name, false, match.begin(0)]
|
151
|
+
result << if param_str.empty?
|
152
|
+
"<% c.#{slot_name} do %>"
|
153
|
+
else
|
154
|
+
"<% c.#{slot_name}(#{param_str}) do %>"
|
155
|
+
end
|
151
156
|
else
|
152
|
-
|
153
|
-
|
157
|
+
component_class = "#{tag_name}Component"
|
158
|
+
params = parse_attributes(attributes_str, attribute_regex)
|
159
|
+
param_str = params.join(", ")
|
160
|
+
# If this is the outermost component, add |c| for slot support only if a slot is used
|
161
|
+
if stack.empty?
|
162
|
+
stack << [tag_name, component_class, param_str, result.length, :component, nil, false, match.begin(0)] # [tag_name, class, params, pos, type, slot_name, slot_used, open_pos]
|
163
|
+
# We'll patch in |c| at close if needed
|
164
|
+
result << if param_str.empty?
|
165
|
+
"<%= render #{component_class}.new do %>"
|
166
|
+
else
|
167
|
+
"<%= render #{component_class}.new(#{param_str}) do %>"
|
168
|
+
end
|
169
|
+
else
|
170
|
+
stack << [tag_name, component_class, param_str, result.length, :component, nil, false, match.begin(0)]
|
171
|
+
result << if param_str.empty?
|
172
|
+
"<%= render #{component_class}.new do %>"
|
173
|
+
else
|
174
|
+
"<%= render #{component_class}.new(#{param_str}) do %>"
|
175
|
+
end
|
176
|
+
end
|
154
177
|
end
|
155
178
|
|
156
|
-
|
157
|
-
elsif
|
158
|
-
#
|
159
|
-
|
160
|
-
pos = match.end(0) # Move position past the matched tag
|
161
|
-
|
179
|
+
pos = match.end(0)
|
180
|
+
elsif next_close
|
181
|
+
# Found closing tag
|
182
|
+
match = next_close
|
162
183
|
closing_tag_name = match[1]
|
163
184
|
|
164
|
-
#
|
185
|
+
# Add content before the closing tag
|
186
|
+
result << source[pos...match.begin(0)] if pos < match.begin(0)
|
187
|
+
|
188
|
+
# Find matching opening tag
|
165
189
|
if stack.empty?
|
166
|
-
|
190
|
+
line = line_number_at_position(source, match.begin(0))
|
191
|
+
col = column_number_at_position(source, match.begin(0))
|
192
|
+
raise ArgumentError, "Unmatched closing tag </#{closing_tag_name}> at line #{line}, column #{col}"
|
167
193
|
end
|
168
194
|
|
169
|
-
#
|
170
|
-
|
195
|
+
# Find the matching opening tag (from the end)
|
196
|
+
matching_index = stack.rindex { |(tag_name, _, _, _, _, _, _, _)| tag_name == closing_tag_name }
|
197
|
+
if matching_index.nil?
|
198
|
+
line = line_number_at_position(source, match.begin(0))
|
199
|
+
col = column_number_at_position(source, match.begin(0))
|
200
|
+
raise ArgumentError, "No matching opening tag for </#{closing_tag_name}> at line #{line}, column #{col}"
|
201
|
+
end
|
171
202
|
|
172
|
-
#
|
173
|
-
|
203
|
+
# Pop the matching opening tag
|
204
|
+
tag_name, component_class, param_str, start_pos, type, slot_name, slot_used, open_pos = stack.delete_at(matching_index)
|
174
205
|
|
175
|
-
#
|
176
|
-
if
|
177
|
-
#
|
178
|
-
|
179
|
-
|
180
|
-
"Mismatched tags: expected </#{expected_tag_name}>, got </#{closing_tag_name}> in template #{template.identifier}"
|
206
|
+
# Patch in |c| for top-level component if a slot was used
|
207
|
+
if type == :component && stack.empty? && slot_used
|
208
|
+
# Find the opening block and insert |c|
|
209
|
+
open_block_regex = /(<%= render #{component_class}\.new(?:\(.*?\))? do)( %>)/
|
210
|
+
result.sub!(open_block_regex) { "#{::Regexp.last_match(1)} |c|#{::Regexp.last_match(2)}" }
|
181
211
|
end
|
182
212
|
|
183
|
-
#
|
184
|
-
|
185
|
-
content = process_template(source[start_pos...match.begin(0)], template)
|
186
|
-
|
187
|
-
# Generate the render call with a block for the content.
|
188
|
-
parts << "<%= render #{component_class_name}.new(#{render_params_str}) do %>#{content}<% end %>"
|
213
|
+
# Add closing block
|
214
|
+
result << (type == :slot ? "<% end %>" : "<% end %>")
|
189
215
|
|
216
|
+
pos = match.end(0)
|
190
217
|
else
|
191
|
-
#
|
192
|
-
|
218
|
+
# No more tags, add remaining content
|
219
|
+
result << source[pos..-1] if pos < source_length
|
193
220
|
break
|
194
221
|
end
|
195
222
|
end
|
196
223
|
|
197
|
-
#
|
198
|
-
|
224
|
+
# Check for unclosed tags
|
225
|
+
unless stack.empty?
|
226
|
+
unclosed_tag = stack.last
|
227
|
+
open_pos = unclosed_tag[7]
|
228
|
+
line = line_number_at_position(source, open_pos)
|
229
|
+
col = column_number_at_position(source, open_pos)
|
230
|
+
raise ArgumentError, "Unclosed tag <#{unclosed_tag[0]}> at line #{line}, column #{col}"
|
231
|
+
end
|
199
232
|
|
200
|
-
|
201
|
-
|
233
|
+
result
|
234
|
+
end
|
235
|
+
|
236
|
+
def normalize_whitespace(erb_string)
|
237
|
+
# For now, return the string as-is to avoid breaking existing functionality
|
238
|
+
# We can add proper whitespace normalization later if needed
|
239
|
+
erb_string
|
240
|
+
end
|
241
|
+
|
242
|
+
def parse_attributes(attributes_str, attribute_regex = ATTRIBUTE_REGEX)
|
243
|
+
params = []
|
244
|
+
attributes_str.scan(attribute_regex) do |key, quoted_value, single_quoted_value, ruby_expression|
|
245
|
+
if ruby_expression
|
246
|
+
params << "#{key}: #{ruby_expression}"
|
247
|
+
elsif quoted_value
|
248
|
+
params << "#{key}: \"#{quoted_value.gsub('"', '\\"')}\""
|
249
|
+
elsif single_quoted_value
|
250
|
+
params << "#{key}: \"#{single_quoted_value.gsub("'", "\\'")}\""
|
251
|
+
end
|
252
|
+
end
|
253
|
+
params
|
202
254
|
end
|
203
255
|
end
|
204
256
|
end
|
data/lib/evc_rails/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: evc_rails
|
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
|
- scttymn
|
@@ -65,9 +65,11 @@ dependencies:
|
|
65
65
|
- - ">="
|
66
66
|
- !ruby/object:Gem::Version
|
67
67
|
version: '2.0'
|
68
|
-
description:
|
69
|
-
allowing
|
70
|
-
|
68
|
+
description: Embedded ViewComponents (EVC) is a Rails template handler that brings
|
69
|
+
JSX-like syntax to ViewComponent, allowing you to write custom component tags directly
|
70
|
+
in your .evc templates. It's a drop-in replacement for .erb files that works seamlessly
|
71
|
+
with existing ViewComponents, supporting self-closing tags, attributes, namespaced
|
72
|
+
components, slots, and complex nesting while maintaining full ERB compatibility.
|
71
73
|
email:
|
72
74
|
- scotty@hey.com
|
73
75
|
executables: []
|
@@ -107,5 +109,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
109
|
requirements: []
|
108
110
|
rubygems_version: 3.6.9
|
109
111
|
specification_version: 4
|
110
|
-
summary:
|
112
|
+
summary: JSX-like syntax for Rails ViewComponent
|
111
113
|
test_files: []
|