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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7fda15b180ea02549511ff7aa2f1a51071ef8cce0fbf9721c92cc8fe4b6f605
4
- data.tar.gz: 03ab565adcff81843d342152c4631c22124ee01d820e8a007a590afea3792347
3
+ metadata.gz: '08062fcb10857de4a7b864d324e705430a6eb3fe7712c2ed893a482dbbc1e604'
4
+ data.tar.gz: cbe0310f402b96fb62fae7a21647a149ec871539b476527615e72a1724402c79
5
5
  SHA512:
6
- metadata.gz: c1b7c0694278f7bffbf73b23423f14ca96f993cdfd0f19fe230ce52014405d8f1221f0ff3922c9ae7fab2e889832d1de480b0465b875cfcb4cd6ad26c012fd34
7
- data.tar.gz: 24c79d6ba0ac68acea62831ee0043bfb175ac05a3aab97cbe641b503258dfb4becabc21cc72b7f2b03bffdd8d68a0d88902dd9e82d79101e0c876cd28654f285
6
+ metadata.gz: a22db0b9bcd33d36b16908482b6eafc12cbbe684e588f9ab19dadeffdc6ac43672f8c026ed23a662cb7babe324abb0ca7d97195bc23a91d54c2f091225fcb472
7
+ data.tar.gz: 3445cef47452e7349d9ed6abe58dcdbe974d5c91097634fb5577ab0947cef60d863ff62e51e6cf457e2db73cb115e4a379f210d97d63b6785aee2eb0a454f9ce
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:
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
- Content Blocks: Components can accept content blocks (<MyComponent>content</MyComponent>) which are passed to the View Component via a block.
31
+ ```html
32
+ <!-- Now you can use it with EVC syntax -->
33
+ <button variant="primary" size="lg">Click me</button>
34
+ ```
14
35
 
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).
36
+ No component modifications required - just install and enjoy easier syntax!
16
37
 
17
- Performance Optimized: Includes in-memory caching of compiled templates and memoization of component class lookups for efficient rendering in production.
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
- Or install it yourself as:
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
- 1. Create a View Component
42
- First, ensure you have a Rails View Component. For example, create app/components/my_component_component.rb:
68
+ ### Basic Components
43
69
 
44
- ```ruby
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
- 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
58
- ```
72
+ ```html
73
+ <!-- app/views/pages/home.evc -->
74
+ <h1>Welcome to our app</h1>
59
75
 
60
- And its associated template app/components/my_component_component.html.erb (if you're using separate templates):
76
+ <button size="lg" variant="primary">Get Started</button>
61
77
 
62
- ```erb
63
- <!-- app/components/my_component_component.html.erb -->
64
- <div class="my-component">
65
- <h2><%= @title %></h2>
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
- 2. Create an .evc Template
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
- <!-- 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>
87
+ <h1>Welcome to our app</h1>
88
88
 
89
- <%# A more concise way to render your DoughnutChartComponent %>
90
- <DoughnutChart rings={@progress_data} />
89
+ <%= render ButtonComponent.new(size: "lg", variant: "primary") do %>
90
+ Get Started
91
+ <% end %>
91
92
 
92
- <%# You can still use standard ERB within .evc files %>
93
- <p><%= link_to "Go somewhere", some_path %></p>
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
- 3. Ensure Components are Autoloaded
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
- ```ruby
100
- # config/application.rb
101
- config.eager_load_paths << Rails.root.join("app/components")
101
+ ```html
102
+ <button />
103
+ <Icon name="star" />
104
+ <spacer height="20" />
102
105
  ```
103
106
 
104
- ## Component Organization
107
+ Becomes:
105
108
 
106
- You can organize your components into namespaces for better structure:
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
- 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
122
+
123
+ #### Ruby Expressions
124
+
125
+ ```html
126
+ <button user="{@current_user}" count="{@items.count}" />
123
127
  ```
124
128
 
125
- Then use them in your .evc templates:
129
+ #### Multiple Attributes
126
130
 
127
- ```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>
131
+ ```html
132
+ <Card class="shadow-lg" data-testid="featured-card" user="{@user}">
133
+ Content here
134
+ </Card>
135
135
  ```
136
136
 
