jsx_rosetta 0.4.0 → 0.6.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +342 -11
  3. data/CLAUDE.md +70 -0
  4. data/README.md +50 -0
  5. data/ROADMAP.md +92 -0
  6. data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
  7. data/lib/jsx_rosetta/ast/inflector.rb +32 -0
  8. data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
  9. data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
  10. data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
  11. data/lib/jsx_rosetta/backend/view_component.rb +261 -31
  12. data/lib/jsx_rosetta/cli.rb +175 -37
  13. data/lib/jsx_rosetta/icons/lucide.json +37 -0
  14. data/lib/jsx_rosetta/icons.rb +44 -0
  15. data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
  16. data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
  17. data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
  18. data/lib/jsx_rosetta/ir/types.rb +264 -19
  19. data/lib/jsx_rosetta/ir.rb +5 -4
  20. data/lib/jsx_rosetta/pages_routing.rb +640 -0
  21. data/lib/jsx_rosetta/version.rb +1 -1
  22. data/lib/jsx_rosetta.rb +8 -6
  23. data/plans/nextjs_pages_to_rails.md +200 -0
  24. data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
  25. data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
  26. data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
  27. data/plans/translator_widening_and_pages_followups.md +120 -0
  28. data/plans/translator_widening_slice_a.md +208 -0
  29. data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
  30. data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
  31. data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
  32. data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
  33. data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
  34. data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
  35. data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
  36. data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
  37. data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
  38. data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
  39. data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
  40. data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
  41. data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
  42. data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
  43. data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
  44. data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
  45. metadata +30 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../ast/inflector"
4
4
  require_relative "../ir/types"
5
+ require_relative "../pages_routing"
5
6
  require_relative "base"
6
7
  require_relative "view_component/expression_translator"
7
8
 
@@ -31,37 +32,198 @@ module JsxRosetta
31
32
  VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
32
33
  VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
33
34
 
35
+ # Inline budget for object/array literal rendering. When the
36
+ # single-line rendering of a literal exceeds this width — measured
37
+ # from the opening bracket — it switches to a multi-line layout
38
+ # with one entry per line, indented two spaces past the parent's
39
+ # line indent. Closing bracket re-aligns to the parent indent.
40
+ # Chosen to keep typical attr lines under ~120 chars after the
41
+ # kwarg name and surrounding `render Foo.new(...)` wrapper.
42
+ LITERAL_INLINE_BUDGET = 80
43
+
44
+ # Per-library TODO header lines surfaced above the verbatim hook
45
+ # source. Each library has a different Rails analog, so we don't
46
+ # collapse them into a single generic block. Keys must mirror the
47
+ # `:library` values produced by IR::Lowering.
48
+ HOOK_TODO_HEADERS = {
49
+ react: [
50
+ "TODO: React hooks detected. None translate automatically.",
51
+ "Hotwire/Stimulus handles behavior; controllers/views handle state;",
52
+ "turbo-frames handle async loading. Original source:"
53
+ ].freeze,
54
+ apollo: [
55
+ "TODO: Apollo data-fetching hooks detected. None translate automatically.",
56
+ "Move the fetch to the Rails controller (or a model/service); pass the",
57
+ "result in as a prop. For useMutation, use a form POST + redirect or a",
58
+ "Turbo Stream response. Original source:"
59
+ ].freeze,
60
+ next_js: [
61
+ "TODO: Next.js navigation hooks detected. None translate automatically.",
62
+ "Rails analogs: useRouter -> redirect_to / form actions;",
63
+ "usePathname -> request.path; useSearchParams / useParams -> params;",
64
+ "useSelectedLayoutSegment(s) -> match against request.path in the view.",
65
+ "Original source:"
66
+ ].freeze
67
+ }.freeze
68
+
34
69
  # Structured intermediate for the data-action attribute — mirrors the
35
70
  # ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
36
71
  EventDescriptor = Data.define(:kind, :body)
37
72
 
38
- def initialize(suffix: nil, namespace: nil)
73
+ RAILS_VIEW_BASE_CLASS = "Views::Base"
74
+
75
+ # Per-tag map of which attribute names carry a URL that the href
76
+ # rewriter should attempt to rewrite. Most link tags use `href`/`to`;
77
+ # `<form action="...">` uses `action`. Slice C1 added the `form`
78
+ # entry — non-GET forms are skipped at format_attributes time
79
+ # before they reach try_rewrite_href.
80
+ LINK_TAG_ATTRS = {
81
+ "a" => %w[href].freeze,
82
+ "Link" => %w[href to].freeze,
83
+ "NavLink" => %w[href to].freeze,
84
+ "RouterLink" => %w[href to].freeze,
85
+ "form" => %w[action].freeze
86
+ }.freeze
87
+ # Matches `router.push("…")` / `router.push('…')` / `router.push(\`…\`)`
88
+ # in verbatim hook source, capturing the quoted argument (including the
89
+ # surrounding quote/backtick). A3: only fires for sync hook bodies —
90
+ # event-handler bodies live in IR::EventHandler / IR::StimulusMethod
91
+ # sources, not in react_hooks, so they're naturally out of scope.
92
+ ROUTER_PUSH_PATTERN = /router\.push\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)\s*[,)]/
93
+
94
+ # Per-wrapper TODO header text. Each HOC that lowering peeled off the
95
+ # source (recorded on IR::Component#hoc_wrappers) gets one short line
96
+ # above the class explaining the Rails analog. Falls back to a
97
+ # generic line for wrappers not in this map.
98
+ HOC_WRAPPER_TODO_LINES = {
99
+ "memo" => "TODO: original component was wrapped in memo(...). React's memo memoizes " \
100
+ "by shallow prop equality. Rails analog: fragment caching (`cache @model do …`) " \
101
+ "when the view is expensive.",
102
+ "forwardRef" => "TODO: original component was wrapped in forwardRef(...). The second " \
103
+ "arg (`ref`) was dropped — Rails has no view-side ref-forwarding analog; " \
104
+ "use Stimulus targets / DOM ids if the host needs a handle.",
105
+ "observer" => "TODO: original component was wrapped in observer(...) (mobx). React's " \
106
+ "observer auto-subscribes to observable state. Rails analog: the controller " \
107
+ "loads data and sets @ivars; reactivity moves to Turbo Streams / Hotwire.",
108
+ "connect" => "TODO: original component was wrapped in connect(...)(X) (redux). Props " \
109
+ "injected by mapState/mapDispatch in the source — in Rails, expect those as " \
110
+ "controller-passed instance variables instead.",
111
+ "withRouter" => "TODO: original component was wrapped in withRouter(...) (React Router). " \
112
+ "Injected router props (location, history, match) map to Rails: request.path, " \
113
+ "redirect_to / form actions, and params.",
114
+ "withTranslation" => "TODO: original component was wrapped in withTranslation()(X) (i18n). " \
115
+ "Injected `t` translator function maps to Rails I18n: `I18n.t(\"…\")` " \
116
+ "or the `t` view helper."
117
+ }.freeze
118
+
119
+ def initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil)
39
120
  super()
40
121
  raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
122
+ if rails_view && (suffix || namespace)
123
+ raise ArgumentError, "Phlex backend: rails_view: cannot be combined with suffix: or namespace:"
124
+ end
41
125
 
42
126
  @suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
43
127
  @namespace = namespace
128
+ @rails_view = rails_view
129
+ @href_rewriter = route_table && PagesRouting::HrefRewriter.new(route_table)
44
130
  end
45
131
 
46
- def emit(component)
47
- prop_names = component.props.map(&:name)
48
- prop_names << component.rest_prop_name if component.rest_prop_name
49
- translator = ViewComponent::ExpressionTranslator.new(
50
- prop_names: prop_names, local_binding_names: component.local_binding_names
51
- )
52
-
132
+ def emit(component, source_filename: nil)
133
+ @current_component = component
134
+ translator = build_translator(component)
53
135
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
54
136
  @lambda_methods = []
55
137
  @lambda_method_counts = {}
138
+ @event_handler_methods = []
139
+ @emit_module_prefix = first_emit_for_module_bindings?(component)
140
+ @source_filename = source_filename
56
141
 
57
- files = [File.new(path: ruby_path(component), contents: render_ruby_class(component, translator))]
142
+ files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
58
143
  if component.stimulus_methods.any?
59
144
  files << File.new(
60
145
  path: stimulus_path(component),
61
146
  contents: render_stimulus_controller_js(component)
62
147
  )
63
148
  end
149
+ files.concat(lucide_icon_files(component))
64
150
  files
151
+ ensure
152
+ # Drop the per-emit IR reference so a long-running emitter
153
+ # instance doesn't pin the entire component tree until the
154
+ # next emit() call.
155
+ @current_component = nil
156
+ end
157
+
158
+ # When a source file lowers to multiple sibling components, lower_all
159
+ # attaches the *same* module_bindings array to every sibling. Emitting
160
+ # the constants TODO block on each one duplicates 40-line GraphQL
161
+ # blocks across every emitted .rb file. Track the array identities
162
+ # we've seen and only emit the prefix the first time.
163
+ def first_emit_for_module_bindings?(component)
164
+ return true if component.module_bindings.empty?
165
+
166
+ @seen_module_bindings ||= Set.new
167
+ key = component.module_bindings.object_id
168
+ return false if @seen_module_bindings.include?(key)
169
+
170
+ @seen_module_bindings << key
171
+ true
172
+ end
173
+
174
+ def build_translator(component)
175
+ prop_names = component.props.map(&:name)
176
+ prop_names << component.rest_prop_name if component.rest_prop_name
177
+ prop_aliases = component.props.each_with_object({}) do |prop, hash|
178
+ hash[prop.alias_name] = prop.name if prop.alias_name
179
+ end
180
+ # `imported_names` covers both top-level `import` declarations AND
181
+ # top-level helper bindings (`function onError(){}`, `const FOO = …`).
182
+ # They behave identically at the use site — the translator bails out
183
+ # to `nil` rather than emitting a bare snake_case ref that NameErrors.
184
+ ViewComponent::ExpressionTranslator.new(
185
+ prop_names: prop_names,
186
+ local_binding_names: component.local_binding_names,
187
+ prop_aliases: prop_aliases,
188
+ imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
189
+ )
190
+ end
191
+
192
+ # Strip trailing whitespace from each emitted line — easier than
193
+ # threading rstrip through every formatting helper, and a single
194
+ # source of truth keeps Layout/TrailingWhitespace at zero. Preserve
195
+ # the trailing newline of the file as-is. Also suppresses the
196
+ # intentional-`if false` cops file-wide so the user's rubocop
197
+ # doesn't drown out actionable findings.
198
+ def clean_output(source)
199
+ cleaned = "#{source.split("\n").map(&:rstrip).join("\n")}\n".sub(/\n\n+\z/, "\n")
200
+ suppress_intentional_if_false_cops(cleaned)
201
+ end
202
+
203
+ # When the file contains any `if false` / `elsif false` branch (the
204
+ # fallback we emit when a JSX condition can't be translated to Ruby),
205
+ # disable the cops that flag those at file scope. The corresponding
206
+ # `# TODO: translate condition:` comment already names the issue, so
207
+ # the cop's report is redundant noise. Only emits when needed.
208
+ def suppress_intentional_if_false_cops(source)
209
+ cops = []
210
+ cops << "Lint/LiteralAsCondition" if source.match?(/^\s*(?:if|elsif) false$/m)
211
+ # An elsif-false adjacent to its leading `if false` is the only
212
+ # configuration that triggers DuplicateElsifCondition — multiple
213
+ # `if false`s in separate scopes don't qualify. The pattern below
214
+ # matches an `if false` directly followed (after consequent lines)
215
+ # by an `elsif false` at the same indent.
216
+ cops << "Lint/DuplicateElsifCondition" if source.match?(/^(\s*)if false\b[\s\S]*?^\1elsif false\b/m)
217
+ return source if cops.empty?
218
+
219
+ disable = "# rubocop:disable #{cops.join(", ")}\n\n"
220
+ enable = "# rubocop:enable #{cops.join(", ")}\n"
221
+ # Magic comment must be followed by a blank line before any other
222
+ # comment (Layout/EmptyLineAfterMagicComment), and every file-level
223
+ # disable needs a matching enable (Lint/MissingCopEnableDirective).
224
+ with_disable = source.sub(/^# frozen_string_literal: true\n\n/,
225
+ "# frozen_string_literal: true\n\n#{disable}")
226
+ "#{with_disable.chomp}\n#{enable}"
65
227
  end
