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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b8286c7a2de504f1e3320186e144fbeadd0c36cf2b0e56b7c2a677994d3ff80
4
- data.tar.gz: a03f8ac74485749cf933bab0d0fb3abe16e042c41ba065fd6f259d2379a58225
3
+ metadata.gz: e15aa81852a8394bcc2a904cff923f3d5a5f588c618a2c0c6112f33203b0bd2c
4
+ data.tar.gz: cdd1c962213f4c2a6593a522ac2aee9cc793858f4759c218242169a26fd40ace
5
5
  SHA512:
6
- metadata.gz: 9f1256d0f4b2d3ae9d9ac59182dab66c32898f6294e739d23819fc8c590e935f8c2ac5f27a3d5622e50aa5e4657af7618fe8b6bfe97da5baaacb0b296a3d0de3
7
- data.tar.gz: f6284a6a2d33cbbffa09485895cfbd8b5a9b680073565281b95bdbf32caeb599254daece8fe837e13e1c2f96f3eb4f6b2731be6e1a6ea75cb69a2eb5b07399dc
6
+ metadata.gz: 91a5a791151b6e2290dc3a20a8e95980a0821bf2526314a3b07724e85dda06f11a6149bfd9d808eb4af9e67da90d80616ddb9fa799d2c61ebc0e908e4cb627ce
7
+ data.tar.gz: 0bc11c9d4026d5357b5d86089884eff7d30bf82a66823ec164aa3600cbf2ca6774c835c18175a132225acd9204cd95879f76a5698fbc80e355a46ba1ac771e85
data/README.md CHANGED
@@ -1,201 +1,316 @@
1
- # EvcRails
1
+ # EVC Rails
2
2
 
3
- EvcRails is a Rails gem that introduces a custom .evc template handler, allowing you to define your Rails View Components using a concise, HTML-like PascalCase tag syntax, reminiscent of React or other modern component-based UI frameworks. This gem seamlessly integrates your custom tags with Rails View Components, enabling a more declarative and readable approach to building UIs in Rails.
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
- ## Features
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
- PascalCase Component Tags: Define and use your View Components with <MyComponent> or self-closing <MyComponent /> syntax directly in your .evc templates.
15
+ The template handler processes EVC syntax first, then passes the result to the standard ERB handler for final rendering.
8
16
 
9
- Namespaced Components: Support for namespaced components like <Ui::Button /> or <Forms::Input /> for better organization.
17
+ ## Works with Existing ViewComponents
10
18
 
11
- Attribute Handling: Pass attributes to your components using standard HTML-like key="value", key='value', or Ruby expressions key={@variable}.
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
- Content Blocks: Components can accept content blocks (<MyComponent>content</MyComponent>) which are passed to the View Component via a block.
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
- Automatic Component Resolution: Automatically appends "Component" to your tag name if it's not already present (e.g., <Button> resolves to ButtonComponent, <Ui::Button> resolves to Ui::ButtonComponent).
31
+ ```erb
32
+ <!-- Now you can use it with EVC syntax -->
33
+ <Button variant="primary" size="lg">Click me</Button>
34
+ ```
16
35
 
17
- Performance Optimized: Includes in-memory caching of compiled templates and memoization of component class lookups for efficient rendering in production.
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
- Or install it yourself as:
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
- gem install evc_rails
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
- ## Usage
99
+ ### Self-Closing Components
40
100
 
41
- 1. Create a View Component
42
- First, ensure you have a Rails View Component. For example, create app/components/my_component_component.rb:
101
+ ```erb
102
+ <Button />
103
+ <Icon name="star" />
104
+ <Spacer height="20" />
105
+ ```
43
106
 
44
- ```ruby
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
- def call
52
- tag.div(class: "my-component") do
53
- concat tag.h2(@title)
54
- concat content # This renders the block content
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
- And its associated template app/components/my_component_component.html.erb (if you're using separate templates):
115
+ ### Attributes
116
+
117
+ #### String Attributes
61
118
 
62
119
  ```erb
63
- <!-- app/components/my_component_component.html.erb -->
64
- <div class="my-component">
65
- <h2><%= @title %></h2>
66
- <%= content %>
67
- </div>
120
+ <Button size="lg" variant="primary" />
68
121
  ```
69
122
 
70
- 2. Create an .evc Template
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
- <!-- app/views/pages/home.html.evc -->
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>
126
+ <Button user={@current_user} count={@items.count} />
127
+ ```
88
128
 
89
- <%# A more concise way to render your DoughnutChartComponent %>
90
- <DoughnutChart rings={@progress_data} />
129
+ #### Multiple Attributes
91
130
 