137
- ## How it Works
137
+ ### Namespaced Components
138
138
 
139
- When Rails processes an .evc template, EvcRails intercepts it and performs the following transformations:
139
+ Organize your components in subdirectories:
140
140
 
141
- ```erb
142
- <Card title="Hello World">... </Card>
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
- becomes:
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
- <%= render CardComponent.new(title: "Hello World") do %>
149
- ... (processed content) ...
150
- <% end %>
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
- <Button text="Click Me" />
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
- becomes:
191
+ #### Multiple Slots (`renders_many`)
158
192
 
159
193
  ```ruby
160
- <%= render ButtonComponent.new(text: "Click Me") %>
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
- <Ui::Button text="Click Me" />
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
- becomes:
238
+ ### Mixed Content
168
239
 
169
- ```ruby
170
- <%= render Ui::ButtonComponent.new(text: "Click Me") %>
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
- ```erb
174
- <DoughnutChart rings={@progress_data} />
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
- 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,195 +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"
34
23
 
35
- def self.clear_cache # Class method to allow clearing cache manually, if needed
36
- @cache = {}
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
- 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
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
- # Using an array for `parts` and then joining at the end is generally more efficient
81
- # for building strings in Ruby than repeated string concatenations (`<<`).
82
- 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 = []
83
84
  pos = 0
84
- stack = [] # Track [component_class_name, render_params_str, start_pos_in_source]
85
+ source_length = source.length
85
86
 
86
- # Initialize memoization cache for this processing run
87
- @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
88
91
 
89
- while pos < source.length
90
- # Try to match an opening or self-closing tag
91
- if (match = TAG_REGEX.match(source, pos))
92
- # Append text before the current tag to the result parts
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
- is_self_closing = match[0].end_with?("/>")
97
- tag_name = match[1]
98
- attributes_str = match[3].strip
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
- # Parse attributes and format them for the render call
121
- params = []
122
- attributes_str.scan(ATTRIBUTE_REGEX) do |key, quoted_value, single_quoted_value, ruby_expression|
123
- # Basic validation for attribute keys
124
- unless key =~ /\A[a-z_][a-z0-9_]*\z/i
125
- raise ArgumentError, "Invalid attribute key '#{key}' in template #{template.identifier}"
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
- formatted_key = "#{key}:"
129
- if ruby_expression
130
- # For Ruby expressions, directly embed the expression.
131
- params << "#{formatted_key} #{ruby_expression}"
132
- elsif quoted_value
133
- # For double-quoted string literals, escape double quotes within the value.
134
- params << "#{formatted_key} \"#{quoted_value.gsub('"', '\"')}\""
135
- elsif single_quoted_value
136
- # For single-quoted string literals, escape single quotes within the value
137
- # and wrap in double quotes for Ruby string literal syntax.
138
- 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
139
125
  end
140
126
  end
141
127
 
142
- render_params_str = params.join(", ")
143
-
144
128
  if is_self_closing
145
- # If it's a self-closing tag, generate a simple render call.
146
- 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
147
156
  else
148
- # If it's an opening tag, push it onto the stack to await its closing tag.
149
- 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
150
177
  end
151
178
 
152
- # Try to match a closing tag
153
- elsif (match = CLOSE_TAG_REGEX.match(source, pos))
154
- # Append text before the closing tag to the result parts
155
- parts << source[pos...match.begin(0)]
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
- # 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
161
189
  if stack.empty?
162
- 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}"
163
193
  end
164
194
 
165
- # Pop the corresponding opening tag from the stack
166
- component_class_name, render_params_str, start_pos = stack.pop
167
-
168
- # Check for mismatched tags (e.g., <div></p>)
169
- if component_class_name != closing_tag_name
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
- # Recursively process the content between the opening and closing tags.
175
- # This is where nested components are handled.
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
- # Generate the render call with a block for the content.
179
- parts << "<%= render #{component_class_name}.new(#{render_params_str}) do %>#{content}<% end %>"
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
- # If no tags are matched, append the rest of the source and break the loop.
183
- parts << source[pos..-1]
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
- # After parsing, if the stack is not empty, it means there are unclosed tags.
189
- 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
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
- # Join all the collected parts to form the final ERB string.
192
- parts.join("")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EvcRails
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
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.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