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 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).
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ruby_ui_converter"
5
+ require "ruby_ui_converter/cli"
6
+
7
+ RubyUIConverter::CLI.start(ARGV)