evc_rails 0.1.1 → 0.1.3
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 +232 -117
- data/lib/evc_rails/railtie.rb +1 -5
- data/lib/evc_rails/template_handler.rb +199 -138
- data/lib/evc_rails/version.rb +1 -1
- metadata +1 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '08062fcb10857de4a7b864d324e705430a6eb3fe7712c2ed893a482dbbc1e604'
|
4
|
+
data.tar.gz: cbe0310f402b96fb62fae7a21647a149ec871539b476527615e72a1724402c79
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a22db0b9bcd33d36b16908482b6eafc12cbbe684e588f9ab19dadeffdc6ac43672f8c026ed23a662cb7babe324abb0ca7d97195bc23a91d54c2f091225fcb472
|
7
|
+
data.tar.gz: 3445cef47452e7349d9ed6abe58dcdbe974d5c91097634fb5577ab0947cef60d863ff62e51e6cf457e2db73cb115e4a379f210d97d63b6785aee2eb0a454f9ce
|
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:
|
20
|
+
|
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
|
+
```
|
12
30
|
|
13
|
-
|
31
|
+
```html
|
32
|
+
<!-- Now you can use it with EVC syntax -->
|
33
|
+
<button variant="primary" size="lg">Click me</button>
|
34
|
+
```
|
14
35
|
|
15
|
-
|
36
|
+
No component modifications required - just install and enjoy easier syntax!
|
16
37
|
|
17
|
-
|
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
|
|
29
|
-
```
|
30
|
-
bundle install
|
60
|
+
```bash
|
61
|
+
$ bundle install
|
31
62
|
```
|
32
63
|
|
33
|
-
|
34
|
-
|
35
|
-
```
|
36
|
-
gem install evc_rails
|
37
|
-
```
|
64
|
+
The template handler will be automatically registered for `.evc` files.
|
38
65
|
|
39
66
|
## Usage
|
40
67
|
|
41
|
-
|
42
|
-
First, ensure you have a Rails View Component. For example, create app/components/my_component_component.rb:
|
68
|
+
### Basic Components
|
43
69
|
|
44
|
-
|
45
|
-
# app/components/my_component_component.rb
|
46
|
-
class MyComponentComponent < ViewComponent::Base
|
47
|
-
def initialize(title:)
|
48
|
-
@title = title
|
49
|
-
end
|
70
|
+
Create `.evc` files in your `app/views` directory:
|
50
71
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
concat content # This renders the block content
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
```
|
72
|
+
```html
|
73
|
+
<!-- app/views/pages/home.evc -->
|
74
|
+
<h1>Welcome to our app</h1>
|
59
75
|
|
60
|
-
|
76
|
+
<button size="lg" variant="primary">Get Started</button>
|
61
77
|
|
62
|
-
|
63
|
-
|
64
|
-
<
|
65
|
-
|
66
|
-
<%= content %>
|
67
|
-
</div>
|
78
|
+
<Card>
|
79
|
+
<h2>Featured Content</h2>
|
80
|
+
<p>This is some amazing content.</p>
|
81
|
+
</Card>
|
68
82
|
```
|
69
83
|
|
70
|
-
|
71
|
-
Now, create a template file with the .evc extension. For instance, app/views/pages/home.html.evc:
|
84
|
+
This becomes:
|
72
85
|
|
73
86
|
```erb
|
74
|
-
|
75
|
-
<h1>Welcome to My App</h1>
|
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>
|
87
|
+
<h1>Welcome to our app</h1>
|
88
88
|
|
89
|
-
|
90
|
-
|
89
|
+
<%= render ButtonComponent.new(size: "lg", variant: "primary") do %>
|
90
|
+
Get Started
|
91
|
+
<% end %>
|
91
92
|
|
92
|
-
|
93
|
-
<
|
93
|
+
<%= render CardComponent.new do %>
|
94
|
+
<h2>Featured Content</h2>
|
95
|
+
<p>This is some amazing content.</p>
|
96
|
+
<% end %>
|
94
97
|
```
|
95
98
|
|
96
|
-
|
97
|
-
Make sure your app/components directory is eager-loaded in production. In config/application.rb or an initializer:
|
99
|
+
### Self-Closing Components
|
98
100
|
|
99
|
-
```
|
100
|
-
|
101
|
-
|
101
|
+
```html
|
102
|
+
<button />
|
103
|
+
<Icon name="star" />
|
104
|
+
<spacer height="20" />
|
102
105
|
```
|
103
106
|
|
104
|
-
|
107
|
+
Becomes:
|
105
108
|
|
106
|
-
|
109
|
+
```erb
|
110
|
+
<%= render ButtonComponent.new %>
|
111
|
+
<%= render IconComponent.new(name: "star") %>
|
112
|
+
<%= render SpacerComponent.new(height: "20") %>
|
113
|
+
```
|
114
|
+
|
115
|
+
### Attributes
|
107
116
|
|
117
|
+
#### String Attributes
|
118
|
+
|
119
|
+
```html
|
120
|
+
<button size="lg" variant="primary" />
|
108
121
|
```
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
├── forms/
|
115
|
-
│ ├── input_component.rb
|
116
|
-
│ ├── select_component.rb
|
117
|
-
│ └── checkbox_component.rb
|
118
|
-
├── layout/
|
119
|
-
│ ├── container_component.rb
|
120
|
-
│ ├── header_component.rb
|
121
|
-
│ └── footer_component.rb
|
122
|
-
└── my_component_component.rb
|
122
|
+
|
123
|
+
#### Ruby Expressions
|
124
|
+
|
125
|
+
```html
|
126
|
+
<button user="{@current_user}" count="{@items.count}" />
|
123
127
|
```
|
124
128
|
|
125
|
-
|
129
|
+
#### Multiple Attributes
|
126
130
|
|
127
|
-
```
|
128
|
-
<
|
129
|
-
|
130
|
-
|
131
|
-
<Layout::Header title="My App" />
|
132
|
-
<p>Main content</p>
|
133
|
-
<Layout::Footer />
|
134
|
-
</Layout::Container>
|
131
|
+
```html
|
132
|
+
<Card class="shadow-lg" data-testid="featured-card" user="{@user}">
|
133
|
+
Content here
|
134
|
+
</Card>
|
135
135
|
```
|
136
136
|
|
137
|
-
|
137
|
+
### Namespaced Components
|
138
138
|
|
139
|
-
|
139
|
+
Organize your components in subdirectories:
|
140
140
|
|
141
|
-
```
|
142
|
-
<
|
141
|
+
```html
|
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>
|
143
147
|
```
|
144
148
|
|
145
|
-
|
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
|
156
|
+
|
157
|
+
#### Single Slots (`renders_one`)
|
146
158
|
|
147
159
|
```ruby
|
148
|
-
|
149
|
-
|
150
|
-
|
160
|
+
# app/components/card_component.rb
|
161
|
+
class CardComponent < ViewComponent::Base
|
162
|
+
renders_one :header
|
163
|
+
renders_one :body
|
164
|
+
end
|
165
|
+
```
|
166
|
+
|
167
|
+
```html
|
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>
|
151
176
|
```
|
152
177
|
|
178
|
+
Becomes:
|
179
|
+
|
153
180
|
```erb
|
154
|
-
|
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 %>
|
155
189
|
```
|
156
190
|
|
157
|
-
|
191
|
+
#### Multiple Slots (`renders_many`)
|
158
192
|
|
159
193
|
```ruby
|
160
|
-
|
194
|
+
# app/components/list_component.rb
|
195
|
+
class ListComponent < ViewComponent::Base
|
196
|
+
renders_many :items
|
197
|
+
end
|
161
198
|
```
|
162
199
|
|
200
|
+
```html
|
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>
|
206
|
+
```
|
207
|
+
|
208
|
+
Becomes:
|
209
|
+
|
163
210
|
```erb
|
164
|
-
|
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 %>
|
215
|
+
<% end %>
|
216
|
+
```
|
217
|
+
|
218
|
+
### Complex Nesting
|
219
|
+
|
220
|
+
```html
|
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>
|
165
236
|
```
|
166
237
|
|
167
|
-
|
238
|
+
### Mixed Content
|
168
239
|
|
169
|
-
|
170
|
-
|
240
|
+
You can mix regular HTML, ERB, and component tags:
|
241
|
+
|
242
|
+
```html
|
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>
|
171
256
|
```
|
172
257
|
|
173
|
-
|
174
|
-
|
258
|
+
## Error Handling
|
259
|
+
|
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
|
266
|
+
```
|
267
|
+
|
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,195 +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
|
-
@cache = {}
|
18
|
+
# Cache for compiled templates
|
19
|
+
@template_cache = {}
|
20
|
+
@cache_mutex = Mutex.new
|
21
|
+
|
22
|
+
require "active_support/cache"
|
34
23
|
|
35
|
-
def self.clear_cache
|
36
|
-
@
|
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
|
38
|
+
|
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
|
-
|
69
|
+
|
70
|
+
normalize_whitespace(result)
|
65
71
|
end
|
66
72
|
|
67
73
|
private
|
68
74
|
|
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
|
-
|
73
|
-
def initialize
|
74
|
-
@component_class_memo = {}
|
75
|
-
end
|
76
|
-
|
77
|
-
# Processes the .evc template source, converting custom tags into Rails View Component render calls.
|
78
|
-
# This method is recursive to handle nested components.
|
79
75
|
def process_template(source, template)
|
80
|
-
#
|
81
|
-
|
82
|
-
|
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 = []
|
83
84
|
pos = 0
|
84
|
-
|
85
|
+
source_length = source.length
|
85
86
|
|
86
|
-
#
|
87
|
-
|
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
|
88
91
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
parts << source[pos...match.begin(0)]
|
94
|
-
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
|
95
96
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
# Determine the full component class name
|
101
|
-
# Handle both namespaced (UI::Button) and non-namespaced (Button) components
|
102
|
-
component_class_name = if tag_name.include?("::")
|
103
|
-
# For namespaced components, just append Component
|
104
|
-
"#{tag_name}Component"
|
105
|
-
elsif tag_name.end_with?("Component")
|
106
|
-
tag_name
|
107
|
-
else
|
108
|
-
"#{tag_name}Component"
|
109
|
-
end
|
110
|
-
|
111
|
-
# Validate if the component class exists using memoization.
|
112
|
-
# The component class will only be constantized once per unique class name
|
113
|
-
# within a single template processing call.
|
114
|
-
component_class = @component_class_memo[component_class_name] ||= begin
|
115
|
-
component_class_name.constantize
|
116
|
-
rescue NameError
|
117
|
-
raise ArgumentError, "Component #{component_class_name} not found in template #{template.identifier}"
|
118
|
-
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)
|
119
101
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
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?("/>")
|
127
108
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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
|
139
125
|
end
|
140
126
|
end
|
141
127
|
|
142
|
-
render_params_str = params.join(", ")
|
143
|
-
|
144
128
|
if is_self_closing
|
145
|
-
|
146
|
-
|
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
|
147
156
|
else
|
148
|
-
|
149
|
-
|
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
|
150
177
|
end
|
151
178
|
|
152
|
-
|
153
|
-
elsif
|
154
|
-
#
|
155
|
-
|
156
|
-
pos = match.end(0) # Move position past the matched tag
|
157
|
-
|
179
|
+
pos = match.end(0)
|
180
|
+
elsif next_close
|
181
|
+
# Found closing tag
|
182
|
+
match = next_close
|
158
183
|
closing_tag_name = match[1]
|
159
184
|
|
160
|
-
#
|
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
|
161
189
|
if stack.empty?
|
162
|
-
|
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}"
|
163
193
|
end
|
164
194
|
|
165
|
-
#
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
raise ArgumentError,
|
171
|
-
"Mismatched tags: expected </#{component_class_name}>, got </#{closing_tag_name}> in template #{template.identifier}"
|
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}"
|
172
201
|
end
|
173
202
|
|
174
|
-
#
|
175
|
-
|
176
|
-
content = process_template(source[start_pos...match.begin(0)], template)
|
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)
|
177
205
|
|
178
|
-
#
|
179
|
-
|
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)}" }
|
211
|
+
end
|
212
|
+
|
213
|
+
# Add closing block
|
214
|
+
result << (type == :slot ? "<% end %>" : "<% end %>")
|
180
215
|
|
216
|
+
pos = match.end(0)
|
181
217
|
else
|
182
|
-
#
|
183
|
-
|
218
|
+
# No more tags, add remaining content
|
219
|
+
result << source[pos..-1] if pos < source_length
|
184
220
|
break
|
185
221
|
end
|
186
222
|
end
|
187
223
|
|
188
|
-
#
|
189
|
-
|
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
|
232
|
+
|
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
|
190
241
|
|
191
|
-
|
192
|
-
|
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
|
193
254
|
end
|
194
255
|
end
|
195
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.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- scttymn
|
@@ -90,7 +90,6 @@ metadata:
|
|
90
90
|
allowed_push_host: https://rubygems.org
|
91
91
|
homepage_uri: https://github.com/scttymn/evc_rails
|
92
92
|
source_code_uri: https://github.com/scttymn/evc_rails
|
93
|
-
changelog_uri: https://github.com/scttymn/evc_rails/blob/main/CHANGELOG.md
|
94
93
|
rdoc_options: []
|
95
94
|
require_paths:
|
96
95
|
- lib
|