92
- <%# You can still use standard ERB within .evc files %>
93
- <p><%= link_to "Go somewhere", some_path %></p>
131
+ ```erb
132
+ <Card class="shadow-lg" data-testid="featured-card" user={@user}>
133
+ Content here
134
+ </Card>
94
135
  ```
95
136
 
96
- 3. Ensure Components are Autoloaded
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
- ```ruby
100
- # config/application.rb
101
- config.eager_load_paths << Rails.root.join("app/components")
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
- ## Component Organization
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
- You can organize your components into namespaces for better structure:
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
- app/components/
110
- ├── ui/
111
- │ ├── button_component.rb
112
- │ ├── card_component.rb
113
- │ └── input_component.rb
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
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
- Then use them in your .evc templates:
178
+ Becomes:
126
179
 
127
180
  ```erb
128
- <Ui::Button text="Submit" />
129
- <Forms::Input name="username" />
130
- <Layout::Container>
131
- <Layout::Header title="My App" />
132
- <p>Main content</p>
133
- <Layout::Footer />
134
- </Layout::Container>
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
- ## How it Works
191
+ #### Multiple Slots (`renders_many`)
138
192
 
139
- When Rails processes an .evc template, EvcRails intercepts it and performs the following transformations:
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
- <Card title="Hello World">... </Card>
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
- becomes:
208
+ Becomes:
146
209
 
147
- ```ruby
148
- <%= render CardComponent.new(title: "Hello World") do %>
149
- ... (processed content) ...
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
- <Button text="Click Me" />
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
- becomes:
238
+ ### Mixed Content
158
239
 
159
- ```ruby
160
- <%= render ButtonComponent.new(text: "Click Me") %>
161
- ```
240
+ You can mix regular HTML, ERB, and component tags:
162
241
 
163
242
  ```erb
164
- <Ui::Button text="Click Me" />
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
- becomes:
258
+ ## Error Handling
168
259
 
169
- ```ruby
170
- <%= render Ui::ButtonComponent.new(text: "Click Me") %>
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
- ```erb
174
- <DoughnutChart rings={@progress_data} />
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
- becomes:
280
+ Or clear specific template patterns:
178
281
 
179
282
  ```ruby
180
- <%= render DoughnutChartComponent.new(rings: @progress_data) %>
283
+ Rails.cache.delete_matched("evc_rails_template:*")
181
284
  ```
182
285
 
183
- `attr={@variable}` becomes `attr: @variable` in the new() call.
286
+ ## Development
287
+
288
+ ### Running Tests
184
289
 
185
- The transformed content is then passed to the standard ERB handler for final rendering.
290
+ ```bash
291
+ bundle exec ruby test/unit/template_handler_test.rb
292
+ ```
186
293
 
187
- ## Configuration
294
+ ### Building the Gem
188
295
 
189
- Currently, EvcRails requires no specific configuration. Future versions might include options for:
296
+ ```bash
297
+ gem build evc_rails.gemspec
298
+ ```
190
299
 
191
- Customizing the component suffix (e.g., if you don't want "Component" appended).
300
+ ## Requirements
192
301
 
193
- Defining custom component lookup paths.
302
+ - Rails 6.0+
303
+ - Ruby 3.1+
304
+ - ViewComponent 2.0+
194
305
 
195
306
  ## Contributing
196
307
 
197
- Bug reports and pull requests are welcome on GitHub at [your-gem-repo-link]. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
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).
@@ -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
- require "evc_rails/template_handler"
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
- # This file contains the core logic for the .evc template handler.
4
- # It's part of the `EvcRails` module to keep it namespaced within the gem.
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
- # Regex to match opening or self-closing PascalCase component tags with attributes
15
- # Updated to support namespaced components like UI::Button
16
- TAG_REGEX = %r{<([A-Z][a-zA-Z_]*(::[A-Z][a-zA-Z_]*)*)([^>]*)/?>}
17
-
18
- # Regex to match closing PascalCase component tags
19
- # Updated to support namespaced components like UI::Button
20
- CLOSE_TAG_REGEX = %r{</([A-Z][a-zA-Z_]*(::[A-Z][a-zA-Z_]*)*)>}
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 to improve performance.
29
- # @cache will store { identifier: { source: original_source, result: compiled_result } }
30
- # Note: In a production environment, a more robust cache store (e.g., Rails.cache)
31
- # might be preferred for persistence and memory management, especially for large applications.
32
- # This simple in-memory cache is effective for a single process.
33
- @cache = {}
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 self.clear_cache # Class method to allow clearing cache manually, if needed
36
- @cache = {}
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
- identifier = template.identifier
41
-
42
- # Only use cache in non-development environments
43
- # Check if cache exists for this template and if the source hasn't changed.
44
- # This prevents recompilation of unchanged templates.
45
- if !Rails.env.development? && self.class.instance_variable_get(:@cache)[identifier] &&
46
- source == self.class.instance_variable_get(:@cache)[identifier][:source]
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 source into an ERB-compatible string
57
+ # Process the template and convert to ERB
51
58
  processed_source = process_template(source, template)
