ruby_ui_converter 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +146 -9
- data/lib/ruby_ui_converter/cli.rb +22 -6
- data/lib/ruby_ui_converter/converter.rb +15 -2
- data/lib/ruby_ui_converter/file_walker.rb +12 -5
- data/lib/ruby_ui_converter/form_builder.rb +4 -0
- data/lib/ruby_ui_converter/haml_attributes.rb +186 -0
- data/lib/ruby_ui_converter/haml_parser.rb +326 -0
- data/lib/ruby_ui_converter/html_entities.rb +89 -0
- data/lib/ruby_ui_converter/locals_detector.rb +2 -0
- data/lib/ruby_ui_converter/nodes.rb +5 -1
- data/lib/ruby_ui_converter/parser.rb +8 -2
- data/lib/ruby_ui_converter/ruby_literal.rb +100 -0
- data/lib/ruby_ui_converter/template.rb +9 -3
- data/lib/ruby_ui_converter/transformer.rb +76 -15
- data/lib/ruby_ui_converter/version.rb +1 -1
- data/lib/ruby_ui_converter.rb +29 -3
- metadata +25 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ee2aaa230c92e0464d439a5767bfdc7913a013015fdd9f56a46f05bfb7c7400
|
|
4
|
+
data.tar.gz: ca39a6d4d10ab0b05d9a58115580faf0b71e3ecd5bee909d6ae7b267c682c98a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 515e92dc6c9d48b04c288cbb7a2448db2c4d51abdeaa6179746b4c02710eb2624417b295bced7186ed822db787017f23e6fa2c56888c09f3f79d6cee25b7acbe
|
|
7
|
+
data.tar.gz: b1d86c82e5405034576ed838fa998af8298f48187eaa987d2612ca76c4f731df585482a94bbaf18e837b8b1b193c481b703477e660f2031e85bf5845b7f0d0a5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-11-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **HAML support**: `.haml` views and partials are converted alongside `.erb`
|
|
8
|
+
(`convert app/views` picks up both; `convert_string(..., format: :haml)`).
|
|
9
|
+
A new front-end (`HamlParser`) uses the haml gem's own parser — an
|
|
10
|
+
*optional* dependency, required lazily, supporting haml 5.2/6.x/7.x — and
|
|
11
|
+
builds the same AST as the ERB front-end, so RubyUI mapping, `link_to`/
|
|
12
|
+
`render`, form-builder mapping, locals/ivars detection and `--literal`
|
|
13
|
+
apply unchanged. Covers tags/shorthand, `=`/`!=`/`&=`, control flow and
|
|
14
|
+
blocks, interpolated text (parsed as HTML, so inline markup and
|
|
15
|
+
`#{link_to ...}` convert too), attribute merging (`class`/`id`, literal
|
|
16
|
+
`data:`/`aria:` hashes, booleans, `[@obj]` → `dom_id`/`dom_class`, splats),
|
|
17
|
+
comments (`/` with nested content → `comment do … end`), doctype and the
|
|
18
|
+
`:javascript`/`:css`/`:plain`/`:escaped`/`:erb`/`:ruby` filters. Without
|
|
19
|
+
the haml gem, the CLI explains once how to add it.
|
|
20
|
+
- Compatibility matrix in the README and in CI: unit tests on Ruby 3.0–4.0
|
|
21
|
+
(plus `ruby-head`), and an end-to-end check (`script/rails_e2e/run.sh`) that
|
|
22
|
+
scaffolds, converts and renders a fresh Rails 7.1/7.2/8.0/8.1 app on
|
|
23
|
+
Ruby 3.2–4.0 — including a resource scaffolded with haml-rails, whose
|
|
24
|
+
converted HAML views are rendered too.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Two templates generating the same `.rb` (`show.html.erb` + `show.html.haml`,
|
|
29
|
+
or a `+variant`) are now reported as an error instead of being skipped or
|
|
30
|
+
overwritten.
|
|
31
|
+
- Attribute expressions that are already self-delimited (`[a, b]`, `{...}`,
|
|
32
|
+
`dom_id(user, :row)`) are no longer wrapped in parentheses.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- HTML character references in text and literal attribute values
|
|
37
|
+
(`©`, ` `, `—`, `&`, `—`, `—`) are now
|
|
38
|
+
decoded before emitting, instead of being double-escaped by Phlex
|
|
39
|
+
(`©` used to render as the visible text "©"). Applies to ERB and
|
|
40
|
+
HAML text; `<script>`/`<style>` content and HAML's `:escaped` filter stay
|
|
41
|
+
verbatim. Invisible characters such as a decoded ` ` are written as
|
|
42
|
+
` ` escapes in the generated source.
|
|
43
|
+
- `form_with model: @x do |form|` (unparenthesized) now activates the
|
|
44
|
+
form-builder mapping (the model capture swallowed `do |form|`).
|
|
45
|
+
- A same-line space between inline siblings (`<em>a</em> <%= b %>`) is kept
|
|
46
|
+
as Phlex `whitespace` instead of being dropped.
|
|
47
|
+
|
|
3
48
|
## [0.1.0] - 2026-06-14
|
|
4
49
|
|
|
5
50
|
- Top-level views now get an initializer (or `prop`s with `--literal`) built
|
data/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# ruby_ui_converter
|
|
2
2
|
|
|
3
|
-
Convert Rails `.erb` views and partials into
|
|
4
|
-
[RubyUI](https://rubyui.com) Ruby components.
|
|
3
|
+
Convert Rails `.erb` and `.haml` views and partials into
|
|
4
|
+
[Phlex](https://www.phlex.fun) / [RubyUI](https://rubyui.com) Ruby components.
|
|
5
5
|
|
|
6
6
|
Point it at a views directory and it walks recursively, converting each `.erb`
|
|
7
|
-
template into an equivalent `.rb` file **next to it**:
|
|
7
|
+
or `.haml` template into an equivalent `.rb` file **next to it**:
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
app/views/users/index.html.erb -> app/views/users/index.rb (Views::Users::Index)
|
|
11
11
|
app/views/users/_user.html.erb -> app/views/users/user.rb (Views::Users::User)
|
|
12
|
+
app/views/posts/show.html.haml -> app/views/posts/show.rb (Views::Posts::Show)
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
Traditional Rails partials (`_user.html.erb`) become their
|
|
15
|
-
classes, with detected locals exposed as keyword
|
|
15
|
+
Traditional Rails partials (`_user.html.erb` / `_user.html.haml`) become their
|
|
16
|
+
own Phlex component classes, with detected locals exposed as keyword
|
|
17
|
+
arguments. Top-level views get
|
|
16
18
|
an initializer for the controller instance variables they reference, so you can
|
|
17
19
|
render them with `render Views::Users::Index.new(users: ...)`.
|
|
18
20
|
|
|
@@ -41,6 +43,13 @@ gem install ruby_ui_converter
|
|
|
41
43
|
> RubyUI, convert with `--no-ruby-ui` to emit plain Phlex elements — then only
|
|
42
44
|
> [phlex-rails](https://github.com/phlex-ruby/phlex-rails) is required.
|
|
43
45
|
>
|
|
46
|
+
> Converting **`.haml`** templates additionally needs the
|
|
47
|
+
> [haml](https://rubygems.org/gems/haml) gem (5.2+) to be loadable by the
|
|
48
|
+
> converter — it uses haml's own parser. It is an *optional* dependency, not
|
|
49
|
+
> installed with this gem: apps with `.haml` views already have it in their
|
|
50
|
+
> bundle, so run the converter with `bundle exec` inside the app. See
|
|
51
|
+
> [HAML templates](#haml-templates).
|
|
52
|
+
>
|
|
44
53
|
> Likewise, converting with `--literal` makes the generated code depend on the
|
|
45
54
|
> [literal](https://rubygems.org/gems/literal) gem (`bundle add literal` +
|
|
46
55
|
> `extend Literal::Properties` on your base component class — see
|
|
@@ -58,10 +67,63 @@ gem install ruby_ui_converter
|
|
|
58
67
|
> `rails new` and a scaffold with every common column type all the way to
|
|
59
68
|
> rendering the converted Phlex/RubyUI components, step by step.
|
|
60
69
|
|
|
70
|
+
## Compatibility
|
|
71
|
+
|
|
72
|
+
There are two layers to check: the **converter** (the CLI/library that reads
|
|
73
|
+
`.erb`/`.haml` and writes `.rb`) and the **generated code** (which runs inside your
|
|
74
|
+
Rails app on top of [phlex-rails](https://github.com/phlex-ruby/phlex-rails) and
|
|
75
|
+
[ruby_ui](https://rubygems.org/gems/ruby_ui)).
|
|
76
|
+
|
|
77
|
+
**Converter** — depends only on `thor`, no Rails needed:
|
|
78
|
+
|
|
79
|
+
| Ruby | 3.0 | 3.1 | 3.2 | 3.3 | 3.4 | 4.0 |
|
|
80
|
+
| ---- | :-: | :-: | :-: | :-: | :-: | :-: |
|
|
81
|
+
| | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
82
|
+
|
|
83
|
+
`.haml` conversion uses the haml gem found at runtime: **haml 5.2, 6.x and
|
|
84
|
+
7.x** are supported (they share the parser API). haml 7 needs Ruby ≥ 3.2, so
|
|
85
|
+
CI exercises haml 6 on Ruby 3.0/3.1 and haml 7 on Ruby 3.2+.
|
|
86
|
+
|
|
87
|
+
**Generated code in a Rails app** — phlex-rails 2.x / ruby_ui 1.x require
|
|
88
|
+
Ruby ≥ 3.2 and Rails ≥ 7.1, < 9:
|
|
89
|
+
|
|
90
|
+
| Rails \ Ruby | 3.2 | 3.3 | 3.4 | 4.0 |
|
|
91
|
+
| ------------ | :-: | :-: | :-: | :-: |
|
|
92
|
+
| **8.1** | ✅ | ✅ | ✅ | ✅ |
|
|
93
|
+
| **8.0** | ✅ | ✅ | ✅ | ✅ |
|
|
94
|
+
| **7.2** | ✅ | ✅ | ✅ | ✅ |
|
|
95
|
+
| **7.1** | ✅ | ✅ | ✅ | ✅ |
|
|
96
|
+
|
|
97
|
+
Both matrices run in [CI](.github/workflows/ci.yml): the unit suite on every
|
|
98
|
+
Ruby above (plus `ruby-head`, allowed to fail), and an end-to-end check
|
|
99
|
+
([`script/rails_e2e/run.sh`](script/rails_e2e/run.sh)) for every Rails × Ruby
|
|
100
|
+
cell — it runs `rails new`, scaffolds a resource with every common column type
|
|
101
|
+
(ERB views) plus a second one with [haml-rails](https://github.com/haml/haml-rails)
|
|
102
|
+
(HAML views), converts both, and renders them through the generated
|
|
103
|
+
Phlex/RubyUI classes in an integration test (index, show, new, edit,
|
|
104
|
+
validation errors, create).
|
|
105
|
+
Last verified with Ruby 4.0.9, Rails 8.1.4.1, phlex-rails 2.4.0 and
|
|
106
|
+
ruby_ui 1.6.0.
|
|
107
|
+
|
|
108
|
+
> [!NOTE]
|
|
109
|
+
> Rails 7.1/7.2/8.0 apps created today resolve `json` 3.x and `minitest` 6.x,
|
|
110
|
+
> which those Rails versions themselves don't support (`unknown keyword:
|
|
111
|
+
> quirks_mode` when saving a JSON column; a minitest `ArgumentError` on 7.1).
|
|
112
|
+
> This is unrelated to the converter — pin `gem "json", "< 3"` and
|
|
113
|
+
> `gem "minitest", "< 6"` in such apps (the CI check does the same).
|
|
114
|
+
|
|
115
|
+
> [!WARNING]
|
|
116
|
+
> On any Rails version: `refract` 1.1.0 (a dependency of phlex 2.4) references
|
|
117
|
+
> `Prism::MissingNode`, which `prism` 2.x removed. Apps that resolve prism 2.x
|
|
118
|
+
> crash with `uninitialized constant Prism::MissingNode` as soon as they eager
|
|
119
|
+
> load (production, or tests with `CI` set) — even in development everything
|
|
120
|
+
> looks fine. Until refract is fixed, add `gem "prism", "< 2"` to your Gemfile.
|
|
121
|
+
> The CI check pins it too and runs with eager loading on.
|
|
122
|
+
|
|
61
123
|
## CLI usage
|
|
62
124
|
|
|
63
125
|
```bash
|
|
64
|
-
# Convert a whole folder (recursively), writing .rb next to each .erb
|
|
126
|
+
# Convert a whole folder (recursively), writing .rb next to each .erb/.haml
|
|
65
127
|
bundle exec ruby_ui_converter convert app/views/users
|
|
66
128
|
|
|
67
129
|
# Preview without writing anything
|
|
@@ -109,6 +171,9 @@ RubyUIConverter.convert_string('<h1><%= @title %></h1>', class_name: "Page")
|
|
|
109
171
|
# h1 { @title }
|
|
110
172
|
# end
|
|
111
173
|
# end
|
|
174
|
+
|
|
175
|
+
# Same for HAML
|
|
176
|
+
RubyUIConverter.convert_string("%h1= @title", class_name: "Page", format: :haml)
|
|
112
177
|
```
|
|
113
178
|
|
|
114
179
|
## What gets converted
|
|
@@ -130,6 +195,7 @@ RubyUIConverter.convert_string('<h1><%= @title %></h1>', class_name: "Page")
|
|
|
130
195
|
| `<%= render "form", user: @user %>` | `render Views::Users::Form.new(user: @user)` |
|
|
131
196
|
| `data-id="<%= id %>"` | `"data-id": id` |
|
|
132
197
|
| `<%# comment %>` | `# comment` |
|
|
198
|
+
| `© 2026 — a <b>` | `"© 2026\u00A0— a <b>"` (Phlex re-escapes) |
|
|
133
199
|
|
|
134
200
|
Top-level views get an initializer built from the controller instance variables
|
|
135
201
|
they reference (`@products` → `def initialize(products: nil); @products = products; end`),
|
|
@@ -188,6 +254,63 @@ class Components::Base < Phlex::HTML
|
|
|
188
254
|
end
|
|
189
255
|
```
|
|
190
256
|
|
|
257
|
+
## HAML templates
|
|
258
|
+
|
|
259
|
+
`.haml` files are converted alongside `.erb` ones (same CLI, same options,
|
|
260
|
+
same naming: `_user.html.haml` → `Views::Users::User`). Parsing is done by
|
|
261
|
+
the [haml](https://rubygems.org/gems/haml) gem's own parser (5.2, 6.x or 7.x),
|
|
262
|
+
which must be loadable by the converter — run it with `bundle exec` inside an
|
|
263
|
+
app that uses HAML. Without it, `.erb` files still convert and the CLI
|
|
264
|
+
explains once how to add the gem.
|
|
265
|
+
|
|
266
|
+
The HAML front-end builds the **same intermediate tree** the ERB one does, so
|
|
267
|
+
everything else — RubyUI element mapping, `link_to`/`render` translation,
|
|
268
|
+
form-builder mapping, locals/ivars detection, `--literal` — applies unchanged.
|
|
269
|
+
|
|
270
|
+
| HAML | Generated Ruby |
|
|
271
|
+
| --------------------------------------------- | ----------------------------------------------------------- |
|
|
272
|
+
| `%p.lead#intro Hello` | `p(class: "lead", id: "intro") { "Hello" }` |
|
|
273
|
+
| `%p= user.email` / `!= markup` | `p { user.email }` / `raw(safe(markup))` |
|
|
274
|
+
| `%p Hi #{name}` | `plain "Hi "` + `plain(name)` (like `Hi <%= name %>`) |
|
|
275
|
+
| `Go to #{link_to "Docs", docs_path}` | `plain "Go to "` + `Link(href: docs_path) { "Docs" }` |
|
|
276
|
+
| `- if a` / `- elsif b` / `- else` | real `if / elsif / else / end` |
|
|
277
|
+
| `- items.each do \|i\|` | `items.each do \|i\| … end` |
|
|
278
|
+
| `.card{class: "shadow"}` | `Card(class: "card shadow")` |
|
|
279
|
+
| `%p.btn{class: "x #{y}"}` | `p(class: "btn x #{y}")` |
|
|
280
|
+
| `%p.row{class: [a, b]}` | `p(class: ["row", [a, b]])` (Phlex flattens the array) |
|
|
281
|
+
| `%a{data: {turbo_method: :delete}}` | `"data-turbo-method": "delete"` |
|
|
282
|
+
| `%input{type: "checkbox", checked: false}` | `Checkbox()` (`false`/`nil` attributes are dropped) |
|
|
283
|
+
| `%li[@user]` | `li(id: dom_id(@user), class: dom_class(@user))` |
|
|
284
|
+
| `%div{html_attrs}` | `div(**(html_attrs))` |
|
|
285
|
+
| `-# note` / `/ note` | `# note` / `comment { "note" }` |
|
|
286
|
+
| `/` with nested content | `comment do … end` |
|
|
287
|
+
| `:javascript` / `:css` | `script` / `style` with raw content (interpolation kept) |
|
|
288
|
+
| `:plain` / `:escaped` / `:erb` / `:ruby` | text (as HTML) / escaped text / ERB conversion / statements |
|
|
289
|
+
|
|
290
|
+
Plain text keeps HAML's semantics: it may contain inline HTML (parsed into
|
|
291
|
+
elements) and its `#{}` interpolations are escaped. HAML puts a line break
|
|
292
|
+
between lines, so a text line next to other content gets a separating space
|
|
293
|
+
(`Signed in as` + `%strong= name` → `plain "Signed in as "` + `strong { name }`),
|
|
294
|
+
except around tags that remove whitespace (`%img>`).
|
|
295
|
+
|
|
296
|
+
HAML-specific limitations:
|
|
297
|
+
|
|
298
|
+
- Whitespace is approximated: only text lines get the separating space; two
|
|
299
|
+
adjacent outputs/tags on separate lines render without one, and `~`/preserve
|
|
300
|
+
handling is not reproduced.
|
|
301
|
+
- `[@obj]` uses Rails' `dom_id`/`dom_class`: for a new record that gives
|
|
302
|
+
`new_user`, where HAML gives `user_new`. Include the `DOMID` helper (see
|
|
303
|
+
[step 3](#3-include-the-rails-helpers-your-views-use)).
|
|
304
|
+
- `{id: [a, b]}`: HAML joins with `_`, Phlex with a space.
|
|
305
|
+
- A class array merged with shorthand classes relies on Phlex flattening
|
|
306
|
+
nested arrays (verified on Phlex 2).
|
|
307
|
+
- Class-based RubyUI rules (`.card` → `Card`, `.badge` → `Badge`) only match a
|
|
308
|
+
static class: `.card{class: "x #{y}"}` or `.card[@user]` stays a `div`
|
|
309
|
+
(same as `<div class="card <%= y %>">` in ERB).
|
|
310
|
+
- IE conditional comments (`/[if IE]`) and filters other than the ones above
|
|
311
|
+
(`:markdown`, `:scss`, …) are left as `# TODO` comments.
|
|
312
|
+
- Inline HTML that opens on one line and closes on another isn't nested.
|
|
313
|
+
|
|
191
314
|
## Migrating a Rails ERB app
|
|
192
315
|
|
|
193
316
|
`ruby_ui_converter` only **generates** the component source — running it
|
|
@@ -450,11 +573,14 @@ Caveats worth reviewing (heuristic, model binding is reconstructed by hand):
|
|
|
450
573
|
|
|
451
574
|
## Design & limitations
|
|
452
575
|
|
|
453
|
-
The converter is a pure-Ruby pipeline with **no native dependencies
|
|
576
|
+
The converter is a pure-Ruby pipeline with **no native dependencies** (only
|
|
577
|
+
`thor` at runtime; the haml gem is used, when present, for `.haml` files). Two
|
|
578
|
+
front-ends build the same AST, which a single Transformer turns into Ruby:
|
|
454
579
|
|
|
455
580
|
```
|
|
456
|
-
|
|
457
|
-
|
|
581
|
+
.erb ─▶ Lexer ─▶ HtmlTokenizer ─▶ Parser ──────┐
|
|
582
|
+
├─▶ AST ─▶ Transformer ─▶ Ruby/Phlex
|
|
583
|
+
.haml ─▶ Haml::Parser (haml gem) ─▶ HamlParser ─┘ (CodeBuilder)
|
|
458
584
|
```
|
|
459
585
|
|
|
460
586
|
It covers the common cases well, but it is a heuristic source-to-source tool —
|
|
@@ -467,10 +593,18 @@ It covers the common cases well, but it is a heuristic source-to-source tool —
|
|
|
467
593
|
as blocks but may need manual adjustment for phlex-rails idioms.
|
|
468
594
|
- `render @collection` / object forms are emitted as a bare `render ...` call;
|
|
469
595
|
phlex-rails' `#render` handles model objects and relations.
|
|
596
|
+
- HTML character references in text and literal attribute values are
|
|
597
|
+
decoded (`©` → `©`, `—` → `—`) so Phlex doesn't escape them
|
|
598
|
+
twice; named references cover the HTML 4 set (` `, `—`,
|
|
599
|
+
accented letters, arrows, Greek, …) — other names are left as written.
|
|
470
600
|
- Inline `<script>` / `<style>` content is wrapped in a raw call
|
|
471
601
|
(`raw(safe(...))` on Phlex 2, `unsafe_raw(...)` on Phlex 1) with a TODO.
|
|
472
602
|
- Custom elements (e.g. `<my-widget>`) are emitted as method calls and may need
|
|
473
603
|
a Phlex-compatible registration.
|
|
604
|
+
- Two templates for the same action (`show.html.erb` + `show.html.haml`, or a
|
|
605
|
+
variant like `show.html+phone.erb`) would generate the same `.rb`: the first
|
|
606
|
+
is converted and the other is reported as an error.
|
|
607
|
+
- See also the [HAML-specific limitations](#haml-templates).
|
|
474
608
|
|
|
475
609
|
Generated files are checked for valid Ruby syntax by the test suite, but
|
|
476
610
|
semantic equivalence is your responsibility to verify.
|
|
@@ -480,6 +614,9 @@ semantic equivalence is your responsibility to verify.
|
|
|
480
614
|
```bash
|
|
481
615
|
bin/setup # bundle install
|
|
482
616
|
bundle exec rake test
|
|
617
|
+
|
|
618
|
+
# end-to-end check against a fresh Rails app (latest patch of the series)
|
|
619
|
+
script/rails_e2e/run.sh 8.1
|
|
483
620
|
```
|
|
484
621
|
|
|
485
622
|
## License
|
|
@@ -8,11 +8,12 @@ module RubyUIConverter
|
|
|
8
8
|
true
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
desc "convert PATH", "Convert .erb views/partials under PATH into RubyUI/Phlex .rb files"
|
|
11
|
+
desc "convert PATH", "Convert .erb/.haml views/partials under PATH into RubyUI/Phlex .rb files"
|
|
12
12
|
long_desc <<~DESC
|
|
13
|
-
Recursively walks PATH looking for *.erb files and writes an
|
|
14
|
-
.rb component next to each one (e.g. index.html.erb ->
|
|
15
|
-
partials (_form.html.
|
|
13
|
+
Recursively walks PATH looking for *.erb and *.haml files and writes an
|
|
14
|
+
equivalent .rb component next to each one (e.g. index.html.erb ->
|
|
15
|
+
index.rb). Rails partials (_form.html.haml) become their own component
|
|
16
|
+
classes (form.rb). Converting .haml requires the haml gem to be loadable.
|
|
16
17
|
|
|
17
18
|
By default, basic HTML elements are mapped onto RubyUI kit components
|
|
18
19
|
(a -> Link, button -> Button, input -> Input, table -> Table, ...).
|
|
@@ -71,7 +72,7 @@ module RubyUIConverter
|
|
|
71
72
|
|
|
72
73
|
def report(results, config)
|
|
73
74
|
if results.empty?
|
|
74
|
-
say "No .erb files found.", :yellow
|
|
75
|
+
say "No .erb or .haml files found.", :yellow
|
|
75
76
|
return
|
|
76
77
|
end
|
|
77
78
|
|
|
@@ -86,13 +87,28 @@ module RubyUIConverter
|
|
|
86
87
|
when :skipped
|
|
87
88
|
say " skipped #{relative(result.output)} (exists, use --force)", :yellow
|
|
88
89
|
when :error
|
|
89
|
-
|
|
90
|
+
if missing_haml?(result)
|
|
91
|
+
say " error #{relative(result.source)}: haml gem not available", :red
|
|
92
|
+
else
|
|
93
|
+
say " error #{relative(result.source)}: #{result.error.message}", :red
|
|
94
|
+
end
|
|
90
95
|
end
|
|
91
96
|
end
|
|
92
97
|
|
|
93
98
|
counts = results.group_by(&:status).transform_values(&:size)
|
|
94
99
|
say ""
|
|
95
100
|
say "Done. #{counts.map { |status, n| "#{n} #{status}" }.join(", ")}."
|
|
101
|
+
|
|
102
|
+
# Every .haml file fails the same way without the gem: explain it once.
|
|
103
|
+
return unless results.any? { |result| missing_haml?(result) }
|
|
104
|
+
|
|
105
|
+
say ""
|
|
106
|
+
say "Note: #{HamlParser::MISSING_HAML}, then run the converter again " \
|
|
107
|
+
"(inside your app, `bundle exec ruby_ui_converter ...`).", :yellow
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def missing_haml?(result)
|
|
111
|
+
result.status == :error && result.error&.message == HamlParser::MISSING_HAML
|
|
96
112
|
end
|
|
97
113
|
|
|
98
114
|
def preview(result)
|
|
@@ -32,7 +32,12 @@ module RubyUIConverter
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def run
|
|
35
|
-
|
|
35
|
+
# output path -> source that claimed it in this run. Two templates of the
|
|
36
|
+
# same action (show.html.erb + show.html.haml, or a variant like
|
|
37
|
+
# show.html+phone.erb) map to the same .rb; the later one is reported as
|
|
38
|
+
# an error instead of silently skipping or overwriting the first.
|
|
39
|
+
@claimed = {}
|
|
40
|
+
FileWalker.new(@path).template_files.map { |file| convert_file(file) }
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
private
|
|
@@ -55,9 +60,17 @@ module RubyUIConverter
|
|
|
55
60
|
def convert_file(file)
|
|
56
61
|
file = File.expand_path(file)
|
|
57
62
|
template = Template.new(path: file, root: root, config: @config)
|
|
58
|
-
code = template.render
|
|
59
63
|
output = template.output_path
|
|
60
64
|
|
|
65
|
+
if (other = @claimed[output])
|
|
66
|
+
error = Error.new("#{File.basename(output)} is also generated from #{File.basename(other)}; " \
|
|
67
|
+
"convert one of them (or remove the other) to avoid a clash")
|
|
68
|
+
return Result.new(source: file, output: output, status: :error, error: error)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@claimed[output] = file
|
|
72
|
+
code = template.render
|
|
73
|
+
|
|
61
74
|
if @config.dry_run
|
|
62
75
|
return Result.new(source: file, output: output, status: :previewed, code: code)
|
|
63
76
|
end
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubyUIConverter
|
|
4
|
-
# Finds .erb
|
|
4
|
+
# Finds template files (.erb, .haml) under a path (recursively for
|
|
5
|
+
# directories).
|
|
5
6
|
class FileWalker
|
|
6
|
-
|
|
7
|
+
# Keep in sync with RubyUIConverter::FORMATS.
|
|
8
|
+
GLOB = "**/*.{erb,haml}"
|
|
7
9
|
|
|
8
10
|
def initialize(path)
|
|
9
11
|
@path = path
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
def
|
|
14
|
+
def template_files
|
|
13
15
|
if File.directory?(@path)
|
|
14
|
-
Dir.glob(File.join(@path,
|
|
15
|
-
elsif File.file?(@path) && @path
|
|
16
|
+
Dir.glob(File.join(@path, GLOB)).select { |f| File.file?(f) }.sort
|
|
17
|
+
elsif File.file?(@path) && RubyUIConverter.format_for(@path)
|
|
16
18
|
[@path]
|
|
17
19
|
else
|
|
18
20
|
[]
|
|
19
21
|
end
|
|
20
22
|
end
|
|
23
|
+
|
|
24
|
+
# Kept for backwards compatibility: only the .erb templates.
|
|
25
|
+
def erb_files
|
|
26
|
+
template_files.select { |f| RubyUIConverter.format_for(f) == :erb }
|
|
27
|
+
end
|
|
21
28
|
end
|
|
22
29
|
end
|
|
@@ -60,6 +60,10 @@ module RubyUIConverter
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def model_expression(header)
|
|
63
|
+
# Drop the block opener first so an unparenthesized call
|
|
64
|
+
# (`form_with model: @product do |form|`) doesn't capture `do |form|`.
|
|
65
|
+
header = header.sub(/\s*\bdo\s*(\|[^|]*\|)?\s*\z/, "")
|
|
66
|
+
|
|
63
67
|
if (model = header[/\bmodel:\s*([^,)]+)/, 1])
|
|
64
68
|
return model.strip
|
|
65
69
|
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIConverter
|
|
4
|
+
# Turns the attributes of a HAML tag (as Haml::Parser reports them) into the
|
|
5
|
+
# converter's attribute list — `[[name, parts], ...]`, the same shape the ERB
|
|
6
|
+
# Parser builds (see Nodes) — merging them the way HAML does:
|
|
7
|
+
#
|
|
8
|
+
# * sources, in order: static (`.a#b`, `(x="y")`, `(disabled)`), html-style
|
|
9
|
+
# dynamic `(x=expr)`, the `{...}` hash, then the object reference `[@obj]`;
|
|
10
|
+
# * `class` values are merged (space-joined), `id` values joined with `_`;
|
|
11
|
+
# any other repeated attribute is overridden by the later source;
|
|
12
|
+
# * `false`/`nil` literals drop the attribute, `true` makes it boolean;
|
|
13
|
+
# * literal `data:`/`aria:` hashes expand to hyphenated attributes
|
|
14
|
+
# (`data: {turbo_method: :delete}` -> `"data-turbo-method": "delete"`);
|
|
15
|
+
# * string literals become text/erb parts (so ComponentMap matchers like
|
|
16
|
+
# `static_classes` keep working); symbol literals become text;
|
|
17
|
+
# * a hash Haml can't split (`{html_attrs}`, `{**opts}`) becomes a
|
|
18
|
+
# `**(...)` splat.
|
|
19
|
+
class HamlAttributes
|
|
20
|
+
NESTED = %w[data aria].freeze
|
|
21
|
+
MERGED = { "class" => " ", "id" => "_" }.freeze
|
|
22
|
+
|
|
23
|
+
Entry = Struct.new(:name, :values)
|
|
24
|
+
|
|
25
|
+
def self.build(tag_value)
|
|
26
|
+
new(tag_value).build
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(tag_value)
|
|
30
|
+
@tag = tag_value
|
|
31
|
+
@entries = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build
|
|
35
|
+
static_attributes
|
|
36
|
+
dynamic = @tag[:dynamic_attributes]
|
|
37
|
+
dynamic_hash(dynamic&.new)
|
|
38
|
+
dynamic_hash(dynamic&.old)
|
|
39
|
+
object_reference
|
|
40
|
+
@entries.map { |entry| finalize(entry) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# --- sources -----------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def static_attributes
|
|
48
|
+
@tag[:attributes].to_h.each do |name, value|
|
|
49
|
+
case value
|
|
50
|
+
when true then add(name.to_s, [:bool])
|
|
51
|
+
when nil, false then add(name.to_s, nil)
|
|
52
|
+
else add(name.to_s, [:parts, [[:text, value.to_s]]])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dynamic_hash(hash_code)
|
|
58
|
+
return if hash_code.nil? || hash_code.strip.empty?
|
|
59
|
+
|
|
60
|
+
pairs = ::Haml::AttributeParser.parse(hash_code)
|
|
61
|
+
if pairs
|
|
62
|
+
pairs.each { |name, code| add_code(name.to_s, code) }
|
|
63
|
+
else
|
|
64
|
+
@entries << Entry.new(:__splat__, [[:parts, [erb(splat_code(hash_code))]]])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# `%div[@user]` / `%div[@user, :edit]` -> Rails' dom_id / dom_class.
|
|
69
|
+
def object_reference
|
|
70
|
+
ref = @tag[:object_ref]
|
|
71
|
+
return if ref.nil? || ref == :nil
|
|
72
|
+
|
|
73
|
+
args = RailsHelpers.split_args(ref.to_s.strip.sub(/\A\[/, "").sub(/\]\z/, ""))
|
|
74
|
+
return if args.empty?
|
|
75
|
+
|
|
76
|
+
call_args = args.first(2).join(", ")
|
|
77
|
+
add("id", [:code, "dom_id(#{call_args})"])
|
|
78
|
+
add("class", [:code, "dom_class(#{call_args})"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- values ------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def add_code(name, code)
|
|
84
|
+
if NESTED.include?(name) && (nested = nested_hash(code))
|
|
85
|
+
nested.each { |key, value| add_code("#{name}-#{key.to_s.tr("_", "-")}", value) }
|
|
86
|
+
else
|
|
87
|
+
add(name, value_for(code))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def nested_hash(code)
|
|
92
|
+
code = code.to_s.strip
|
|
93
|
+
return nil unless code.start_with?("{")
|
|
94
|
+
|
|
95
|
+
::Haml::AttributeParser.parse(code)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# nil means "drop the attribute".
|
|
99
|
+
def value_for(code)
|
|
100
|
+
code = code.to_s.strip
|
|
101
|
+
return [:bool] if code == "true"
|
|
102
|
+
return nil if %w[false nil].include?(code)
|
|
103
|
+
return [:parts, [[:text, code[1..]]]] if code.match?(/\A:\w+[?!]?\z/)
|
|
104
|
+
|
|
105
|
+
parts = RubyLiteral.string_parts(code)
|
|
106
|
+
return [:parts, parts.map { |kind, value| kind == :text ? [:text, value] : erb(value) }] if parts
|
|
107
|
+
|
|
108
|
+
[:code, code]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def add(name, value)
|
|
112
|
+
entry = @entries.find { |e| e.name == name }
|
|
113
|
+
|
|
114
|
+
if MERGED.key?(name)
|
|
115
|
+
return unless value
|
|
116
|
+
|
|
117
|
+
entry ? entry.values << value : @entries << Entry.new(name, [value])
|
|
118
|
+
elsif value.nil?
|
|
119
|
+
@entries.delete(entry)
|
|
120
|
+
elsif entry
|
|
121
|
+
entry.values = [value]
|
|
122
|
+
else
|
|
123
|
+
@entries << Entry.new(name, [value])
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- output ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def finalize(entry)
|
|
130
|
+
values = entry.values
|
|
131
|
+
return [entry.name, nil] if values == [[:bool]]
|
|
132
|
+
return [entry.name, to_parts(values.first)] if values.length == 1
|
|
133
|
+
|
|
134
|
+
separator = MERGED.fetch(entry.name, " ")
|
|
135
|
+
if entry.name == "class" && values.any? { |kind, _| kind == :code }
|
|
136
|
+
# Arbitrary expressions may be arrays/nil: let Phlex flatten them.
|
|
137
|
+
[entry.name, [erb("[#{values.map { |value| to_code(value) }.join(", ")}]")]]
|
|
138
|
+
else
|
|
139
|
+
parts = values.flat_map.with_index do |value, index|
|
|
140
|
+
(index.zero? ? [] : [[:text, separator]]) + to_parts(value)
|
|
141
|
+
end
|
|
142
|
+
[entry.name, parts]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def to_parts(value)
|
|
147
|
+
kind, payload = value
|
|
148
|
+
case kind
|
|
149
|
+
when :parts then payload
|
|
150
|
+
when :code then [erb(payload)]
|
|
151
|
+
when :bool then [erb("true")]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_code(value)
|
|
156
|
+
kind, payload = value
|
|
157
|
+
return payload if kind == :code
|
|
158
|
+
return "true" if kind == :bool
|
|
159
|
+
return payload[0][1].value if payload.length == 1 && payload[0][0] == :erb
|
|
160
|
+
|
|
161
|
+
body = payload.map do |part_kind, part|
|
|
162
|
+
part_kind == :text ? escape(part) : "\#{#{part.value}}"
|
|
163
|
+
end.join
|
|
164
|
+
"\"#{body}\""
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def escape(text)
|
|
168
|
+
text.gsub(/[\\"\n\t]|\#\{/) do |match|
|
|
169
|
+
{ "\\" => "\\\\", "\"" => "\\\"", "\n" => "\\n", "\t" => "\\t", "\#{" => "\\\#{" }[match]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# "{a: 1}" -> "{a: 1}"; "{html_attrs}" -> "html_attrs" (haml treats the
|
|
174
|
+
# braces' content as an argument list, so a bare expression is the hash).
|
|
175
|
+
def splat_code(hash_code)
|
|
176
|
+
inner = hash_code.to_s.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
177
|
+
args = RailsHelpers.split_args(inner)
|
|
178
|
+
pairs = args.any? { |arg| arg =~ /\A(\*\*|[\w?!]+:(?!:)|"[^"]*":|'[^']*':)/ || arg.include?("=>") }
|
|
179
|
+
pairs ? "{#{inner}}" : inner
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def erb(code)
|
|
183
|
+
[:erb, Lexer::Token.new(type: :output, value: code, raw: false)]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|