66
228
 
67
229
  private
@@ -72,42 +234,234 @@ module JsxRosetta
72
234
  # letter when forming the class name. Pure-PascalCase names pass
73
235
  # through unchanged.
74
236
  def class_name(component)
237
+ return rails_view_class_name if @rails_view
238
+
75
239
  base = "#{component.name[0].upcase}#{component.name[1..]}"
76
- @suffix ? "#{base}#{@suffix}" : base
240
+ suffix = effective_suffix_for(base, source_filename: @source_filename)
241
+ suffix ? "#{base}#{suffix}" : base
242
+ end
243
+
244
+ def rails_view_class_name
245
+ return rails_layout_class_name if @rails_view.kind == :layout
246
+
247
+ namespace_parts = (@rails_view.namespace || []).map { |ns| AST::Inflector.upper_camelize(ns) }
248
+ controller = AST::Inflector.upper_camelize(@rails_view.controller)
249
+ action = AST::Inflector.upper_camelize(@rails_view.action)
250
+ (["Views"] + namespace_parts + [controller, action]).join("::")
251
+ end
252
+
253
+ # Layouts land under Views::Layouts:: regardless of source path —
254
+ # `_app.tsx` → Views::Layouts::Application. There's only ever one
255
+ # "controller" for layouts (`layouts`), so the controller segment
256
+ # is collapsed into the namespace.
257
+ def rails_layout_class_name
258
+ action = AST::Inflector.upper_camelize(@rails_view.action)
259
+ "Views::Layouts::#{action}"
260
+ end
261
+
262
+ def parent_class
263
+ @rails_view ? RAILS_VIEW_BASE_CLASS : PHLEX_BASE_CLASS
264
+ end
265
+
266
+ # Pick the suffix to append to a class name, applying two rules in
267
+ # order:
268
+ #
269
+ # 1. **Page detection.** Source name ends in `Page` OR the source
270
+ # file path contains `/pages/` (Next.js / Nuxt convention) →
271
+ # use the literal `Page` suffix, regardless of the configured
272
+ # `@suffix`. Lets the gem keep `<HomePage>` / `pages/home.tsx`
273
+ # landing as `HomePage` / `home_page.rb` even when the user
274
+ # passes `--phlex-suffix=Component` for the rest of the codebase.
275
+ # 2. **No double-suffix.** When the name already ends with the
276
+ # chosen suffix (e.g. source `HomePage` with the `Page` suffix,
277
+ # or source `FooComponent` with the `Component` suffix), skip
278
+ # the append. Returns nil so callers don't concatenate.
279
+ #
280
+ # `source_filename` is the absolute or repo-relative path of the
281
+ # JSX source; nil when callers translate raw source strings
282
+ # without filename context (then only the name-based signal fires).
283
+ def effective_suffix_for(name, source_filename: nil)
284
+ suffix = page?(name, source_filename) ? "Page" : @suffix
285
+ return nil unless suffix
286
+ return nil if name.end_with?(suffix)
287
+
288
+ suffix
289
+ end
290
+
291
+ def page?(name, source_filename)
292
+ return true if name.end_with?("Page")
293
+ return false unless source_filename
294
+
295
+ source_filename.include?("/pages/")
77
296
  end
78
297
 
79
298
  def ruby_path(component)
299
+ return "#{rails_view_dir}/#{@rails_view.action}.rb" if @rails_view
300
+
80
301
  "#{AST::Inflector.underscore(class_name(component))}.rb"
81
302
  end
82
303
 
83
304
  def stimulus_path(component)
305
+ return "#{rails_view_dir}/#{@rails_view.action}_controller.js" if @rails_view
306
+
84
307
  "#{AST::Inflector.underscore(class_name(component))}_controller.js"
85
308
  end
86
309
 
310
+ def rails_view_dir
311
+ ((@rails_view.namespace || []) + [@rails_view.controller]).join("/")
312
+ end
313
+
87
314
  def stimulus_identifier(component)
88
315
  AST::Inflector.underscore(component.name).tr("_", "-")
89
316
  end
90
317
 
91
318
  def render_ruby_class(component, translator)
92
319
  class_body = render_class_body(component, translator)
93
- prefix = render_module_bindings_prefix(component)
320
+ prefix = "#{render_hoc_wrappers_prefix(component)}" \
321
+ "#{render_server_data_source_prefix(component)}" \
322
+ "#{render_module_bindings_prefix(component)}"
94
323
  wrap_in_namespace("#{prefix}#{class_body}")
95
324
  end
96
325
 
326
+ def render_hoc_wrappers_prefix(component)
327
+ return "" if component.hoc_wrappers.empty?
328
+
329
+ lines = component.hoc_wrappers.map do |wrapper|
330
+ "# #{HOC_WRAPPER_TODO_LINES[wrapper] ||
331
+ "TODO: original component was wrapped in #{wrapper}(...). " \
332
+ "Verify the Rails-side equivalent (or absence) and adjust before shipping."}"
333
+ end
334
+ "#{lines.join("\n")}\n"
335
+ end
336
+
337
+ # When the source file exports `getServerSideProps` /
338
+ # `getStaticProps`, emit a TODO block above the class with the
339
+ # body verbatim. The block names the matching Rails controller
340
+ # action when `@rails_view` is set; otherwise it points at "the
341
+ # host controller" generically.
342
+ def render_server_data_source_prefix(component)
343
+ return "" unless component.server_data_source
344
+
345
+ sds = component.server_data_source
346
+ target = if @rails_view
347
+ "#{AST::Inflector.upper_camelize(@rails_view.controller)}Controller##{@rails_view.action}"
348
+ else
349
+ "the host controller action"
350
+ end
351
+ body_lines = comment_lines(sds.source)
352
+ lines = [
353
+ "# TODO: port the original Next.js `#{sds.hook_name}` to #{target}:",
354
+ "#",
355
+ *body_lines,
356
+ "#",
357
+ "# In Rails: load the data in the controller (set @ivars), and read",
358
+ "# them in this view via the standard props plumbing."
359
+ ]
360
+ "#{lines.join("\n")}\n"
361
+ end
362
+
97
363
  # Top-level `const`/`let` declarations outside the component
98
- # function — captured at lowering time and surfaced here as a TODO
99
- # comment block above the class definition. We don't try to
100
- # translate the JS; the human reviewer either copies the value as
101
- # a Ruby constant or moves it to a Rails initializer.
364
+ # function — captured at lowering time. Cva-shaped bindings get
365
+ # emitted as real Ruby constants (FOO_BASE_CLASS, etc.); generic
366
+ # local bindings still surface as a TODO comment block.
102
367
  def render_module_bindings_prefix(component)
103
368
  return "" if component.module_bindings.empty?
104
369
 
370
+ groups = component.module_bindings.group_by { |b| binding_group(b) }
371
+ cva_bindings = groups[:cva] || []
372
+ module_constants = groups[:module_constant] || []
373
+ other_bindings = groups[:other] || []
374
+ sections = []
375
+ # cva constants are *referenced* by every sibling's class body via
376
+ # FOO_BASE_CLASS / FOO_VARIANT_CLASSES — they have to land in every
377
+ # sibling's file or non-first siblings NameError at render. The
378
+ # non-cva TODO block is informational only, so it suppresses on
379
+ # later siblings to avoid duplicating 40-line GraphQL blocks.
380
+ sections << render_cva_constants(cva_bindings) unless cva_bindings.empty?
381
+ sections << render_module_constants(module_constants) unless module_constants.empty?
382
+ emit_todo_block = @emit_module_prefix && !other_bindings.empty?
383
+ sections << render_module_local_bindings_todo(other_bindings) if emit_todo_block
384
+ sections.compact.join("\n")
385
+ end
386
+
387
+ def binding_group(binding)
388
+ case binding
389
+ when IR::CvaBinding then :cva
390
+ when IR::ModuleConstant then :module_constant
391
+ else :other
392
+ end
393
+ end
394
+
395
+ # Emit each literal-shaped module-level const as a real Ruby constant.
396
+ # Hash and array values are emitted via Ruby's `inspect` and frozen so
397
+ # accidental mutation surfaces immediately. Scalars (string / number /
398
+ # bool / nil) skip the `.freeze` since they're already immutable in
399
+ # Ruby — keeps the output terse for the common `const TITLE = "X"` case.
400
+ def render_module_constants(constants)
401
+ lines = constants.map do |constant|
402
+ literal = constant.value.inspect
403
+ literal += ".freeze" if constant.value.is_a?(Hash) || constant.value.is_a?(Array) ||
404
+ constant.value.is_a?(String)
405
+ "#{constant.constant_name} = #{literal}"
406
+ end
407
+ "#{lines.join("\n")}\n"
408
+ end
409
+
410
+ # Emit one cva binding as a triplet of Ruby constants —
411
+ # FOO_BASE_CLASS, FOO_VARIANT_CLASSES, FOO_DEFAULT_VARIANTS — that
412
+ # the call-site interpolation in the class body references.
413
+ def render_cva_constants(cva_bindings)
414
+ lines = []
415
+ cva_bindings.each do |cva|
416
+ prefix = cva_constant_prefix(cva.name)
417
+ lines << "#{prefix}_BASE_CLASS = #{cva.base_class.inspect}"
418
+ lines << "#{prefix}_VARIANT_CLASSES = #{format_variants_literal(cva.variants)}.freeze"
419
+ unless cva.default_variants.empty?
420
+ lines << "#{prefix}_DEFAULT_VARIANTS = #{cva.default_variants.inspect}.freeze"
421
+ end
422
+ if cva.compound_source
423
+ lines << "# TODO: compoundVariants from #{cva.name} aren't translated — port by hand:"
424
+ lines.concat(comment_lines(cva.compound_source))
425
+ end
426
+ lines << ""
427
+ end
428
+ "#{lines.join("\n").rstrip}\n"
429
+ end
430
+
431
+ # Non-cva module bindings — the original Gap E pre-class TODO block.
432
+ # Distinct from the body-level `render_local_bindings_todo` further
433
+ # below in this file.
434
+ def render_module_local_bindings_todo(bindings)
105
435
  lines = ["# TODO: module-level constants — translate to Ruby constants " \
106
436
  "or move to a Rails initializer:"]
107
- component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
437
+ bindings.each { |b| lines.concat(comment_lines(b.source)) }
108
438
  "#{lines.join("\n")}\n"
109
439
  end
110
440
 
441
+ # buttonVariants → BUTTON, alertVariants → ALERT. The degenerate
442
+ # name `"Variants"` would strip to `""` (a Ruby SyntaxError when
443
+ # used as a `_BASE_CLASS` prefix) — fall back to the raw name in
444
+ # that case. Two cva bindings whose names collapse to the same
445
+ # prefix (`fooVariant` and `fooVariants` → `FOO`) keep both forms
446
+ # disambiguated by upcasing the unstripped name as the fallback.
447
+ def cva_constant_prefix(cva_name)
448
+ stripped = cva_name.sub(/Variants?\z/, "")
449
+ base = stripped.empty? ? cva_name : stripped
450
+ AST::Inflector.underscore(base).upcase
451
+ end
452
+
453
+ def format_variants_literal(variants)
454
+ return "{}" if variants.empty?
455
+
456
+ lines = ["{"]
457
+ variants.each do |axis, options|
458
+ opts_pairs = options.map { |k, v| "#{k.inspect} => #{v.inspect}" }.join(", ")
459
+ lines << " #{axis.inspect} => { #{opts_pairs} },"
460
+ end
461
+ lines << "}"
462
+ lines.join("\n")
463
+ end
464
+
111
465
  def wrap_in_namespace(body)