52
59
 
53
- # Get the standard ERB handler and pass the processed source to it
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 for future requests, but only in non-development environments.
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
- self.class.instance_variable_set(:@cache, self.class.instance_variable_get(:@cache).merge({
61
- identifier => { source: source, result: result }
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
- def initialize
74
- @component_class_memo = {}
70
+ normalize_whitespace(result)
75
71
  end
76
72
 
77
- # Helper method to determine the full component class name
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
- # Using an array for `parts` and then joining at the end is generally more efficient
93
- # for building strings in Ruby than repeated string concatenations (`<<`).
94
- parts = []
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
- stack = [] # Track [component_class_name, render_params_str, start_pos_in_source]
85
+ source_length = source.length
97
86
 
98
- # Initialize memoization cache for this processing run
99
- @component_class_memo = {}
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
- while pos < source.length
102
- # Try to match an opening or self-closing tag
103
- if (match = TAG_REGEX.match(source, pos))
104
- # Append text before the current tag to the result parts
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
- is_self_closing = match[0].end_with?("/>")
109
- tag_name = match[1]
110
- attributes_str = match[3].strip
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
- # Parse attributes and format them for the render call
125
- params = []
126
- attributes_str.scan(ATTRIBUTE_REGEX) do |key, quoted_value, single_quoted_value, ruby_expression|
127
- # Basic validation for attribute keys
128
- unless key =~ /\A[a-z_][a-z0-9_]*\z/i
129
- raise ArgumentError, "Invalid attribute key '#{key}' in template #{template.identifier}"
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
- formatted_key = "#{key}:"
133
- if ruby_expression
134
- # For Ruby expressions, directly embed the expression.
135
- params << "#{formatted_key} #{ruby_expression}"
136
- elsif quoted_value
137
- # For double-quoted string literals, escape double quotes within the value.
138
- params << "#{formatted_key} \"#{quoted_value.gsub('"', '\"')}\""
139
- elsif single_quoted_value
140
- # For single-quoted string literals, escape single quotes within the value
141
- # and wrap in double quotes for Ruby string literal syntax.
142
- params << "#{formatted_key} \"#{single_quoted_value.gsub("'", "\\'")}\""
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
- # If it's a self-closing tag, generate a simple render call.
150
- parts << "<%= render #{component_class_name}.new(#{render_params_str}) %>"
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
- # If it's an opening tag, push it onto the stack to await its closing tag.
153
- stack << [component_class_name, render_params_str, pos]
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
- # Try to match a closing tag
157
- elsif (match = CLOSE_TAG_REGEX.match(source, pos))
158
- # Append text before the closing tag to the result parts
159
- parts << source[pos...match.begin(0)]
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
- # Check for unmatched closing tags
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
- raise ArgumentError, "Unmatched closing tag </#{closing_tag_name}> in template #{template.identifier}"
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
- # Pop the corresponding opening tag from the stack
170
- component_class_name, render_params_str, start_pos = stack.pop
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
- # Apply the same transformation to the closing tag name for comparison
173
- expected_closing_component_name = resolve_component_class_name(closing_tag_name)
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
- # Check for mismatched tags (e.g., <div></p>)
176
- if component_class_name != expected_closing_component_name
177
- # Extract the original tag name from the component class name for the error message
178
- expected_tag_name = component_class_name.gsub(/Component$/, "")
179
- raise ArgumentError,
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
- # Recursively process the content between the opening and closing tags.
184
- # This is where nested components are handled.
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
- # If no tags are matched, append the rest of the source and break the loop.
192
- parts << source[pos..-1]
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
- # After parsing, if the stack is not empty, it means there are unclosed tags.
198
- raise ArgumentError, "Unclosed tag <#{stack.last[0]}> in template #{template.identifier}" unless stack.empty?
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
- # Join all the collected parts to form the final ERB string.
201
- parts.join("")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EvcRails
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.4"
5
5
  end
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.2
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: A Rails engine that provides a custom template handler for .evc files,
69
- allowing developers to use PascalCase ViewComponent tags (e.g., <MyComponent />)
70
- directly in their HTML, similar to JSX.
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: Enables JSX-like PascalCase component tags in Rails .evc view files.
112
+ summary: JSX-like syntax for Rails ViewComponent
111
113
  test_files: []