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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffc607673bf4249a934ea009f793f034a3d1c75bb72a100ff1df2f39e60e3ff7
4
- data.tar.gz: 670b2734525033da876314c0a30b18da3910c9a7cac284d1d1759e43bc7cf2af
3
+ metadata.gz: 3ee2aaa230c92e0464d439a5767bfdc7913a013015fdd9f56a46f05bfb7c7400
4
+ data.tar.gz: ca39a6d4d10ab0b05d9a58115580faf0b71e3ecd5bee909d6ae7b267c682c98a
5
5
  SHA512:
6
- metadata.gz: ceff723f0ea70a12973656154e947c5367170e8c2eab787f242f317ddbf106528ebeddb6882a74fe53f90917e5139d9d44c3b32c0792b58eab3342fb9f8419bb
7
- data.tar.gz: ee214fe74ea2f9065d72aa5da91441cad67ffee934e756467c5d99702e234086c65891774e825fece326aa98a2b3b24d46267eedda906de846fa059475b75b10
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 `&nbsp;` 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 [Phlex](https://www.phlex.fun) /
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 own Phlex component
15
- classes, with detected locals exposed as keyword arguments. Top-level views get
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
+ | `&copy; 2026&nbsp;&mdash; a &lt;b&gt;` | `"© 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
- ERB ─▶ Lexer ─▶ HtmlTokenizer ─▶ Parser ─▶ Transformer ─▶ Ruby/Phlex
457
- (tokens) (html tokens) (AST) (CodeBuilder)
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 (`&copy;` → `©`, `&#8212;` → `—`) so Phlex doesn't escape them
598
+ twice; named references cover the HTML 4 set (`&nbsp;`, `&mdash;`,
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 equivalent
14
- .rb component next to each one (e.g. index.html.erb -> index.rb). Rails
15
- partials (_form.html.erb) become their own component classes (form.rb).
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
- say " error #{relative(result.source)}: #{result.error.message}", :red
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
- FileWalker.new(@path).erb_files.map { |file| convert_file(file) }
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 files under a path (recursively for directories).
4
+ # Finds template files (.erb, .haml) under a path (recursively for
5
+ # directories).
5
6
  class FileWalker
6
- ERB_GLOB = "**/*.erb"
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 erb_files
14
+ def template_files
13
15
  if File.directory?(@path)
14
- Dir.glob(File.join(@path, ERB_GLOB)).select { |f| File.file?(f) }.sort
15
- elsif File.file?(@path) && @path.end_with?(".erb")
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