112
466
  return "# frozen_string_literal: true\n\n#{body}" unless @namespace
113
467
 
@@ -117,22 +471,98 @@ module JsxRosetta
117
471
 
118
472
  def render_class_body(component, translator)
119
473
  initializer = render_initializer(component, translator)
120
- template = render_view_template(component, translator)
121
- # Render lambdas only AFTER the template — `@lambda_methods` is
122
- # populated as attribute values are rendered.
123
- lambda_methods = render_lambda_method_definitions(translator)
124
-
125
- sections = [initializer, template, lambda_methods].compact.join("\n\n")
126
- "class #{class_name(component)} < #{PHLEX_BASE_CLASS}\n#{sections}\nend\n"
474
+ template = if component.mode == :data_factory
475
+ render_data_factory_method(component, translator)
476
+ else
477
+ render_view_template(component, translator)
478
+ end
479
+ # Render private methods (render_methods + lambdas) AFTER the
480
+ # template — `@lambda_methods` is populated during attribute-value
481
+ # rendering, and render_methods bodies share the same indent.
482
+ private_section = render_private_methods(component, translator)
483
+
484
+ sections = [initializer, template, private_section].compact.join("\n\n")
485
+ cls = class_name(component)
486
+ # One-line docstring above the class — pacifies Style/Documentation
487
+ # without forcing the host project to disable the cop globally. The
488
+ # body is intentionally minimal so it doesn't drift from the source
489
+ # over time; a richer comment belongs in the host repo's review.
490
+ "# #{cls} — generated by jsx_rosetta from JSX. Review before shipping.\n" \
491
+ "class #{cls} < #{parent_class}\n#{sections}\nend\n"
492
+ end
493
+
494
+ # For data-factory components (`export const createColumns = (args)
495
+ # => [{...}, {...}]`) emit a public method that returns the
496
+ # translated data array. The method name is the snake_case of the
497
+ # original JS identifier (`createColumns` → `create_columns`).
498
+ # Param names come from the regular `props:` list so callers can
499
+ # invoke with keyword arguments matching the JS signature.
500
+ def render_data_factory_method(component, translator)
501
+ method_name = AST::Inflector.underscore(component.name)
502
+ param_names = component.props.map(&:name)
503
+ signature = data_factory_signature(method_name, param_names)
504
+ # Param refs translate as locals (`token`) inside the body rather
505
+ # than as ivars (`@token`) — the factory params are method-local,
506
+ # not constructor-stored. `with_locals` pushes the JS names onto
507
+ # the translator's local stack for the duration of the body.
508
+ body = translator.with_locals(param_names) do
509
+ render_inline_value(component.body, translator, todos: [], attr_name: nil, indent: 4)
510
+ end
511
+ " def #{signature}\n #{body}\n end"
127
512
  end
128
513
 
129
- def render_lambda_method_definitions(translator)
130
- return nil if @lambda_methods.nil? || @lambda_methods.empty?
514
+ def data_factory_signature(method_name, param_names)
515
+ return method_name if param_names.empty?
131
516
 
132
- defs = @lambda_methods.map do |entry|
517
+ kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
518
+ "#{method_name}(#{kwargs.join(", ")})"
519
+ end
520
+
521
+ # Coalesce RenderMethod (from local-arrow extraction) and Lambda
522
+ # (from Gap H object-literal extraction) into one `private` section.
523
+ # Emitting `private` twice is harmless but ugly; one block reads
524
+ # cleaner.
525
+ def render_private_methods(component, translator)
526
+ render_methods = component.render_methods.map { |rm| render_render_method_definition(rm, translator) }
527
+ lambda_methods = (@lambda_methods || []).map do |entry|
133
528
  render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
134
529
  end
135
- " private\n\n#{defs.join("\n\n")}"
530
+ event_handlers = (@event_handler_methods || []).map do |entry|
531
+ render_event_handler_method_definition(entry[:method_name], entry[:handler], entry[:attr_name])
532
+ end
533
+ all = render_methods + lambda_methods + event_handlers
534
+ return nil if all.empty?
535
+
536
+ " private\n\n#{all.join("\n\n")}"
537
+ end
538
+
539
+ # Emit one EventHandler as a stub method on the class. The JS body
540
+ # is preserved verbatim as a TODO comment; the method itself is a
541
+ # no-op so the file loads and the receiving component sees a real
542
+ # `Method` object via `method(:name)`. Parameter names snake_case
543
+ # from JS conventions to Ruby identifiers.
544
+ def render_event_handler_method_definition(method_name, handler, attr_name)
545
+ snake_params = handler.params.map { |p| AST::Inflector.underscore(p) }
546
+ signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
547
+ body_lines = comment_lines(handler.body_source).map { |l| " #{l}" }
548
+ [
549
+ " def #{signature}",
550
+ " # TODO: translate the original JSX `#{attr_name}` handler:",
551
+ *body_lines,
552
+ " end"
553
+ ].join("\n")
554
+ end
555
+
556
+ # Emit one RenderMethod as a private method on the class. Params are
557
+ # pushed into the translator scope so identifier references inside
558
+ # the body resolve to method-local arguments.
559
+ def render_render_method_definition(render_method, translator)
560
+ snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
561
+ signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
562
+ body = translator.with_locals(render_method.params) do
563
+ render_ir_node(render_method.body, translator, indent: 4)
564
+ end
565
+ " def #{signature}\n#{body}\n end"
136
566
  end
137
567
 
138
568
  def render_lambda_method_definition(method_name, lambda, translator)
@@ -145,20 +575,30 @@ module JsxRosetta
145
575
  end
146
576
 
147
577
  def render_initializer(component, translator)
578
+ # Data-factory components consume their params as method args, not
579
+ # as constructor props — skip the initializer entirely.
580
+ return nil if component.mode == :data_factory
581
+
148
582
  props = initializable_props(component)
149
583
  rest_name = component.rest_prop_name
150
584
  return nil if props.empty? && rest_name.nil?
151
585
 
586
+ # Snake-case the rest-name kwarg so it matches the snake_case ivar
587
+ # the body uses (`**(@description_props || {})`). Emitting the
588
+ # camelCase JS name straight to the kwarg would create a different
589
+ # ivar than the body reads, silently dropping the splat's contents.
590
+ rest_snake = rest_name && AST::Inflector.underscore(rest_name)
152
591
  kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
153
- kwargs << "**#{rest_name}" if rest_name
592
+ kwargs << "**#{rest_snake}" if rest_snake
154
593
 
155
- assignments = props.map do |prop|
594
+ body = [" super()"]
595
+ body.concat(props.map do |prop|
156
596
  snake = AST::Inflector.underscore(prop.name)
157
597
  " @#{snake} = #{snake}"
158
- end
159
- assignments << " @#{rest_name} = #{rest_name}" if rest_name
598
+ end)
599
+ body << " @#{rest_snake} = #{rest_snake}" if rest_snake
160
600
 
161
- " def initialize(#{kwargs.join(", ")})\n#{assignments.join("\n")}\n end"
601
+ " def initialize(#{kwargs.join(", ")})\n#{body.join("\n")}\n end"
162
602
  end
163
603
 
164
604
  def initializable_props(component)
@@ -172,6 +612,15 @@ module JsxRosetta
172
612
  end
173
613
 
174
614
  def ruby_default_for(prop, translator)
615
+ # Use the cva defaultVariants entry as the initializer default when
616
+ # the prop name matches a cva axis and the JSX didn't already
617
+ # specify its own default. So `variant: 'default'` flows from the
618
+ # cva binding's defaultVariants, even though the React function
619
+ # signature took it as an undefaulted prop.
620
+ if prop.default.nil? && (cva_default = cva_axis_default_for(prop.name))
621
+ return cva_default.inspect
622
+ end
623
+
175
624
  return "nil" if prop.default.nil?
176
625
 
177
626
  case prop.default
@@ -179,43 +628,117 @@ module JsxRosetta
179
628
  translated = translator.translate(prop.default.expression)
180
629
  translated ? translated.ruby : "nil"
181
630
  when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
182
- # Inline values: route through the recursive renderer. Pass an
183
- # empty todos array TODO markers wouldn't be safe inside a
184
- # parameter-list anyway.
185
- render_inline_value(prop.default, translator, todos: [], attr_name: prop.name)
631
+ # Inline values: route through the recursive renderer with
632
+ # `force_inline: true`. A wrapped multi-line default inside the
633
+ # `initialize(...)` parameter list would put the `{` at one
634
+ # column and the children at the indent-aligned column —
635
+ # legal Ruby but trips Layout/FirstHashElementIndentation. Empty
636
+ # todos array — TODO markers wouldn't survive a parameter list.
637
+ render_inline_value(prop.default, translator, todos: [], attr_name: prop.name, force_inline: true)
186
638
  else
187
639
  "nil"
188
640
  end
189
641
  end
190
642
 
643
+ # Look up `prop_name` (e.g. "variant") across every CvaBinding on the
644
+ # current component. Returns the cva default value (e.g. "default") or
645
+ # nil when no cva binding declares that axis with a default.
646
+ def cva_axis_default_for(prop_name)
647
+ return nil unless @current_component
648
+
649
+ @current_component.module_bindings.each do |b|
650
+ next unless b.is_a?(IR::CvaBinding)
651
+ next unless b.default_variants.key?(prop_name)
652
+
653
+ return b.default_variants[prop_name]
654
+ end
655
+ nil
656
+ end
657
+
191
658
  def render_view_template(component, translator)
192
659
  body = render_template_body(component, translator)
193
- prefix = render_template_prefix(component)
660
+ prefix = render_template_prefix(component, translator)
194
661
  body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
195
662
  " def view_template\n#{body_with_prefix}\n end"
196
663
  end
197
664
 
198
- def render_template_prefix(component)
665
+ def render_template_prefix(component, translator)
199
666
  lines = []
200
- lines.concat(render_react_hooks_todo(component.react_hooks))
667
+ lines.concat(render_react_hooks_todo(component.react_hooks, translator))
201
668
  lines.concat(render_local_bindings_todo(component.local_bindings))
202
669
  return "" if lines.empty?
203
670
 
204
671
  "#{lines.map { |l| " #{l}" }.join("\n")}\n"
205
672
  end
206
673
 
207
- def render_react_hooks_todo(hooks)
674
+ def render_react_hooks_todo(hooks, translator)
208
675
  return [] if hooks.empty?
209
676
 
210
- lines = [
211
- "# TODO: React hooks detected. None translate automatically.",
212
- "# Hotwire/Stimulus handles behavior; controllers/views handle state;",
213
- "# turbo-frames handle async loading. Original source:"
214
- ]
215
- hooks.each { |hook| lines.concat(comment_lines(hook.source)) }
677
+ # Preserve the source order of the first occurrence per library so
678
+ # the React block (typical) lands before Apollo/Next.js blocks when
679
+ # all three are present. group_by preserves first-seen order.
680
+ hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls, translator) }
681
+ end
682
+
683
+ def hook_todo_block_lines(library, calls, translator)
684
+ header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
685
+ lines = header_lines.map { |line| "# #{line}" }
686
+ calls.each do |call|
687
+ lines.concat(router_push_hint_lines(call.source, translator))
688
+ lines << "# operation: #{call.operation}" if call.operation
689
+ lines.concat(comment_lines(call.source))
690
+ end
216
691
  lines
217
692
  end
218
693
 
