ruby_ui_converter 0.1.0
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 +7 -0
- data/CHANGELOG.md +73 -0
- data/LICENSE.txt +21 -0
- data/README.md +487 -0
- data/exe/ruby_ui_converter +7 -0
- data/lib/ruby_ui_converter/cli.rb +179 -0
- data/lib/ruby_ui_converter/code_builder.rb +33 -0
- data/lib/ruby_ui_converter/component_map.rb +125 -0
- data/lib/ruby_ui_converter/configuration.rb +53 -0
- data/lib/ruby_ui_converter/converter.rb +76 -0
- data/lib/ruby_ui_converter/doctor.rb +190 -0
- data/lib/ruby_ui_converter/file_walker.rb +22 -0
- data/lib/ruby_ui_converter/form_builder.rb +252 -0
- data/lib/ruby_ui_converter/html_tokenizer.rb +109 -0
- data/lib/ruby_ui_converter/lexer.rb +58 -0
- data/lib/ruby_ui_converter/locals_detector.rb +111 -0
- data/lib/ruby_ui_converter/naming.rb +45 -0
- data/lib/ruby_ui_converter/nodes.rb +128 -0
- data/lib/ruby_ui_converter/parser.rb +179 -0
- data/lib/ruby_ui_converter/rails_helpers.rb +230 -0
- data/lib/ruby_ui_converter/template.rb +170 -0
- data/lib/ruby_ui_converter/transformer.rb +401 -0
- data/lib/ruby_ui_converter/version.rb +5 -0
- data/lib/ruby_ui_converter.rb +54 -0
- metadata +114 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ffc607673bf4249a934ea009f793f034a3d1c75bb72a100ff1df2f39e60e3ff7
|
|
4
|
+
data.tar.gz: 670b2734525033da876314c0a30b18da3910c9a7cac284d1d1759e43bc7cf2af
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ceff723f0ea70a12973656154e947c5367170e8c2eab787f242f317ddbf106528ebeddb6882a74fe53f90917e5139d9d44c3b32c0792b58eab3342fb9f8419bb
|
|
7
|
+
data.tar.gz: ee214fe74ea2f9065d72aa5da91441cad67ffee934e756467c5d99702e234086c65891774e825fece326aa98a2b3b24d46267eedda906de846fa059475b75b10
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
- Top-level views now get an initializer (or `prop`s with `--literal`) built
|
|
6
|
+
from the controller instance variables they reference (`@products` →
|
|
7
|
+
`initialize(products: nil)`), so they can be rendered with
|
|
8
|
+
`render Views::Products::Index.new(products: ...)`.
|
|
9
|
+
- Form-builder mapping: inside a model-bound `form_with`/`form_for`, field
|
|
10
|
+
calls become RubyUI components — `form.text_field`/typed fields → `Input`,
|
|
11
|
+
`form.textarea` → `Textarea`, `form.checkbox` → `Checkbox`,
|
|
12
|
+
`form.collection_select` → `NativeSelect` with an option loop, `form.label` →
|
|
13
|
+
`FormFieldLabel`, `form.submit` → `Button` — reconstructing `name`/`id` as
|
|
14
|
+
`"model[attr]"` and `value` as `model.attr.to_s`, appending a `FormFieldError`
|
|
15
|
+
per field, and dropping the `|form|` block var when every call maps.
|
|
16
|
+
- Rails flash paragraphs map to RubyUI `Alert`: `<p id="notice">` →
|
|
17
|
+
`Alert(variant: :success)`, `<p id="alert">` → `Alert(variant: :destructive)`,
|
|
18
|
+
each with an `AlertTitle` + `AlertDescription`.
|
|
19
|
+
- Phlex 2 raw output uses `raw(safe(...))` (Phlex 1 keeps `unsafe_raw(...)`) via
|
|
20
|
+
`Configuration#raw_call`. Unmapped HTML output helpers and object/collection
|
|
21
|
+
`render` are emitted as **bare calls** (phlex-rails writes to the buffer);
|
|
22
|
+
only string-returning helpers (`sanitize`, `safe_join`, ...) are wrapped.
|
|
23
|
+
- `Doctor` emits one `bin/rails generate ruby_ui:component <Name>` command per
|
|
24
|
+
missing component (the generator takes a single component per invocation).
|
|
25
|
+
- RubyUI element mapping **enabled by default**: basic HTML elements are
|
|
26
|
+
converted to RubyUI kit components — `a[href]` → `Link`, `button` → `Button`,
|
|
27
|
+
`input` → `Input`/`Checkbox`/`RadioButton`, `textarea` → `Textarea`,
|
|
28
|
+
`select`/`option` → `NativeSelect`/`NativeSelectOption`, the `table` family,
|
|
29
|
+
`hr` → `Separator`, plus class-based `Badge`/`Card`; `link_to` becomes
|
|
30
|
+
`Link(href: ...)`. Disable with `--no-ruby-ui` / `ruby_ui: false`.
|
|
31
|
+
- New `Transformer#kit_component` helper for kit-style component emission;
|
|
32
|
+
built-in rules are fallbacks, so custom `component_map.register` rules
|
|
33
|
+
always take precedence.
|
|
34
|
+
- Removed `--starter-rules` / `enable_starter_rubyui_rules!` (replaced by the
|
|
35
|
+
default mapping above and `enable_rubyui_rules!`).
|
|
36
|
+
- Fix: `--output` now creates missing directories in the output tree.
|
|
37
|
+
- New `--literal` flag (`literal: true`): partials declare
|
|
38
|
+
`Literal::Properties` props (`prop :user, _Nilable(User)`) instead of
|
|
39
|
+
`initialize`/`attr_reader`, and the template body references locals as
|
|
40
|
+
`@ivar`s (rewritten safely via Ripper token-level pass). The local matching
|
|
41
|
+
the partial name gets an inferred model type; others get `_Any?`.
|
|
42
|
+
- Fix: `LocalsDetector` no longer treats `render` as a partial local.
|
|
43
|
+
- Namespaces are now anchored at the nearest `app/views` ancestor: converting
|
|
44
|
+
`app/views/users` (or a single file inside it) generates `Views::Users::*`,
|
|
45
|
+
matching the Zeitwerk/phlex-rails layout regardless of which subfolder was
|
|
46
|
+
converted. `--output` mirroring follows the same root. Outside an `app/views`
|
|
47
|
+
tree the previous relative behavior is kept; `--root DIR` sets the anchor
|
|
48
|
+
explicitly (passing the converted folder itself restores the old behavior).
|
|
49
|
+
- Prerequisite check after each CLI run (`Doctor`): detects missing
|
|
50
|
+
`phlex-rails`/`ruby_ui`/`literal` gems, ungenerated RubyUI components
|
|
51
|
+
referenced by the converted code, and a missing `extend Literal::Properties`,
|
|
52
|
+
then offers to install them (prompt; warn-only on `--dry-run`/non-TTY).
|
|
53
|
+
After installing, a follow-up diagnosis fixes problems that only appear
|
|
54
|
+
post-install — notably the broken `tw-animate-css` import that
|
|
55
|
+
`ruby_ui:install` leaves on importmap apps (the jspm pin fails for this
|
|
56
|
+
CSS-only package): the real CSS is vendored next to `application.css` and
|
|
57
|
+
the import is rewritten.
|
|
58
|
+
|
|
59
|
+
- Fix: whole-value ERB attribute expressions with unparenthesized arguments
|
|
60
|
+
(e.g. `id="<%= dom_id user %>"`) are now wrapped in parens so they parse
|
|
61
|
+
correctly inside the attribute list.
|
|
62
|
+
- Fix: `link_to` targets that are not strings or route helpers (e.g.
|
|
63
|
+
`link_to "Show", user`) are now wrapped in `url_for(...)`.
|
|
64
|
+
- Fix: `LocalsDetector` no longer treats common Rails helpers (`dom_id`,
|
|
65
|
+
`dom_class`, `notice`, `alert`, `content_for`, `cycle`) as partial locals.
|
|
66
|
+
|
|
67
|
+
- Initial release.
|
|
68
|
+
- Recursive conversion of `.erb` views into Phlex/RubyUI `.rb` components.
|
|
69
|
+
- Conversion of Rails partials (`_partial.html.erb`) into dedicated Phlex component classes.
|
|
70
|
+
- Pure-Ruby ERB + HTML lexer/parser (no native dependencies).
|
|
71
|
+
- Best-effort mapping of common Rails helpers (`render`, `link_to`, `image_tag`, `content_tag`, `yield`).
|
|
72
|
+
- Configurable, conservative RubyUI component mapping via `ComponentMap`.
|
|
73
|
+
- `ruby_ui_converter convert PATH` CLI.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jackson Pires
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# ruby_ui_converter
|
|
2
|
+
|
|
3
|
+
Convert Rails `.erb` views and partials into [Phlex](https://www.phlex.fun) /
|
|
4
|
+
[RubyUI](https://rubyui.com) Ruby components.
|
|
5
|
+
|
|
6
|
+
Point it at a views directory and it walks recursively, converting each `.erb`
|
|
7
|
+
template into an equivalent `.rb` file **next to it**:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
app/views/users/index.html.erb -> app/views/users/index.rb (Views::Users::Index)
|
|
11
|
+
app/views/users/_user.html.erb -> app/views/users/user.rb (Views::Users::User)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Traditional Rails partials (`_user.html.erb`) become their own Phlex component
|
|
15
|
+
classes, with detected locals exposed as keyword arguments. Top-level views get
|
|
16
|
+
an initializer for the controller instance variables they reference, so you can
|
|
17
|
+
render them with `render Views::Users::Index.new(users: ...)`.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add it to your Gemfile (typically in the `:development` group):
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "ruby_ui_converter", group: :development
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install it directly:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
gem install ruby_ui_converter
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> [!IMPORTANT]
|
|
34
|
+
> The converter itself has no runtime dependencies beyond `thor` — conversion
|
|
35
|
+
> works anywhere. However, by default the **generated code** calls RubyUI
|
|
36
|
+
> components (`Link(...)`, `Button(...)`, `Input(...)`, ...), so for it to run
|
|
37
|
+
> your app must have the [ruby_ui](https://rubygems.org/gems/ruby_ui) gem
|
|
38
|
+
> installed, with the corresponding components generated and the kit included
|
|
39
|
+
> (`rails g ruby_ui:install` + `rails g ruby_ui:component ...` — see
|
|
40
|
+
> [Migrating a Rails ERB app](#migrating-a-rails-erb-app)). If you don't use
|
|
41
|
+
> RubyUI, convert with `--no-ruby-ui` to emit plain Phlex elements — then only
|
|
42
|
+
> [phlex-rails](https://github.com/phlex-ruby/phlex-rails) is required.
|
|
43
|
+
>
|
|
44
|
+
> Likewise, converting with `--literal` makes the generated code depend on the
|
|
45
|
+
> [literal](https://rubygems.org/gems/literal) gem (`bundle add literal` +
|
|
46
|
+
> `extend Literal::Properties` on your base component class — see
|
|
47
|
+
> [`--literal`](#--literal-literalproperties-instead-of-initialize)).
|
|
48
|
+
>
|
|
49
|
+
> You don't have to track this by hand: after each run the CLI **checks the
|
|
50
|
+
> target app for these prerequisites** (gems in the Gemfile, generated RubyUI
|
|
51
|
+
> components for what the converted code actually uses, `Literal::Properties`
|
|
52
|
+
> on the base class) and offers to install what's missing — in non-interactive
|
|
53
|
+
> sessions and on `--dry-run` it just prints the exact commands.
|
|
54
|
+
|
|
55
|
+
> [!TIP]
|
|
56
|
+
> Want a hands-on walkthrough? See
|
|
57
|
+
> [docs/practical-example.md](docs/practical-example.md) — it goes from
|
|
58
|
+
> `rails new` and a scaffold with every common column type all the way to
|
|
59
|
+
> rendering the converted Phlex/RubyUI components, step by step.
|
|
60
|
+
|
|
61
|
+
## CLI usage
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Convert a whole folder (recursively), writing .rb next to each .erb
|
|
65
|
+
bundle exec ruby_ui_converter convert app/views/users
|
|
66
|
+
|
|
67
|
+
# Preview without writing anything
|
|
68
|
+
bundle exec ruby_ui_converter convert app/views --dry-run
|
|
69
|
+
|
|
70
|
+
# Overwrite existing .rb files
|
|
71
|
+
bundle exec ruby_ui_converter convert app/views --force
|
|
72
|
+
|
|
73
|
+
# Customize the base module namespace and superclass
|
|
74
|
+
bundle exec ruby_ui_converter convert app/views --namespace Views --base-class Views::Base
|
|
75
|
+
|
|
76
|
+
# Write into a separate output tree (mirrors the directory structure)
|
|
77
|
+
bundle exec ruby_ui_converter convert app/views -o app/components
|
|
78
|
+
|
|
79
|
+
# Emit plain Phlex elements instead of RubyUI components
|
|
80
|
+
bundle exec ruby_ui_converter convert app/views --no-ruby-ui
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Option | Default | Description |
|
|
84
|
+
| ---------------- | ------------- | --------------------------------------------------------------------------------------------- |
|
|
85
|
+
| `--namespace` | `Views` | Base module namespace for generated constants |
|
|
86
|
+
| `--root` | _(auto)_ | Directory namespaces are derived from (default: nearest `app/views` ancestor, else PATH) |
|
|
87
|
+
| `--base-class` | `Phlex::HTML` | Superclass for generated components |
|
|
88
|
+
| `--phlex` | `2` | Target Phlex major version (`2` => `view_template`) |
|
|
89
|
+
| `--output`, `-o` | _(in place)_ | Write into this directory instead of next to the source |
|
|
90
|
+
| `--dry-run` | `false` | Print what would be generated without writing |
|
|
91
|
+
| `--force` | `false` | Overwrite existing `.rb` files |
|
|
92
|
+
| `--ruby-ui` | `true` | Map basic HTML elements onto RubyUI components (`--no-ruby-ui` for plain Phlex) |
|
|
93
|
+
| `--literal` | `false` | Emit [Literal](https://literal.fun) `prop` declarations instead of `initialize`/`attr_reader` |
|
|
94
|
+
| `--verbose` | `false` | Print the generated source for each file |
|
|
95
|
+
|
|
96
|
+
## Ruby API
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
require "ruby_ui_converter"
|
|
100
|
+
|
|
101
|
+
# Convert a directory (returns Converter::Result structs)
|
|
102
|
+
RubyUIConverter.convert("app/views/users", force: true)
|
|
103
|
+
|
|
104
|
+
# Convert a single ERB string (no file IO)
|
|
105
|
+
RubyUIConverter.convert_string('<h1><%= @title %></h1>', class_name: "Page")
|
|
106
|
+
# =>
|
|
107
|
+
# class Page < Phlex::HTML
|
|
108
|
+
# def view_template
|
|
109
|
+
# h1 { @title }
|
|
110
|
+
# end
|
|
111
|
+
# end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## What gets converted
|
|
115
|
+
|
|
116
|
+
| ERB | Generated Ruby |
|
|
117
|
+
| ----------------------------------------- | --------------------------------------------------- |
|
|
118
|
+
| `<div class="box">hi</div>` | `div(class: "box") { "hi" }` |
|
|
119
|
+
| `<%= user.name %>` | `plain(user.name)` (escaped) |
|
|
120
|
+
| `<%== markup %>` | `raw(safe(markup))` (Phlex 1: `unsafe_raw(markup)`) |
|
|
121
|
+
| `<p class="a <%= b %>">` | `p(class: "a #{b}")` |
|
|
122
|
+
| `<p class="<%= css %>">` | `p(class: css)` |
|
|
123
|
+
| `<% if x %>…<% else %>…<% end %>` | real `if / else / end` |
|
|
124
|
+
| `<% items.each do \|i\| %>…<% end %>` | `items.each do \|i\| … end` |
|
|
125
|
+
| `<%= link_to "Home", path, class: "x" %>` | `Link(href: path, class: "x") { "Home" }` |
|
|
126
|
+
| `<%= link_to "Show", user %>` | `Link(href: url_for(user)) { "Show" }` |
|
|
127
|
+
| `id="<%= dom_id user %>"` | `id: (dom_id user)` |
|
|
128
|
+
| `<%= image_tag "logo.png", alt: "L" %>` | `img(src: "logo.png", alt: "L")` |
|
|
129
|
+
| `<%= render "shared/header" %>` | `render Views::Shared::Header.new` |
|
|
130
|
+
| `<%= render "form", user: @user %>` | `render Views::Users::Form.new(user: @user)` |
|
|
131
|
+
| `data-id="<%= id %>"` | `"data-id": id` |
|
|
132
|
+
| `<%# comment %>` | `# comment` |
|
|
133
|
+
|
|
134
|
+
Top-level views get an initializer built from the controller instance variables
|
|
135
|
+
they reference (`@products` → `def initialize(products: nil); @products = products; end`),
|
|
136
|
+
so the component can be rendered with `render Views::Products::Index.new(products: ...)`.
|
|
137
|
+
|
|
138
|
+
Partials additionally get an initializer and private readers for detected
|
|
139
|
+
locals:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
class User < Phlex::HTML
|
|
143
|
+
def initialize(user: nil)
|
|
144
|
+
@user = user
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def view_template
|
|
148
|
+
li(class: "user", "data-id": user.id) { ... }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
attr_reader :user
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `--literal`: Literal::Properties instead of initialize
|
|
158
|
+
|
|
159
|
+
With `--literal`, partials declare [Literal](https://literal.fun) props instead
|
|
160
|
+
of the initializer boilerplate, and the body references locals as instance
|
|
161
|
+
variables (props always set `@ivar`s; no readers are generated):
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class User < Phlex::HTML
|
|
165
|
+
prop :user, _Nilable(User)
|
|
166
|
+
|
|
167
|
+
def view_template
|
|
168
|
+
li(class: "user", "data-id": @user.id) { ... }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The local matching the partial's name gets an inferred model type
|
|
174
|
+
(`_user.html.erb` → `_Nilable(User)` — adjust if the constant doesn't exist);
|
|
175
|
+
other locals get the permissive `_Any?` (accepts anything, including nil).
|
|
176
|
+
Nilable types make the keyword argument optional automatically. Top-level views
|
|
177
|
+
likewise get a `prop` for each controller ivar they reference, all typed
|
|
178
|
+
`_Any?` (the partial-name model inference doesn't apply to them).
|
|
179
|
+
|
|
180
|
+
Requirements: `bundle add literal` and `extend Literal::Properties` on your
|
|
181
|
+
base component class:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# app/components/base.rb
|
|
185
|
+
class Components::Base < Phlex::HTML
|
|
186
|
+
extend Literal::Properties
|
|
187
|
+
# ...
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Migrating a Rails ERB app
|
|
192
|
+
|
|
193
|
+
`ruby_ui_converter` only **generates** the component source — running it
|
|
194
|
+
requires [Phlex](https://www.phlex.fun) in your app. For a typical Rails app
|
|
195
|
+
the migration looks like this:
|
|
196
|
+
|
|
197
|
+
### 1. Install phlex-rails and RubyUI
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
bundle add phlex-rails
|
|
201
|
+
bin/rails generate phlex:install
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The generator creates `Views::Base` / `Components::Base` and registers
|
|
205
|
+
`app/views` and `app/components` in the Rails autoloader under the `Views` /
|
|
206
|
+
`Components` namespaces — that's what makes `render Views::Users::Index.new`
|
|
207
|
+
work from a controller.
|
|
208
|
+
|
|
209
|
+
The converter maps basic elements onto [RubyUI](https://rubyui.com) components
|
|
210
|
+
by default (see [RubyUI element mapping](#rubyui-element-mapping)), so install
|
|
211
|
+
RubyUI and generate the components your views will use:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
bundle add ruby_ui
|
|
215
|
+
bin/rails generate ruby_ui:install
|
|
216
|
+
|
|
217
|
+
# ruby_ui:component takes one component per invocation — loop over the list
|
|
218
|
+
for c in Button Link Input; do bin/rails generate ruby_ui:component "$c"; done
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
(`ruby_ui:install` also wires `include RubyUI` into `Components::Base`, which
|
|
222
|
+
is what enables the kit-style `Link(...)` / `Button(...)` calls.)
|
|
223
|
+
|
|
224
|
+
If you'd rather stay on plain Phlex, skip this and convert with `--no-ruby-ui`.
|
|
225
|
+
|
|
226
|
+
### 2. Convert
|
|
227
|
+
|
|
228
|
+
Zeitwerk expects `app/views/users/index.rb` to define `Views::Users::Index` —
|
|
229
|
+
and the converter guarantees that automatically: whenever the converted path is
|
|
230
|
+
inside an `app/views` directory, namespaces are derived **relative to
|
|
231
|
+
`app/views`**, no matter which subfolder (or single file) you point it at:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# whole tree or a single folder — both produce Views::Users::Index etc.
|
|
235
|
+
bundle exec ruby_ui_converter convert app/views --base-class "Views::Base"
|
|
236
|
+
bundle exec ruby_ui_converter convert app/views/users --base-class "Views::Base"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
(Outside an `app/views` tree, namespaces are relative to the converted folder;
|
|
240
|
+
pass `--root DIR` to set the anchor explicitly.)
|
|
241
|
+
|
|
242
|
+
Use `--base-class "Views::Base"` so the generated classes inherit the helpers
|
|
243
|
+
configured in the next step.
|
|
244
|
+
|
|
245
|
+
### 3. Include the Rails helpers your views use
|
|
246
|
+
|
|
247
|
+
Generated components call view helpers (`content_for`, `button_to`, `dom_id`,
|
|
248
|
+
`notice`, route helpers, ...) that are not available in plain Phlex. Include the
|
|
249
|
+
phlex-rails adapters once in `Components::Base` — and keep the `include RubyUI`
|
|
250
|
+
that `ruby_ui:install` added, it's what enables the `Link(...)` / `Button(...)`
|
|
251
|
+
kit calls:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
# app/components/base.rb
|
|
255
|
+
class Components::Base < Phlex::HTML
|
|
256
|
+
include RubyUI
|
|
257
|
+
|
|
258
|
+
include Phlex::Rails::Helpers::Routes
|
|
259
|
+
include Phlex::Rails::Helpers::ContentFor
|
|
260
|
+
include Phlex::Rails::Helpers::ButtonTo
|
|
261
|
+
include Phlex::Rails::Helpers::DOMID
|
|
262
|
+
include Phlex::Rails::Helpers::Notice # scaffold views render `notice`
|
|
263
|
+
include Phlex::Rails::Helpers::FormWith # `_form` partials use `form_with`
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Each bare helper a converted view calls (`notice`, `form_with`, `current_user`,
|
|
268
|
+
...) needs a matching `Phlex::Rails::Helpers::*` module included here, or it
|
|
269
|
+
raises `NoMethodError` / `undefined local variable or method '...'` at render
|
|
270
|
+
time. phlex-rails' error message names the exact module to add.
|
|
271
|
+
|
|
272
|
+
### 4. Pass data explicitly from controllers
|
|
273
|
+
|
|
274
|
+
Controller instance variables are **not** shared with Phlex components. The
|
|
275
|
+
converter generates an initializer for each top-level view from the controller
|
|
276
|
+
ivars it references (and for partials from their detected locals), so all you
|
|
277
|
+
have to do is render the component and pass the data from the action:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
# app/views/users/index.rb (generated)
|
|
281
|
+
class Views::Users::Index < Views::Base
|
|
282
|
+
def initialize(users: nil)
|
|
283
|
+
@users = users
|
|
284
|
+
end
|
|
285
|
+
# ...
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# app/controllers/users_controller.rb
|
|
289
|
+
def index
|
|
290
|
+
render Views::Users::Index.new(users: User.all)
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The generated keyword arguments default to `nil` — tighten them to required
|
|
295
|
+
where it helps. Bare view helpers (`notice`, `current_user`, ...) are not
|
|
296
|
+
ivars, so they are not added as arguments; pass them in explicitly or include
|
|
297
|
+
the matching helper on your base class.
|
|
298
|
+
|
|
299
|
+
Converted partials are plain components too — render them from other
|
|
300
|
+
components, passing the detected locals as keyword arguments:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# app/views/users/_user.html.erb -> Views::Users::User
|
|
304
|
+
render Views::Users::User.new(user: user)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
If you converted with `--literal`, the converter emits `prop` declarations
|
|
308
|
+
instead of an initializer — a `prop` for each controller ivar a top-level view
|
|
309
|
+
references (requires `extend Literal::Properties` on the base class — see
|
|
310
|
+
[`--literal`](#--literal-literalproperties-instead-of-initialize)):
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
# app/views/users/index.rb (generated)
|
|
314
|
+
class Views::Users::Index < Views::Base
|
|
315
|
+
prop :users, _Any?
|
|
316
|
+
# ...
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Review the generated props and tighten the permissive `_Any?` types where you
|
|
321
|
+
can.
|
|
322
|
+
|
|
323
|
+
### 5. Review the output
|
|
324
|
+
|
|
325
|
+
Run with `--dry-run` first, convert incrementally (one folder at a time), and
|
|
326
|
+
review each file — see [Design & limitations](#design--limitations) for what
|
|
327
|
+
needs manual attention (e.g. `form_with` blocks and inline `<script>` /
|
|
328
|
+
`<style>` content). The original `.erb` files are never modified, so actions you haven't
|
|
329
|
+
migrated keep rendering through ERB.
|
|
330
|
+
|
|
331
|
+
Every RubyUI component the converted code references must exist in
|
|
332
|
+
`app/components/ruby_ui/` — if a converted view uses a `<table>`, generate it
|
|
333
|
+
too (`bin/rails generate ruby_ui:component Table`), otherwise rendering raises
|
|
334
|
+
`NameError`. The full list of components the mapping can emit is in
|
|
335
|
+
[RubyUI element mapping](#rubyui-element-mapping).
|
|
336
|
+
|
|
337
|
+
## RubyUI element mapping
|
|
338
|
+
|
|
339
|
+
By default the converter maps basic HTML elements onto
|
|
340
|
+
[RubyUI](https://rubyui.com) kit components (disable with `--no-ruby-ui` /
|
|
341
|
+
`ruby_ui: false` to get plain Phlex elements):
|
|
342
|
+
|
|
343
|
+
| HTML | RubyUI |
|
|
344
|
+
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
345
|
+
| `<a href="...">` | `Link(href: ...) { ... }` (without `href` stays `a`) |
|
|
346
|
+
| `<button>` | `Button(...) { ... }` |
|
|
347
|
+
| `<input>` | `Input(...)` |
|
|
348
|
+
| `<input type="checkbox">` | `Checkbox(...)` |
|
|
349
|
+
| `<input type="radio">` | `RadioButton(...)` |
|
|
350
|
+
| `<textarea>` | `Textarea(...) { ... }` |
|
|
351
|
+
| `<select>` / `<option>` | `NativeSelect(...)` / `NativeSelectOption(...)` |
|
|
352
|
+
| `<table>` and friends | `Table` / `TableHeader` / `TableBody` / `TableFooter` / `TableRow` / `TableHead` / `TableCell` / `TableCaption` |
|
|
353
|
+
| `<hr>` | `Separator(...)` |
|
|
354
|
+
| `class="badge"` / `class="card"` | `Badge(...)` / `Card(...)` |
|
|
355
|
+
| `<p id="notice">` / `<p id="alert">` (Rails flash) | `Alert(variant: :success) { ... }` (notice) / `Alert(variant: :destructive) { ... }` (alert, error) |
|
|
356
|
+
| `<%= link_to "X", target %>` | `Link(href: target) { "X" }` |
|
|
357
|
+
|
|
358
|
+
The original attributes (including `class`) are passed through — RubyUI merges
|
|
359
|
+
them with each component's defaults via `tailwind_merge`. No `variant:`/`size:`
|
|
360
|
+
inference is attempted; review and add them where you want them.
|
|
361
|
+
|
|
362
|
+
For the generated code to run, the app needs the corresponding RubyUI
|
|
363
|
+
components generated and the kit included (see
|
|
364
|
+
[Migrating a Rails ERB app](#migrating-a-rails-erb-app)):
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
# ruby_ui:component takes one component per invocation — loop over the list
|
|
368
|
+
for c in Button Link Input Checkbox RadioButton Textarea NativeSelect Table Separator Badge Card Alert; do
|
|
369
|
+
bin/rails generate ruby_ui:component "$c"
|
|
370
|
+
done
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Custom rules
|
|
374
|
+
|
|
375
|
+
To map specific markup onto other RubyUI (or your own) components, register
|
|
376
|
+
rules on the configuration — user rules always take precedence over the
|
|
377
|
+
built-in mapping:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
config = RubyUIConverter::Configuration.new
|
|
381
|
+
|
|
382
|
+
config.component_map.register(
|
|
383
|
+
->(el) { el.name == "button" && el.static_classes.include?("danger") }
|
|
384
|
+
) do |el, transformer, builder|
|
|
385
|
+
transformer.kit_component("Button", el, builder, extra: "variant: :destructive")
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
RubyUIConverter::Converter.new("app/views", config: config).run
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Emitters can use the transformer's public helpers: `kit_component` (kit-style
|
|
392
|
+
calls like `Button(...) { ... }`), `wrap_component` (render-style
|
|
393
|
+
`render Const.new(...)`), `component_block` (a nested content component with no
|
|
394
|
+
attributes, like `AlertDescription { ... }`), `emit_children`, `render_attrs`
|
|
395
|
+
and `meaningful`.
|
|
396
|
+
|
|
397
|
+
## Form-builder mapping
|
|
398
|
+
|
|
399
|
+
Inside a `form_with` / `form_for` block, Rails form-builder field calls
|
|
400
|
+
(`form.text_field`, ...) aren't HTML elements, so the element mapping above
|
|
401
|
+
doesn't see them. When `ruby_ui` is on **and** the form is model-bound, the
|
|
402
|
+
converter instead translates them into RubyUI form components, reconstructing
|
|
403
|
+
`name`/`id` as `"model[attr]"` and `value` as `model.attr`:
|
|
404
|
+
|
|
405
|
+
| ERB (inside `form_with model: product`) | Generated Ruby |
|
|
406
|
+
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
407
|
+
| `<%= form.text_field :name %>` | `Input(name: "product[name]", id: "product[name]", value: product.name.to_s)` |
|
|
408
|
+
| `<%= form.email_field :email %>` | `Input(type: "email", name: "product[email]", ...)` |
|
|
409
|
+
| `<%= form.number_field :qty %>` | `Input(type: "number", ...)` (also `date`/`datetime`/`time`/`color`/...) |
|
|
410
|
+
| `<%= form.textarea :bio %>` | `Textarea(name: "product[bio]", id: "product[bio]") { product.bio }` |
|
|
411
|
+
| `<%= form.checkbox :active %>` | `Checkbox(value: "1", name: "product[active]", id: "product[active]", checked: product.active?)` |
|
|
412
|
+
| `<%= form.label :published_on %>` | `FormFieldLabel(for: "product[published_on]") { "Published on" }` |
|
|
413
|
+
| `<%= form.collection_select :category_id, Category.all, :id, :name %>` | `NativeSelect(...)` wrapping a `Category.all.each { ... NativeSelectOption(value:, selected:) { ... } }` loop |
|
|
414
|
+
| `<%= form.submit %>` | `Button(type: "submit") { "Save" }` |
|
|
415
|
+
|
|
416
|
+
Each input/textarea/checkbox is followed by a `FormFieldError` that surfaces the
|
|
417
|
+
attribute's backend (model) errors, mirroring the RubyUI form convention:
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
Input(name: "product[name]", id: "product[name]", value: product.name.to_s)
|
|
421
|
+
FormFieldError { product.errors[:name].to_sentence.upcase_first }
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
The `value` is emitted as `model.attr.to_s` because HTML attribute values are
|
|
425
|
+
strings — Phlex rejects non-string/number columns (e.g. a `decimal`/BigDecimal
|
|
426
|
+
`price`) otherwise.
|
|
427
|
+
|
|
428
|
+
> [!NOTE]
|
|
429
|
+
> A select only appears if the ERB actually uses `collection_select`. Rails
|
|
430
|
+
> scaffolds a `belongs_to`/`references` column as a plain `form.text_field
|
|
431
|
+
:category_id`, which maps to an `Input` — **not** a `NativeSelect`. To get the
|
|
432
|
+
> select, swap the `text_field` for `collection_select` in the ERB _before_
|
|
433
|
+
> converting. The converter only translates what the template contains; it never
|
|
434
|
+
> infers an association select on its own.
|
|
435
|
+
|
|
436
|
+
Extra options (`class:`, `required:`, ...) are passed through. The block's
|
|
437
|
+
`|form|` variable is dropped when every `form.*` call is mapped, and kept when
|
|
438
|
+
an unmapped one (e.g. `form.hidden_field`) remains. This needs the `Form`
|
|
439
|
+
component family generated (`bin/rails generate ruby_ui:component Form`) for
|
|
440
|
+
`FormFieldLabel` / `FormFieldError`.
|
|
441
|
+
|
|
442
|
+
Caveats worth reviewing (heuristic, model binding is reconstructed by hand):
|
|
443
|
+
|
|
444
|
+
- `Checkbox` drops the hidden field Rails' `check_box` emits, so an unchecked
|
|
445
|
+
boolean no longer submits `"0"` — add it back if you rely on it.
|
|
446
|
+
- `name`/`id` use the bracketed `"model[attr]"` form; `form.submit`'s
|
|
447
|
+
auto-generated "Create/Update" label becomes a `"Save"` placeholder.
|
|
448
|
+
- With `--no-ruby-ui` (or a form without a determinable model) the calls are
|
|
449
|
+
left as `form.text_field :name` and the `|form|` variable is kept.
|
|
450
|
+
|
|
451
|
+
## Design & limitations
|
|
452
|
+
|
|
453
|
+
The converter is a pure-Ruby pipeline with **no native dependencies**:
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
ERB ─▶ Lexer ─▶ HtmlTokenizer ─▶ Parser ─▶ Transformer ─▶ Ruby/Phlex
|
|
457
|
+
(tokens) (html tokens) (AST) (CodeBuilder)
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
It covers the common cases well, but it is a heuristic source-to-source tool —
|
|
461
|
+
**review the output**. Known limitations:
|
|
462
|
+
|
|
463
|
+
- Locals detection for partials is heuristic; add/remove keyword args as needed.
|
|
464
|
+
- `form_with` / `form_for` field helpers map to RubyUI form components (see
|
|
465
|
+
[Form-builder mapping](#form-builder-mapping)) — review the reconstructed
|
|
466
|
+
bindings (notably checkboxes). Other `<%= ... do %>` block helpers are emitted
|
|
467
|
+
as blocks but may need manual adjustment for phlex-rails idioms.
|
|
468
|
+
- `render @collection` / object forms are emitted as a bare `render ...` call;
|
|
469
|
+
phlex-rails' `#render` handles model objects and relations.
|
|
470
|
+
- Inline `<script>` / `<style>` content is wrapped in a raw call
|
|
471
|
+
(`raw(safe(...))` on Phlex 2, `unsafe_raw(...)` on Phlex 1) with a TODO.
|
|
472
|
+
- Custom elements (e.g. `<my-widget>`) are emitted as method calls and may need
|
|
473
|
+
a Phlex-compatible registration.
|
|
474
|
+
|
|
475
|
+
Generated files are checked for valid Ruby syntax by the test suite, but
|
|
476
|
+
semantic equivalence is your responsibility to verify.
|
|
477
|
+
|
|
478
|
+
## Development
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
bin/setup # bundle install
|
|
482
|
+
bundle exec rake test
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
## License
|
|
486
|
+
|
|
487
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|