694
+ # Scan a hook's verbatim source for `router.push("…")` invocations and
695
+ # emit a one-line hint per match. With a route table the hint names
696
+ # the matching URL helper (`redirect_to user_path(@id)`); without one,
697
+ # it suggests adding the helper. See ROUTER_PUSH_PATTERN for the
698
+ # accepted shapes and why event-handler bodies are out of scope.
699
+ def router_push_hint_lines(source, translator)
700
+ source.scan(ROUTER_PUSH_PATTERN).flat_map do |(arg)|
701
+ hint = router_push_hint(arg, translator)
702
+ hint ? ["# #{hint}"] : []
703
+ end
704
+ end
705
+
706
+ def router_push_hint(arg, translator)
707
+ if arg.start_with?("`")
708
+ template_router_push_hint(arg, translator)
709
+ else
710
+ path = arg[1..-2]
711
+ literal_router_push_hint(path)
712
+ end
713
+ end
714
+
715
+ def literal_router_push_hint(path)
716
+ helper = @href_rewriter&.rewrite_literal(path)
717
+ return "→ redirect_to #{helper} (translated from router.push(#{path.inspect}))" if helper
718
+
719
+ "→ consider redirect_to <helper> for router.push(#{path.inspect}) (not in route table)"
720
+ end
721
+
722
+ def template_router_push_hint(template_src, translator)
723
+ segments = PagesRouting::HrefRewriter.parse_template_source(template_src)
724
+ return "→ consider redirect_to <helper> for router.push(#{template_src})" unless segments
725
+
726
+ translated = segments.map { |kind, value| kind == :hole ? translate_hole(value, translator) : [kind, value] }
727
+ return "→ consider redirect_to <helper> for router.push(#{template_src})" if translated.any?(&:nil?)
728
+
729
+ helper = @href_rewriter&.rewrite_template(translated)
730
+ return "→ redirect_to #{helper} (translated from router.push(#{template_src}))" if helper
731
+
732
+ "→ consider redirect_to <helper> for router.push(#{template_src}) (path not in route table)"
733
+ end
734
+
735
+ def translate_hole(js_source, translator)
736
+ translated = translator.translate(js_source)
737
+ return nil unless translated && translated.ruby != "nil"
738
+
739
+ [:hole, translated.ruby]
740
+ end
741
+
219
742
  def render_local_bindings_todo(bindings)
220
743
  return [] if bindings.empty?
221
744
 
@@ -249,10 +772,29 @@ module JsxRosetta
249
772
  when IR::Conditional then render_conditional(node, translator, indent: indent)
250
773
  when IR::Loop then render_loop(node, translator, indent: indent)
251
774
  when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
775
+ when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
252
776
  when IR::Slot then render_slot(node, indent: indent)
253
777
  when IR::Text then render_text(node, indent: indent)
254
778
  when IR::Interpolation then render_interpolation(node, translator, indent: indent)
255
779
  when IR::Comment then render_comment(node, indent: indent)
780
+ when IR::LayoutYield then "#{" " * indent}yield"
781
+ end
782
+ end
783
+
784
+ # Emit a call to a previously-extracted RenderMethod. The method body
785
+ # uses `tag.*`/`render` helpers (Phlex executes inside the view), so
786
+ # invoking it inline produces output at the right place in the
787
+ # template. Arg expressions are translated; any that fail translation
788
+ # fall back to verbatim source.
789
+ def render_local_render_call(call, translator, indent:)
790
+ if call.args.empty?
791
+ "#{spaces(indent)}#{call.method_name}"
792
+ else
793
+ arg_sources = call.args.map do |arg|
794
+ translated = translator.translate(arg.expression)
795
+ translated ? translated.ruby : arg.expression
796
+ end
797
+ "#{spaces(indent)}#{call.method_name}(#{arg_sources.join(", ")})"
256
798
  end
257
799
  end
258
800
 
@@ -268,36 +810,81 @@ module JsxRosetta
268
810
 
269
811
  def render_element(element, translator, indent:)
270
812
  todos = []
271
- attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos)
813
+ attrs_source = format_attributes(element.attributes, translator,
814
+ context: :html, tag: element.tag, todos: todos, indent: indent)
272
815
  method_call = "#{element.tag}#{attrs_source}"
816
+ body = element_body(element, method_call, translator, indent)
817
+ prepend_attribute_todos(todos, indent, body)
818
+ end
273
819
 
274
- body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
275
- "#{spaces(indent)}#{method_call}"
276
- else
277
- inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
278
- "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
279
- end
820
+ # Decide whether the HTML tag is blockless, yield-only (auto-yield for
821
+ # self-closing-with-spread), or full do/end (explicit children).
822
+ def element_body(element, method_call, translator, indent)
823
+ return "#{spaces(indent)}#{method_call}" if blockless_element?(element)
280
824
 
281
- prepend_attribute_todos(todos, indent, body)
825
+ if element.children.empty?
826
+ # `<tag {...rest} />` — the rest-spread carries React `children`,
827
+ # but JSX self-closes so there are no explicit IR children. Yield
828
+ # to the Phlex caller's block so `Component.new { ... }` nesting
829
+ # actually renders; guard with `block_given?` so callers who pass
830
+ # no block don't blow up.
831
+ yield_only_block(method_call, indent)
832
+ else
833
+ inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
834
+ "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
835
+ end
836
+ end
837
+
838
+ def blockless_element?(element)
839
+ return true if VOID_ELEMENTS.include?(element.tag)
840
+
841
+ element.children.empty? && !spreads_children?(element)
282
842
  end
283
843
 
284
844
  def render_component_invocation(invocation, translator, indent:)
285
845
  todos = []
286
- kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos)
846
+ kwargs = component_invocation_kwargs(invocation.props, translator,
847
+ todos: todos, indent: indent, tag: invocation.name)
287
848
  class_ref = component_class_reference(invocation.name)
288
849
  new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
850
+ body = component_invocation_body(invocation, new_call, translator, indent)
851
+ prepend_attribute_todos(todos, indent, body)
852
+ end
289
853
 
854
+ def component_invocation_body(invocation, new_call, translator, indent)
290
855
  render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
291
- body = if render_prop
292
- render_with_render_prop(new_call, render_prop, translator, indent)
293
- elsif invocation.children.empty?
294
- "#{spaces(indent)}render #{new_call}"
295
- else
296
- inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
297
- "#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
298
- end
856
+ return render_with_render_prop(new_call, render_prop, translator, indent) if render_prop
299
857
 
300
- prepend_attribute_todos(todos, indent, body)
858
+ call = "render #{new_call}"
859
+ return "#{spaces(indent)}#{call}" if blockless_invocation?(invocation)
860
+
861
+ if invocation.children.empty?
862
+ # `<Component {...rest} />` — same idiom as element_body. The
863
+ # spread carries `children`; yield to the caller's block.
864
+ yield_only_block(call, indent)
865
+ else
866
+ inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
867
+ "#{spaces(indent)}#{call} do\n#{inner}\n#{spaces(indent)}end"
868
+ end
869
+ end
870
+
871
+ def blockless_invocation?(invocation)
872
+ invocation.children.empty? && !spreads_children?(invocation)
873
+ end
874
+
875
+ def yield_only_block(call, indent)
876
+ outer = spaces(indent)
877
+ inner = spaces(indent + 2)
878
+ "#{outer}#{call} do\n#{inner}yield if block_given?\n#{outer}end"
879
+ end
880
+
881
+ # Does this Element/ComponentInvocation carry a JSX rest-spread
882
+ # (`{...props}`) that may transport React `children` we can't see in
883
+ # the IR? Used to decide whether a self-closing JSX tag should still
884
+ # yield to the Phlex caller's block.
885
+ def spreads_children?(node)
886
+ attrs = node.respond_to?(:attributes) ? node.attributes : node.props
887
+ attrs.any?(IR::SpreadAttribute)
301
888
  end
302
889
 
303
890
  # Emit a render-prop child as a Ruby block on the parent `render` call.
@@ -324,9 +911,14 @@ module JsxRosetta
324
911
  # JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
325
912
  # `Foo` again under namespace (Ruby's constant lookup finds the
326
913
  # peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
914
+ # `<HomePage>` keeps the `Page` suffix without doubling (no
915
+ # `HomePageComponent`) — see effective_suffix_for. The path-based
916
+ # page detection doesn't apply here: an invocation only carries
917
+ # the JSX tag name, not the target file's path.
327
918
  def component_class_reference(jsx_tag)
328
919
  segments = jsx_tag.split(".")
329
- segments[-1] = "#{segments.last}#{@suffix}" if @suffix
920
+ suffix = effective_suffix_for(segments.last)
921
+ segments[-1] = "#{segments.last}#{suffix}" if suffix
330
922
  segments.join("::")
331
923
  end
332
924
 
@@ -335,17 +927,107 @@ module JsxRosetta
335
927
  end
336
928
 
337
929
  def render_conditional(conditional, translator, indent:)
338
- test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
930
+ if guard_ladder?(conditional, translator)
931
+ return render_guard_ladder_collapse(conditional, translator, indent: indent)
932
+ end
933
+
339
934
  lines = []
340
- lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
341
- lines << "#{spaces(indent)}if #{test_ruby}"
935
+ emit_conditional_branches(conditional, translator, indent, lines, leading_keyword: "if")
936
+ lines << "#{spaces(indent)}end"
937
+ lines.join("\n")
938
+ end
939
+
940
+ # A guard ladder is a chain of `if/elsif` branches whose tests are all
941
+ # untranslatable AND whose consequents are all "render nothing" (the
942
+ # lowered form of `return null` in a guard), terminating in a real
943
+ # else branch. Emitted naively as `if false / elsif false / .../ else
944
+ # <main>`, the else *always* fires — silently inverting the source
945
+ # semantic ("render nothing when any guard hits") into "render main
946
+ # unconditionally." Collapse to a single TODO block + just the else
947
+ # so the reviewer sees what guards used to gate the render, and the
948
+ # main render is at least visible without the misleading `if false`s.
949
+ def guard_ladder?(conditional, translator)
950
+ branches, else_branch = walk_conditional_chain(conditional)
951
+ return false unless else_branch
952
+ return false if branches.empty?
953
+
954
+ branches.all? do |b|
955
+ test_translates_to_untranslatable?(b[:test], translator) && empty_consequent?(b[:consequent])
956
+ end
957
+ end
958
+
959
+ def render_guard_ladder_collapse(conditional, translator, indent:)
960
+ branches, else_branch = walk_conditional_chain(conditional)
961
+ lines = ["#{spaces(indent)}# TODO: #{branches.length} render guard(s) couldn't translate; wire up Rails-side:"]
962
+ branches.each do |b|
963
+ compact = b[:test].tr("\n", " ").squeeze(" ")
964
+ lines << "#{spaces(indent)}# #{compact}"
965
+ end
966
+ lines << render_ir_node(else_branch, translator, indent: indent)
967
+ lines.join("\n")
968
+ end
969
+
970
+ def walk_conditional_chain(conditional)
971
+ branches = []
972
+ node = conditional
973
+ while node.is_a?(IR::Conditional)
974
+ branches << { test: node.test.expression, consequent: node.consequent }
975
+ node = node.alternate
976
+ end
977
+ [branches, node]
978
+ end
979
+
980
+ def test_translates_to_untranslatable?(expression, translator)
981
+ # Use condition-mode translation so bucket-4 hits (hook-tuple
982
+ # destructures, top-level imports) count as translatable. When a
983
+ # guard ladder's tests *do* translate under widening, the ladder
984
+ # collapses to nothing — render_conditional emits the real
985
+ # `if @ivar / elsif @ivar / else <main>` form, which correctly
986
+ # short-circuits to render nothing when any guard fires. Only
987
+ # untranslatable tests (verbatim JS we can't ivar-promote) still
988
+ # trip the collapse-to-TODO path.
989
+ translated = translator.translate_condition(expression)
990
+ translated.nil? || translated.ruby == "nil"
991
+ end
992
+
993
+ # An empty consequent is what `return null` (the JS guard idiom) lowers
994
+ # to. Detected as either a literal empty Text node or a Fragment whose
995
+ # children are all empty.
996
+ def empty_consequent?(node)
997
+ case node
998
+ when IR::Text then node.value.to_s.empty?
999
+ when IR::Fragment then node.children.all? { |c| empty_consequent?(c) }
1000
+ else false
1001
+ end
1002
+ end
1003
+
1004
+ # Walk a Conditional and its `alternate` chain, emitting `if` for the
1005
+ # first test, `elsif` for each alternate that is itself a Conditional,
1006
+ # and a final `else` for a non-Conditional alternate. Flattens the
1007
+ # `if X / else / if Y / end / end` shape that JS `else if` chains
1008
+ # produce into idiomatic Ruby `if X / elsif Y / else / end`. Without
1009
+ # this, deeply nested conditional chains explode the file's
1010
+ # indentation and trip Style/IfInsideElse + Metrics/BlockNesting.
1011
+ def emit_conditional_branches(conditional, translator, indent, lines, leading_keyword:)
1012
+ test_ruby, todo, promoted = safe_test_expression(conditional.test.expression, translator, fallback: "false")
1013
+ if todo
1014
+ lines << "#{spaces(indent)}# TODO: translate condition: #{todo}"
1015
+ elsif promoted && !promoted.empty?
1016
+ lines << "#{spaces(indent)}# TODO: render condition references binding(s) promoted to @ivar — " \
1017
+ "thread as controller-passed prop(s): #{promoted.join(", ")}"
1018
+ end
1019
+ lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
342
1020
  lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
343
- if conditional.alternate
1021
+
1022
+ alt = conditional.alternate
1023
+ return unless alt
1024
+
1025
+ if alt.is_a?(IR::Conditional)
1026
+ emit_conditional_branches(alt, translator, indent, lines, leading_keyword: "elsif")
1027
+ else
344
1028
  lines << "#{spaces(indent)}else"
345
- lines << render_ir_node(conditional.alternate, translator, indent: indent + 2)
1029
+ lines << render_ir_node(alt, translator, indent: indent + 2)
346
1030
  end
347
- lines << "#{spaces(indent)}end"
348
- lines.join("\n")
349
1031
  end
350
1032
 
351
1033
  def render_loop(loop_node, translator, indent:)
@@ -375,26 +1057,51 @@ module JsxRosetta
375
1057
  when IR::ArrayLiteral
376
1058
  [render_array_literal_value(iterable, translator, todos: []), nil]
377
1059
  when IR::Interpolation
378
- safe_test_expression(iterable.expression, translator, fallback: "[]")
1060
+ safe_iterable_expression(iterable.expression, translator)
379
1061
  else
380
1062
  ["[]", iterable.inspect]
381
1063
  end
382
1064
  end
383
1065
 
1066
+ # Iterable variant of safe_test_expression. Uses narrow (non-condition)
1067
+ # translation: a hook-tuple `items` translates to `nil` here rather than
1068
+ # being promoted to `@items`. Rationale: `nil.each` would crash at
1069
+ # render time; `[].each` renders nothing while the accompanying TODO
1070
+ # documents what didn't translate. Render-condition contexts (A1)
1071
+ # take the opposite trade-off because driving `if nil` silently
1072
+ # disables a load-bearing decision.
1073
+ def safe_iterable_expression(expression, translator)
1074
+ translated = translator.translate(expression)
1075
+ return [translated.ruby, nil] if translated && translated.ruby != "nil"
1076
+
1077
+ compact = expression.tr("\n", " ").squeeze(" ")
1078
+ ["[]", compact]
1079
+ end
1080
+
384
1081
  # Translate an expression intended to drive an `if` or `.each` call.
385
- # Returns `[ruby_source, todo_text]`. When the translator can parse
386
- # the expression, `todo_text` is nil. When it can't, the caller's
387
- # `fallback` (e.g. `"false"` for conditions, `"[]"` for iterables)
388
- # is returned along with the original expression so a TODO comment
389
- # can be emitted above the call. Without this, JS operators like
390
- # `!==`, `===`, optional chaining, and `in` would leak into the
391
- # emitted Ruby and produce SyntaxError on load.
1082
+ # Returns `[ruby_source, todo_text, promoted_locals]`:
1083
+ # - When the translator can parse the expression cleanly, `todo_text`
1084
+ # is nil. `promoted_locals` carries any bucket-4 names the
1085
+ # condition-mode translator promoted to `@ivar` the caller
1086
+ # surfaces these in a TODO so a reviewer knows which bindings need
1087
+ # to become controller-passed props.
1088
+ # - When the translator can't, the caller's `fallback` (`"false"` for
1089
+ # conditions, `"[]"` for iterables) is returned with the verbatim
1090
+ # expression in `todo_text` so a TODO comment can be emitted. Without
1091
+ # this, JS operators like `!==`, `===`, optional chaining, and `in`
1092
+ # would leak into Ruby and produce SyntaxError on load.
1093
+ #
1094
+ # Condition-mode translation (`translate_condition`) is used for both
1095
+ # branches — it widens bucket-4 hits to `@ivar` so `if loading; …`
1096
+ # becomes `if @loading; …` instead of the dead `if false` fallback.
1097
+ # The promoted list separates that path from full-clean translation,
1098
+ # which has nothing to flag.
392
1099
  def safe_test_expression(expression, translator, fallback:)
393
- translated = translator.translate(expression)
394
- return [translated.ruby, nil] if translated
1100
+ translated = translator.translate_condition(expression)
1101
+ return [translated.ruby, nil, translated.promoted_locals] if translated && translated.ruby != "nil"
395
1102
 
396
1103
  compact = expression.tr("\n", " ").squeeze(" ")
397
- [fallback, compact]
1104
+ [fallback, compact, []]
398
1105
  end
399
1106
 
400
1107
  def render_slot(slot, indent:)
@@ -406,7 +1113,7 @@ module JsxRosetta
406
1113
  end
407
1114
 
408
1115
  def render_text(text, indent:)
409
- "#{spaces(indent)}plain #{text.value.inspect}"
1116
+ "#{spaces(indent)}plain #{AST::Inflector.ruby_string_literal(text.value)}"
410
1117
  end
411
1118
 
412
1119
  def render_interpolation(interpolation, translator, indent:)
@@ -443,8 +1150,9 @@ module JsxRosetta
443
1150
  # renders something visible at runtime.
444
1151
  def render_untranslated_interpolation(expression, indent)
445
1152
  compact = expression.tr("\n", " ").squeeze(" ")
1153
+ placeholder = AST::Inflector.ruby_string_literal("[untranslated: #{compact}]")
446
1154
  "#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
447
- "#{spaces(indent)}plain #{"[untranslated: #{compact}]".inspect}"
1155
+ "#{spaces(indent)}plain #{placeholder}"
448
1156
  end
449
1157
 
450
1158
  def render_comment(comment, indent:)
@@ -459,53 +1167,141 @@ module JsxRosetta
459
1167
  # of `h1()`). The `context:` param selects naming convention:
460
1168
  # - :html (HTML element attrs — preserve camelCase for SVG)
461
1169
  # - :component (Ruby method args — snake_case via Inflector.underscore)
462
- def format_attributes(attributes, translator, context: :html, todos: [])
1170
+ def format_attributes(attributes, translator, context: :html, tag: nil, todos: [], indent: 0)
463
1171
  events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
464
1172
  spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
465
1173
 
466
- sym_parts = []
467
- str_parts = []
1174
+ # Per-element form-method check. Set before any attribute renders
1175
+ # so try_rewrite_href can consult it. Scoped per element via a
1176
+ # local backup/restore around the iteration — nested forms are
1177
+ # rare but legal HTML.
1178
+ prev_form_rewritable = @form_action_rewritable
1179
+ @form_action_rewritable = tag == "form" ? form_action_rewritable?(plain_attrs) : false
1180
+
1181
+ parts = { sym: [], str: [] }
468
1182
  plain_attrs.each do |a|
469
- append_attribute_part(a, translator, sym_parts, str_parts, context: context, todos: todos)
1183
+ append_attribute_part(a, translator, parts, context: context, tag: tag, todos: todos, indent: indent)
470
1184
  end
471
- sym_parts << data_action_entry(events, translator) if events.any?
1185
+ parts[:sym] << data_action_entry(events, translator) if events.any?
472
1186
 
473
- joined = build_attribute_list(sym_parts, str_parts, spreads, translator)
1187
+ joined = build_attribute_list(parts, spreads, translator)
474
1188
  joined.empty? ? "" : "(#{joined})"
1189
+ ensure
1190
+ @form_action_rewritable = prev_form_rewritable
475
1191
  end
476
1192
 
477
- def append_attribute_part(attribute, translator, sym_parts, str_parts, context:, todos:)
478
- part = phlex_attribute_part(attribute, translator, context: context, todos: todos)
1193
+ def append_attribute_part(attribute, translator, parts, context:, todos:, indent: 0, tag: nil)
1194
+ part = phlex_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
479
1195
  return unless part
480
1196
 
481
- (part[:string_key] ? str_parts : sym_parts) << part[:source]
1197
+ (part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
482
1198
  end
483
1199
 
484
- def build_attribute_list(sym_parts, str_parts, spreads, translator)
485
- pieces = sym_parts.dup
486
- pieces << "**{ #{str_parts.join(", ")} }" if str_parts.any?
1200
+ def build_attribute_list(parts, spreads, translator)
1201
+ pieces = parts[:sym].dup
1202
+ pieces << "**{ #{parts[:str].join(", ")} }" if parts[:str].any?
487
1203
  pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
488
1204
  pieces.join(", ")
489
1205
  end
490
1206
 
491
1207
  # Emit one attribute as either a {string_key: false, source: "id: @x"}
492
1208
  # (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
493
- # (rare; non-identifier name — goes into a **{ ... } splat).
494
- def phlex_attribute_part(attribute, translator, context:, todos:)
1209
+ # (rare; non-identifier name — goes into a **{ ... } splat). Returns
1210
+ # nil to signal "drop this attribute entirely" used when every style
1211
+ # declaration dropped (would emit `style: ''`) or every plain-attribute
1212
+ # value bailed (would emit `attr: nil`); the TODO comment above the
1213
+ # element already describes what was lost.
1214
+ def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
495
1215
  case attribute
1216
+ when IR::CvaCallSite then cva_call_site_attribute_part(attribute, translator)
496
1217
  when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
497
1218
  when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
498
- when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator)}" }
499
- when IR::Attribute then plain_attribute_part(attribute, translator, context: context, todos: todos)
1219
+ when IR::Style then style_attribute_part(attribute, translator, todos: todos)
1220
+ when IR::Attribute
1221
+ plain_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
500
1222
  end
501
1223
  end
502
1224
 
1225
+ # Skip the `style:` kwarg entirely when every declaration failed to
1226
+ # translate — `style: ''` is invalid HTML output and pure noise; the
1227
+ # per-declaration TODO comments above the element preserve what was
1228
+ # there.
1229
+ def style_attribute_part(style, translator, todos:)
1230
+ ruby = style_to_ruby_string(style, translator, todos: todos)
1231
+ return nil if empty_style_ruby?(ruby)
1232
+
1233
+ { string_key: false, source: "style: #{ruby}" }
1234
+ end
1235
+
1236
+ def empty_style_ruby?(ruby)
1237
+ ["''", '""'].include?(ruby)
1238
+ end
1239
+
1240
+ # A "dropped" attribute is one where translation failed AND the
1241
+ # fallback was the literal `nil` string. We detect this by watching
1242
+ # whether a TODO was appended during the value computation: a real
1243
+ # `attr={null}` in the source produces `nil` *without* a TODO and
1244
+ # should still emit (preserves intent); a failed translation
1245
+ # produces `nil` *with* a TODO and we drop the kwarg to keep output
1246
+ # clean — the TODO above the element already describes the loss.
1247
+ def dropped_value?(value_ruby, todos_before, todos_after)
1248
+ value_ruby == "nil" && todos_after.length > todos_before
1249
+ end
1250
+
503
1251
  def class_attribute_part(expression, translator)
504
1252
  translated = translator.translate(expression)
505
1253
  ruby = translated ? translated.ruby : expression.inspect
506
1254
  { string_key: false, source: "class: #{ruby}" }
507
1255
  end
508
1256
 
1257
+ # Render an IR::CvaCallSite as the `class:` kwarg. Always produces
1258
+ # a single Ruby string-interpolation literal that references the
1259
+ # backend-emitted constants for the cva binding. The detection
1260
+ # happened at lowering time (in `try_lower_cva_call_site`), so the
1261
+ # node already carries the binding name, axes, and optional
1262
+ # class_arg — no regex over verbatim JS source here.
1263
+ def cva_call_site_attribute_part(node, translator)
1264
+ cva = find_cva_binding(node.binding_name)
1265
+ return { string_key: false, source: "class: nil" } unless cva
1266
+
1267
+ parts = cva_call_site_parts(node, cva, translator)
1268
+ { string_key: false, source: %(class: "#{parts.join(" ")}") }
1269
+ end
1270
+
1271
+ def find_cva_binding(binding_name)
1272
+ @current_component&.module_bindings&.find do |b|
1273
+ b.is_a?(IR::CvaBinding) && b.name == binding_name
1274
+ end
1275
+ end
1276
+
1277
+ def cva_call_site_parts(node, cva, translator)
1278
+ prefix = cva_constant_prefix(cva.name)
1279
+ parts = ["\#{#{prefix}_BASE_CLASS}"]
1280
+ node.axes.each do |pair|
1281
+ next unless cva.variants.key?(pair.axis)
1282
+
1283
+ parts << "\#{#{prefix}_VARIANT_CLASSES[#{pair.axis.inspect}][#{cva_axis_ruby_value(pair)}]}"
1284
+ end
1285
+ parts << "\#{#{cva_class_arg_ruby(node.class_arg, translator)}}" if node.class_arg
1286
+ parts
1287
+ end
1288
+
1289
+ def cva_class_arg_ruby(class_arg, translator)
1290
+ translated = translator.translate(class_arg.expression)
1291
+ return translated.ruby if translated
1292
+
1293
+ "@#{AST::Inflector.underscore(class_arg.expression)}"
1294
+ end
1295
+
1296
+ def cva_axis_ruby_value(pair)
1297
+ case pair.kind
1298
+ when :literal_string then pair.source.inspect
1299
+ when :literal_other then pair.source
1300
+ when :literal_nil then "nil"
1301
+ when :prop_ref then "@#{AST::Inflector.underscore(pair.source)}"
1302
+ end
1303
+ end
1304
+
509
1305
  # Map a JSX attribute name to its Ruby kwarg form. For HTML element
510
1306
  # attrs (`context: :html`), only hyphens convert to underscores —
511
1307
  # camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
@@ -515,8 +1311,12 @@ module JsxRosetta
515
1311
  # follow snake_case convention (`defaultValue` → `default_value`).
516
1312
  # Names that aren't valid Ruby identifiers after conversion (rare:
517
1313
  # `xml:lang` and friends) fall back to a quoted string key.
518
- def plain_attribute_part(attribute, translator, context:, todos:)
519
- value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos)
1314
+ def plain_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
1315
+ todos_before = todos.length
1316
+ value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator,
1317
+ todos: todos, indent: indent, tag: tag)
1318
+ return nil if dropped_value?(value_ruby, todos_before, todos)
1319
+
520
1320
  ruby_name = case context
521
1321
  when :component then AST::Inflector.underscore(attribute.name)
522
1322
  else attribute.name.tr("-", "_")
@@ -524,59 +1324,182 @@ module JsxRosetta
524
1324
  if ruby_name.match?(VALID_IDENTIFIER)
525
1325
  { string_key: false, source: "#{ruby_name}: #{value_ruby}" }
526
1326
  else
527
- { string_key: true, source: "#{attribute.name.inspect} => #{value_ruby}" }
1327
+ { string_key: true, source: "#{AST::Inflector.ruby_string_literal(attribute.name)} => #{value_ruby}" }
528
1328
  end
529
1329
  end
530
1330
 
531
- def attribute_value_to_ruby(name, value, translator, todos:)
1331
+ def attribute_value_to_ruby(name, value, translator, todos:, indent: 0, tag: nil)
1332
+ if (rewrite = try_rewrite_href(name, value, translator, tag: tag))
1333
+ return rewrite
1334
+ end
1335
+
532
1336
  case value
533
1337
  when true then "true"
534
- when String then value.inspect
1338
+ when String then AST::Inflector.ruby_string_literal(value)
535
1339
  when IR::Interpolation then interpolated_attribute_value(name, value, translator, todos: todos)
536
- when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
537
- when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
1340
+ when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos, indent: indent)
1341
+ when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos, indent: indent)
538
1342
  when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
1343
+ when IR::EventHandler then render_event_handler_method_reference(value, attr_name: name)
1344
+ when IR::ComponentInvocation
1345
+ component_invocation_value(value, translator, todos: todos, attr_name: name)
1346
+ when IR::Element, IR::Fragment
1347
+ # An HTML tag (`title={<span>x</span>}`) or a multi-element
1348
+ # Fragment as an attribute value needs a Phlex execution context
1349
+ # the receiver might not provide. Drop with a TODO rather than
1350
+ # emit a broken kwarg or a speculative method reference.
1351
+ drop_jsx_value_with_todo(name, value, todos: todos)
1352
+ end
1353
+ end
1354
+
1355
+ # Slice 3: rewrite `href`/`to` on link-shaped tags to a Rails URL
1356
+ # helper call when the literal/template-literal path matches a
1357
+ # route in the scanned table. Returns nil to fall through to the
1358
+ # default emission path. Slice C1 added `<form action>` — see
1359
+ # form_action_rewritable? for the method-attribute check.
1360
+ def try_rewrite_href(name, value, translator, tag:)
1361
+ return nil unless @href_rewriter
1362
+ return nil unless tag
1363
+
1364
+ allowed_attrs = LINK_TAG_ATTRS[tag]
1365
+ return nil unless allowed_attrs&.include?(name)
1366
+ return nil if tag == "form" && !@form_action_rewritable
1367
+
1368
+ case value
1369
+ when String
1370
+ @href_rewriter.rewrite_literal(value)
1371
+ when IR::Interpolation
1372
+ rewrite_interpolation_href(value.expression, translator)
1373
+ end
1374
+ end
1375
+
1376
+ # `<form action="…">` only rewrites for safe (GET) submissions —
1377
+ # slice-1 routes are GET-only, so POST/PUT/DELETE forms must stay
1378
+ # verbatim until the route table supports non-GET routes. Returns
1379
+ # true when no method attr is set (HTML default = GET) or when the
1380
+ # method is a literal GET; false otherwise (including interpolated
1381
+ # / TODO methods where we can't statically prove GET).
1382
+ def form_action_rewritable?(attributes)
1383
+ method_attr = attributes.find { |a| a.is_a?(IR::Attribute) && a.name == "method" }
1384
+ return true unless method_attr
1385
+
1386
+ case method_attr.value
1387
+ when nil, true then true
1388
+ when String then method_attr.value.casecmp("get").zero?
1389
+ else false
539
1390
  end
540
1391
  end
541
1392
 
1393
+ def rewrite_interpolation_href(js_source, translator)
1394
+ if (literal = string_literal_path(js_source))
1395
+ return @href_rewriter.rewrite_literal(literal)
1396
+ end
1397
+
1398
+ segments = PagesRouting::HrefRewriter.parse_template_source(js_source)
1399
+ return nil unless segments
1400
+
1401
+ translated = segments.map do |kind, val|
1402
+ next [:literal, val] if kind == :literal
1403
+
1404
+ result = translator.translate(val)
1405
+ return nil unless result && result.ruby != "nil"
1406
+
1407
+ [:hole, result.ruby]
1408
+ end
1409
+ @href_rewriter.rewrite_template(translated)
1410
+ end
1411
+
1412
+ def string_literal_path(js_source)
1413
+ return nil unless js_source.is_a?(String)
1414
+ return nil unless js_source.length >= 2
1415
+
1416
+ first = js_source[0]
1417
+ return nil unless ['"', "'"].include?(first)
1418
+ return nil unless js_source[-1] == first
1419
+
1420
+ body = js_source[1..-2]
1421
+ return nil if body.include?(first) || body.include?("\\")
1422
+
1423
+ body
1424
+ end
1425
+
542
1426
  # Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
543
1427
  # entries become Ruby kwargs (snake_cased to match Ruby convention);
544
1428
  # non-identifier keys (numeric, hyphenated) fall back to string keys.
545
- def render_object_literal_value(object_literal, translator, todos:)
1429
+ # When the single-line rendering exceeds LITERAL_INLINE_BUDGET, or
1430
+ # when any rendered child value spans multiple lines, the layout
1431
+ # switches to one entry per line, indented two spaces past `indent`.
1432
+ def render_object_literal_value(object_literal, translator, todos:, indent: 0, force_inline: false)
1433
+ child_indent = indent + 2
546
1434
  parts = object_literal.properties.map do |(key, value)|
547
- render_object_property(key, value, translator, todos: todos)
1435
+ render_object_property(key, value, translator, todos: todos, indent: child_indent, force_inline: force_inline)
548
1436
  end
549
- "{ #{parts.join(", ")} }"
1437
+ wrap_literal_parts(parts, open: "{", close: "}", indent: indent, inline_sep: ", ", inline_pad: " ",
1438
+ force_inline: force_inline)
550
1439
  end
551
1440
 
552
- def render_object_property(key, value, translator, todos:)
553
- value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key)
1441
+ def render_object_property(key, value, translator, todos:, indent: 0, force_inline: false)
1442
+ value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key, indent: indent,
1443
+ force_inline: force_inline)
554
1444
  snake = AST::Inflector.underscore(key)
555
1445
  if snake.match?(VALID_IDENTIFIER)
556
1446
  "#{snake}: #{value_ruby}"
557
1447
  else
558
- "#{key.inspect} => #{value_ruby}"
1448
+ "#{AST::Inflector.ruby_string_literal(key)} => #{value_ruby}"
559
1449
  end
560
1450
  end
561
1451
 
562
- def render_array_literal_value(array_literal, translator, todos:)
1452
+ def render_array_literal_value(array_literal, translator, todos:, indent: 0, force_inline: false)
1453
+ child_indent = indent + 2
563
1454
  parts = array_literal.elements.map do |el|
564
- el.nil? ? "nil" : render_inline_value(el, translator, todos: todos, attr_name: nil)
1455
+ if el.nil?
1456
+ "nil"
1457
+ else
1458
+ render_inline_value(el, translator, todos: todos, attr_name: nil, indent: child_indent,
1459
+ force_inline: force_inline)
1460
+ end
565
1461
  end
566
- "[#{parts.join(", ")}]"
1462
+ wrap_literal_parts(parts, open: "[", close: "]", indent: indent, inline_sep: ", ", inline_pad: "",
1463
+ force_inline: force_inline)
1464
+ end
1465
+
1466
+ # Pick single-line vs multi-line layout for a rendered literal.
1467
+ # Multi-line is forced when any rendered part already contains a
1468
+ # newline (a nested literal that wrapped); otherwise we wrap only
1469
+ # when the single-line form exceeds LITERAL_INLINE_BUDGET.
1470
+ def wrap_literal_parts(parts, **opts)
1471
+ open = opts[:open]
1472
+ close = opts[:close]
1473
+ return "#{open}#{close}" if parts.empty?
1474
+
1475
+ inline = "#{open}#{opts[:inline_pad]}#{parts.join(opts[:inline_sep])}#{opts[:inline_pad]}#{close}"
1476
+ any_multiline = parts.any? { |p| p.include?("\n") }
1477
+ return inline if opts[:force_inline]
1478
+ return inline if !any_multiline && inline.length <= LITERAL_INLINE_BUDGET
1479
+
1480
+ child_pad = " " * (opts[:indent] + 2)
1481
+ close_pad = " " * opts[:indent]
1482
+ "#{open}\n#{child_pad}#{parts.join(",\n#{child_pad}")}\n#{close_pad}#{close}"
567
1483
  end
568
1484
 
569
1485
  # An inline value can appear as a kwarg value, an array element, or a
570
1486
  # hash property value. Recursive shapes route back through the new IR
571
1487
  # types; primitives fall through the same paths as attribute_value_to_ruby.
572
- def render_inline_value(value, translator, todos:, attr_name:)
1488
+ def render_inline_value(value, translator, todos:, attr_name:, indent: 0, force_inline: false)
573
1489
  case value
574
- when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
575
- when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
1490
+ when IR::ObjectLiteral
1491
+ render_object_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
1492
+ when IR::ArrayLiteral
1493
+ render_array_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
576
1494
  when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
1495
+ when IR::EventHandler then render_event_handler_method_reference(value, attr_name: attr_name)
577
1496
  when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
578
1497
  todos: todos)
579
- when String then value.inspect
1498
+ when IR::ComponentInvocation
1499
+ component_invocation_value(value, translator, todos: todos, attr_name: attr_name)
1500
+ when IR::Element, IR::Fragment
1501
+ drop_jsx_value_with_todo(attr_name, value, todos: todos)
1502
+ when String then AST::Inflector.ruby_string_literal(value)
580
1503
  when true then "true"
581
1504
  else
582
1505
  "nil"
@@ -612,43 +1535,145 @@ module JsxRosetta
612
1535
  @lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
613
1536
  end
614
1537
 
615
- # Attribute-position interpolation. Two failure modes:
616
- # 1. Translator returns non-nil but with unresolved identifiers
617
- # the Ruby reference is fine to emit (it'll surface as a
618
- # NameError at render time if it's wrong). Inline TODO comments
619
- # aren't safe in attribute position (would break hash splat or
620
- # method-call syntax), so the marker is suppressed.
621
- # 2. Translator returns nil — the original JS expression couldn't
1538
+ # Inline arrow event handler on a PascalCase tag — `onClick={() =>
1539
+ # doX()}` on `<Button>`. Extract to a stub method on the class and
1540
+ # reference via `method(:handle_click)` at the kwarg position so the
1541
+ # receiving component has a callable. The body translation is left
1542
+ # to the human reviewer (the JS source is preserved as a TODO
1543
+ # comment inside the method), but the structural wiring is intact.
1544
+ def render_event_handler_method_reference(handler, attr_name:)
1545
+ @event_handler_methods ||= []
1546
+ base = event_handler_method_base(attr_name)
1547
+ method_name = unique_lambda_method_name(base)
1548
+ @event_handler_methods << { handler: handler, method_name: method_name, attr_name: attr_name }
1549
+ "method(:#{method_name})"
1550
+ end
1551
+
1552
+ # Map a JSX attribute name to an idiomatic Ruby handler-method name.
1553
+ # `onClick` → `handle_click` (mirrors React's `handleClick` convention,
1554
+ # snake_cased). Non-event attrs (rare — a callback prop with no `on`
1555
+ # prefix) fall back to `<attr>_handler`.
1556
+ def event_handler_method_base(attr_name)
1557
+ return "anonymous_handler" if attr_name.nil? || attr_name.empty?
1558
+
1559
+ snake = AST::Inflector.underscore(attr_name)
1560
+ snake.start_with?("on_") ? "handle_#{snake.delete_prefix("on_")}" : "#{snake}_handler"
1561
+ end
1562
+
1563
+ # Attribute-position interpolation. Three failure modes:
1564
+ # 1. Translator returns non-nil, no unresolved identifiers — emit
1565
+ # the Ruby reference directly. Common case.
1566
+ # 2. Translator returns non-nil but with unresolved identifiers
1567
+ # starting with uppercase (PascalCase / SCREAMING_SNAKE_CASE —
1568
+ # almost always imported constants or enums) — emitting bare
1569
+ # `default_page_size` from `DEFAULT_PAGE_SIZE` produces a
1570
+ # runtime NameError with no marker. Drop the value to `nil`
1571
+ # and surface a TODO with the verbatim source. Lowercase
1572
+ # unresolved identifiers may be Rails helpers (`current_user`)
1573
+ # and are passed through as before.
1574
+ # 3. Translator returns nil — the original JS expression couldn't
622
1575
  # be parsed at all (e.g. `<LeftOutlined .../>`, array literals,
623
- # template literals with method calls). We emit `nil` for the
624
- # kwarg AND record the original expression in `todos` so the
625
- # caller can prepend a `# TODO:` comment line above the element.
1576
+ # template literals with method calls). Same TODO + nil path.
626
1577
  def interpolated_attribute_value(name, value, translator, todos:)
627
1578
  translated = translator.translate(value.expression)
628
- return translated.ruby if translated
1579
+ return translated.ruby if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
629
1580
 
630
1581
  compact = value.expression.tr("\n", " ").squeeze(" ")
631
1582
  todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
632
1583
  "nil"
633
1584
  end
634
1585
 
635
- def component_invocation_kwargs(props, translator, todos: [])
1586
+ def uppercase_unresolved?(unresolved_identifiers)
1587
+ unresolved_identifiers.any? { |name| name[0] == name[0].upcase }
1588
+ end
1589
+
1590
+ # JSX appearing as an attribute value — typically `icon={<Foo/>}` or
1591
+ # `fallback={<Loading/>}` on antd/MUI components. Emitted as a
1592
+ # component-instance value: `icon: FooComponent.new`. The receiving
1593
+ # Phlex component can render it directly via `render @icon`. Closes
1594
+ # the largest single category of attribute-value drops we were
1595
+ # silently emitting as `attr: nil` + TODO.
1596
+ #
1597
+ # Three child-handling tiers:
1598
+ # 1. No children — `ClassRef.new(kwargs)` on one line.
1599
+ # 2. Children whose rendered Phlex body fits a single line — emit
1600
+ # as a block: `ClassRef.new(kwargs) { plain "x" }`. The block
1601
+ # runs in the child component's render context, so HTML helpers
1602
+ # resolve correctly.
1603
+ # 3. Children that need multiple lines, or any IR::Element /
1604
+ # IR::Fragment we can't represent inline — fall back to the
1605
+ # existing TODO drop. Out of MVP scope; expand later if the
1606
+ # stress numbers warrant it.
1607
+ def component_invocation_value(invocation, translator, todos:, attr_name:)
1608
+ return drop_attribute_with_todo(attr_name, invocation, todos: todos) if invocation_has_render_prop?(invocation)
1609
+
1610
+ kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: 0)
1611
+ class_ref = component_class_reference(invocation.name)
1612
+ new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
1613
+
1614
+ return new_call if invocation.children.empty?
1615
+
1616
+ block_body = render_inline_children(invocation.children, translator)
1617
+ return drop_attribute_with_todo(attr_name, invocation, todos: todos) unless block_body
1618
+
1619
+ "#{new_call} { #{block_body} }"
1620
+ end
1621
+
1622
+ def invocation_has_render_prop?(invocation)
1623
+ invocation.children.any?(IR::RenderProp)
1624
+ end
1625
+
1626
+ # Render children to a single-line Phlex block body. Returns nil when
1627
+ # any child needs multiple lines or isn't representable inline — the
1628
+ # caller falls back to the TODO drop.
1629
+ def render_inline_children(children, translator)
1630
+ rendered = children.map { |c| render_ir_node(c, translator, indent: 0) }
1631
+ return nil if rendered.any? { |line| line.include?("\n") }
1632
+
1633
+ joined = rendered.join("; ")
1634
+ joined.length <= LITERAL_INLINE_BUDGET ? joined : nil
1635
+ end
1636
+
1637
+ def drop_attribute_with_todo(attr_name, invocation, todos:)
1638
+ label = attr_name || "<element>"
1639
+ compact = invocation_source_summary(invocation)
1640
+ todos << "attribute #{label.inspect} dropped — couldn't inline JSX value: #{compact}"
1641
+ "nil"
1642
+ end
1643
+
1644
+ def invocation_source_summary(invocation)
1645
+ tag = invocation.name
1646
+ suffix = invocation.children.empty? ? "/" : "...>"
1647
+ "<#{tag}#{suffix}"
1648
+ end
1649
+
1650
+ def drop_jsx_value_with_todo(attr_name, value, todos:)
1651
+ label = attr_name || "<element>"
1652
+ summary = case value
1653
+ when IR::Element then "<#{value.tag}...>"
1654
+ when IR::Fragment then "<>...</>"
1655
+ else "<JSX>"
1656
+ end
1657
+ todos << "attribute #{label.inspect} dropped — couldn't inline JSX value: #{summary}"
1658
+ "nil"
1659
+ end
1660
+
1661
+ def component_invocation_kwargs(props, translator, todos: [], indent: 0, tag: nil)
636
1662
  events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
637
1663
  spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
638
1664
 
639
- sym_parts = []
640
- str_parts = []
1665
+ parts = { sym: [], str: [] }
641
1666
  plain_attrs.each do |a|
642
- append_attribute_part(a, translator, sym_parts, str_parts, context: :component, todos: todos)
1667
+ append_attribute_part(a, translator, parts, context: :component, tag: tag, todos: todos, indent: indent)
643
1668
  end
644
- sym_parts << data_action_entry(events, translator) if events.any?
1669
+ parts[:sym] << data_action_entry(events, translator) if events.any?
645
1670
 
646
- build_attribute_list(sym_parts, str_parts, spreads, translator)
1671
+ build_attribute_list(parts, spreads, translator)
647
1672
  end
648
1673
 
649
1674
  def class_list_to_ruby_string(class_list, translator)
650
1675
  parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
651
- %("#{parts.join(" ")}")
1676
+ wrap_concatenated_string(parts.join(" "))
652
1677
  end
653
1678
 
654
1679
  def class_segment_to_ruby(segment, translator)
@@ -660,23 +1685,46 @@ module JsxRosetta
660
1685
  when IR::ConditionalSegment
661
1686
  cond_translated = translator.translate(segment.condition.expression)
662
1687
  cond_ruby = cond_translated&.ruby || segment.condition.expression
663
- %(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
1688
+ %(\#{#{cond_ruby} ? #{AST::Inflector.ruby_string_literal(segment.class_name)} : ''})
664
1689
  end
665
1690
  end
666
1691
 
667
- def style_to_ruby_string(style, translator)
668
- parts = style.declarations.map { |decl| style_declaration_to_ruby(decl, translator) }
669
- %("#{parts.join(" ")}")
1692
+ def style_to_ruby_string(style, translator, todos: [])
1693
+ parts = style.declarations.filter_map { |decl| style_declaration_to_ruby(decl, translator, todos: todos) }
1694
+ wrap_concatenated_string(parts.join(" "))
670
1695
  end
671
1696
 
672
- def style_declaration_to_ruby(decl, translator)
673
- value = case decl.value
674
- when String then decl.value
675
- when IR::Interpolation
676
- translated = translator.translate(decl.value.expression)
677
- "\#{#{translated&.ruby || decl.value.expression}}"
678
- end
679
- "#{decl.property}: #{value};"
1697
+ # Wrap a built-up Ruby string body in single quotes when it contains
1698
+ # no interpolation (`\#{...}`) and no escaping pitfalls; otherwise
1699
+ # use double quotes so the interpolation is honored. Keeps class /
1700
+ # style attribute output passing Style/StringLiterals when no
1701
+ # dynamic segments are present (the common case for hardcoded
1702
+ # `style="margin-bottom: 16px"`).
1703
+ def wrap_concatenated_string(body)
1704
+ return %("#{body}") if body.include?("\#{") || body.include?("'") || body.include?("\\")
1705
+
1706
+ "'#{body}'"
1707
+ end
1708
+
1709
+ def style_declaration_to_ruby(decl, translator, todos: [])
1710
+ case decl.value
1711
+ when String
1712
+ "#{decl.property}: #{decl.value};"
1713
+ when IR::Interpolation
1714
+ translated = translator.translate(decl.value.expression)
1715
+ # Translation failed (e.g., interpolation rooted at an
1716
+ # unresolvable local). Emitting the verbatim JS source inside
1717
+ # `\#{}` would render valid Ruby that NameErrors at runtime.
1718
+ # Drop the declaration and surface a TODO above the element so
1719
+ # the reviewer sees what was lost.
1720
+ if translated.nil?
1721
+ todos << "style declaration #{decl.property.inspect} dropped — " \
1722
+ "couldn't translate: #{decl.value.expression}"
1723
+ nil
1724
+ else
1725
+ "#{decl.property}: \#{#{translated.ruby}};"
1726
+ end
1727
+ end
680
1728
  end
681
1729
 
682
1730
  # Wrap the spread expression in `(… || {})` so a nil-valued prop
@@ -712,7 +1760,7 @@ module JsxRosetta
712
1760
  end
713
1761
 
714
1762
  def render_single_event_descriptor(descriptor)
715
- descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
1763
+ descriptor.kind == :literal ? AST::Inflector.ruby_string_literal(descriptor.body) : descriptor.body
716
1764
  end
717
1765
 
718
1766
  def descriptor_in_string(descriptor)
@@ -733,15 +1781,77 @@ module JsxRosetta
733
1781
  "#{lines.join("\n")}\n"
734
1782
  end
735
1783
 
1784
+ # Stimulus method emission. JSX inline arrow bodies are valid JS already;
1785
+ # for DOM-driven handlers (the common shadcn shape) we just paste the body
1786
+ # verbatim into the method, naming the JS parameter to match the original
1787
+ # arrow's parameter so identifier references in the body still resolve.
1788
+ #
1789
+ # When the body references React-state setters or hooks we can't run in
1790
+ # the browser, fall back to the previous TODO-comment behavior so the
1791
+ # human reviewer ports it by hand.
736
1792
  def stimulus_method_lines(method)
737
- body_lines = method.body_source.strip.split("\n")
738
- commented = body_lines.map { |line| " // #{line}" }
739
- header = [" // TODO: translate from the original JSX handler:"]
1793
+ lines = []
740
1794
  if method.name != method.original_name
741
- header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
742
- "to avoid collision with an earlier handler")
1795
+ lines << " // NOTE: method renamed from #{method.original_name.inspect} " \
1796
+ "to avoid collision with an earlier handler"
743
1797
  end
744
- header + commented + [
1798
+
1799
+ if safe_to_paste_handler?(method)
1800
+ lines.concat(pasted_handler_lines(method))
1801
+ else
1802
+ lines.concat(todo_handler_lines(method))
1803
+ end
1804
+
1805
+ lines
1806
+ end
1807
+
1808
+ # Heuristic for "this JS body is safe to drop into a Stimulus method
1809
+ # verbatim." Bails out when:
1810
+ # - any arrow param wasn't a plain Identifier (destructured / rest) —
1811
+ # pasting would reference an undefined local at runtime;
1812
+ # - the body is the identifier-bound pseudo-comment we synthesize
1813
+ # when an `onClick={onChange}` reference resolved to no arrow;
1814
+ # - the body calls a top-level React state setter (`setX(`) or hook
1815
+ # (`useX(`) — the negative lookbehind on `[.\w]` makes sure DOM
1816
+ # methods like `e.setAttribute(` / `el.setPointerCapture(` don't
1817
+ # trip the guard.
1818
+ def safe_to_paste_handler?(method)
1819
+ return false unless method.params.all?
1820
+
1821
+ body = method.body_source
1822
+ return false if body.lstrip.start_with?("//")
1823
+ return false if body =~ /(?<![.\w])set[A-Z]\w*\(/
1824
+ return false if body =~ /(?<![.\w])use[A-Z]\w*\(/
1825
+
1826
+ true
1827
+ end
1828
+
1829
+ # Paste the JS body into the method, using the original arrow's first
1830
+ # parameter name (so the body's references still resolve). Strip the
1831
+ # outer `{ … }` wrapper only when the body was an arrow BlockStatement
1832
+ # (`(e) => { … }`), not an expression-form body like `(e) => ({ x: 1 })`
1833
+ # which Babel hands back as `{ x: 1 }` already — stripping would yield
1834
+ # `x: 1`, a JS label statement (no-op).
1835
+ def pasted_handler_lines(method)
1836
+ param = method.params.first || "event"
1837
+ body = method.body_source.strip
1838
+ body = body[1..-2].strip if method.body_is_block
1839
+ inner_lines = body.split("\n").map { |l| " #{l.lstrip}" }
1840
+ [
1841
+ " #{method.name}(#{param}) {",
1842
+ *inner_lines,
1843
+ " }"
1844
+ ]
1845
+ end
1846
+
1847
+ # Fallback for handlers that aren't safe to paste verbatim — preserve
1848
+ # the original body as a comment and emit an empty method body.
1849
+ def todo_handler_lines(method)
1850
+ body_lines = method.body_source.strip.split("\n")
1851
+ commented = body_lines.map { |line| " // #{line}" }
1852
+ [
1853
+ " // TODO: translate from the original JSX handler:",
1854
+ *commented,
745
1855
  " #{method.name}(event) {",
746
1856
  " // ...",
747
1857
  " }"
@@ -751,6 +1861,159 @@ module JsxRosetta
751
1861
  def spaces(count)
752
1862
  " " * count
753
1863
  end
1864
+
1865
+ # ----------------------------------------------------------------------
1866
+ # Lucide icon sidecars
1867
+ # ----------------------------------------------------------------------
1868
+ #
1869
+ # When a translated component references `<ChevronRight />` after
1870
+ # `import { ChevronRight } from "lucide-react"`, the consumer ends up
1871
+ # with a `render ChevronRight.new(...)` call against a Ruby class that
1872
+ # doesn't exist. To close that NameError, we emit one Phlex class per
1873
+ # referenced icon as a sidecar file (`chevron_right.rb` etc.) plus a
1874
+ # shared `lucide_icon.rb` base. Each file follows Zeitwerk's
1875
+ # one-constant-per-file convention so it drops straight into
1876
+ # `app/components/` without further configuration.
1877
+
1878
+ def lucide_icon_files(component)
1879
+ usages = referenced_lucide_icons(component)
1880
+ return [] if usages.empty?
1881
+
1882
+ @seen_lucide_icons ||= Set.new
1883
+ files = []
1884
+ unless @seen_lucide_icons.include?(:base)
1885
+ files << File.new(path: "lucide_icon.rb", contents: render_lucide_icon_base_rb)
1886
+ @seen_lucide_icons << :base
1887
+ end
1888
+
1889
+ usages.sort_by { |u| u[:local_name] }.each do |usage|
1890
+ next if @seen_lucide_icons.include?(usage[:local_name])
1891
+
1892
+ path = "#{AST::Inflector.underscore(usage[:local_name])}.rb"
1893
+ files << File.new(
1894
+ path: path,
1895
+ contents: render_lucide_icon_class_rb(usage[:local_name], canonical: usage[:canonical_name])
1896
+ )
1897
+ @seen_lucide_icons << usage[:local_name]
1898
+ end
1899
+ files
1900
+ end
1901
+
1902
+ # Lucide imports used as JSX tags, each carrying both the local
1903
+ # binding (what the emitted file/class is named after) and the
1904
+ # canonical export (used to look up the vendored SVG path data).
1905
+ # The two diverge under aliased imports — `import { ChevronRight as
1906
+ # CR }` should still resolve `ChevronRight`'s SVG while emitting a
1907
+ # `cr.rb` defining `class CR < LucideIcon`. Names not in the
1908
+ # vendored data still emit, but the class falls back to a TODO
1909
+ # `inner_svg` instead of NameError-ing at render.
1910
+ def referenced_lucide_icons(component)
1911
+ lucide_by_local = component.module_imports
1912
+ .select { |i| Icons.lucide_source?(i.source) }
1913
+ .to_h { |i| [i.name, i.imported_name || i.name] }
1914
+ return [] if lucide_by_local.empty?
1915
+
1916
+ invocations = Set.new
1917
+ collect_component_invocations(component.body, invocations)
1918
+ invocations.intersection(lucide_by_local.keys).map do |local_name|
1919
+ { local_name: local_name, canonical_name: lucide_by_local.fetch(local_name) }
1920
+ end
1921
+ end
1922
+
1923
+ # Walk an IR subtree and collect every ComponentInvocation tag name we
1924
+ # see. Recurses through any field that holds an IR node or array of
1925
+ # nodes. Conservative — visits every container; cost is proportional
1926
+ # to IR size.
1927
+ def collect_component_invocations(node, acc)
1928
+ return if node.nil?
1929
+
1930
+ acc << node.name if node.is_a?(IR::ComponentInvocation)
1931
+ return unless node.respond_to?(:members)
1932
+
1933
+ node.members.each do |field|
1934
+ value = node.public_send(field)
1935
+ if value.is_a?(Array)
1936
+ value.each { |child| collect_component_invocations(child, acc) }
1937
+ elsif value.respond_to?(:members)
1938
+ collect_component_invocations(value, acc)
1939
+ end
1940
+ end
1941
+ end
1942
+
1943
+ def render_lucide_icon_base_rb
1944
+ mod_open, mod_close, indent = lucide_module_wrap
1945
+ <<~RUBY
1946
+ # frozen_string_literal: true
1947
+
1948
+ # Generated by jsx_rosetta. Don't edit by hand — re-translate the source
1949
+ # to refresh. Shared SVG-wrapper base for Lucide icon shims; one subclass
1950
+ # per icon (e.g. ChevronRight, Search) sits alongside this file.
1951
+ #{mod_open}#{indent}class LucideIcon < Phlex::HTML
1952
+ #{indent} BASE_ATTRS = %{xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"}.freeze
1953
+
1954
+ #{indent} def initialize(class_name: nil, **)
1955
+ #{indent} super()
1956
+ #{indent} @class_name = class_name
1957
+ #{indent} end
1958
+
1959
+ #{indent} def view_template
1960
+ #{indent} cls = @class_name.to_s.empty? ? "" : %{ class="\#{@class_name}"}
1961
+ #{indent} raw safe("<svg \#{BASE_ATTRS}\#{cls}>\#{inner_svg}</svg>")
1962
+ #{indent} end
1963
+
1964
+ #{indent} def inner_svg
1965
+ #{indent} raise NotImplementedError
1966
+ #{indent} end
1967
+ #{indent}end
1968
+ #{mod_close}
1969
+ RUBY
1970
+ end
1971
+
1972
+ # `name` is the local binding (the Ruby class name we emit), `canonical`
1973
+ # is the original Lucide export — they diverge under aliased imports.
1974
+ # SVG lookup keys on `canonical`; the class definition keys on `name`.
1975
+ def render_lucide_icon_class_rb(name, canonical: name)
1976
+ inner = Icons.lucide_for(canonical)
1977
+ mod_open, mod_close, indent = lucide_module_wrap
1978
+ body = if inner
1979
+ "#{indent} def inner_svg = #{format_svg_string(inner)}"
1980
+ else
1981
+ "#{indent} # TODO: #{canonical.inspect} isn't in jsx_rosetta's vendored lucide.json.\n" \
1982
+ "#{indent} # Fill in inner_svg with the SVG path data from lucide.dev, or refresh\n" \
1983
+ "#{indent} # `lib/jsx_rosetta/icons/lucide.json`.\n" \
1984
+ "#{indent} def inner_svg = \"\""
1985
+ end
1986
+ <<~RUBY
1987
+ # frozen_string_literal: true
1988
+
1989
+ # Generated by jsx_rosetta from a "lucide-react" import. Refresh with
1990
+ # a re-translate; don't edit by hand.
1991
+ #{mod_open}#{indent}class #{name} < LucideIcon
1992
+ #{body}
1993
+ #{indent}end
1994
+ #{mod_close}
1995
+ RUBY
1996
+ end
1997
+
1998
+ # Match the namespace wrapping we use for the main component class.
1999
+ # Returns ["module Foo\n", "end\n", " "] when a namespace is set,
2000
+ # or ["", "", ""] for the top-level case.
2001
+ def lucide_module_wrap
2002
+ return ["", "", ""] unless @namespace
2003
+
2004
+ ["module #{@namespace}\n", "end\n", " "]
2005
+ end
2006
+
2007
+ # Wrap an SVG inner-markup snippet in a Ruby string literal that
2008
+ # preserves its embedded double quotes. Single-quoted when the markup
2009
+ # contains no single quotes (most cases), otherwise %q delimited.
2010
+ def format_svg_string(svg)
2011
+ if svg.include?("'")
2012
+ %(%q{#{svg}})
2013
+ else
2014
+ "'#{svg}'"
2015
+ end
2016
+ end
754
2017
  end
755
2018
  end
756
2019
  end