jsx_rosetta 0.5.1 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -11
  3. data/CLAUDE.md +70 -0
  4. data/README.md +50 -0
  5. data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
  6. data/lib/jsx_rosetta/ast/inflector.rb +17 -0
  7. data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
  8. data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
  9. data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
  10. data/lib/jsx_rosetta/backend/view_component.rb +48 -2
  11. data/lib/jsx_rosetta/cli.rb +175 -37
  12. data/lib/jsx_rosetta/icons/lucide.json +37 -0
  13. data/lib/jsx_rosetta/icons.rb +44 -0
  14. data/lib/jsx_rosetta/ir/lowering.rb +720 -31
  15. data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
  16. data/lib/jsx_rosetta/ir/types.rb +187 -3
  17. data/lib/jsx_rosetta/ir.rb +5 -4
  18. data/lib/jsx_rosetta/pages_routing.rb +640 -0
  19. data/lib/jsx_rosetta/version.rb +1 -1
  20. data/lib/jsx_rosetta.rb +8 -6
  21. data/plans/nextjs_pages_to_rails.md +200 -0
  22. data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
  23. data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
  24. data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
  25. data/plans/translator_widening_and_pages_followups.md +120 -0
  26. data/plans/translator_widening_slice_a.md +208 -0
  27. data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
  28. data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
  29. data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
  30. data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
  31. data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
  32. data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
  33. data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
  34. data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
  35. data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
  36. data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
  37. data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
  38. data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
  39. data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
  40. data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
  41. data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
  42. data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
  43. metadata +29 -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
 
@@ -69,19 +70,74 @@ module JsxRosetta
69
70
  # ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
70
71
  EventDescriptor = Data.define(:kind, :body)
71
72
 
72
- 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)
73
120
  super()
74
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
75
125
 
76
126
  @suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
77
127
  @namespace = namespace
128
+ @rails_view = rails_view
129
+ @href_rewriter = route_table && PagesRouting::HrefRewriter.new(route_table)
78
130
  end
79
131
 
80
- def emit(component)
132
+ def emit(component, source_filename: nil)
133
+ @current_component = component
81
134
  translator = build_translator(component)
82
135
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
83
136
  @lambda_methods = []
84
137
  @lambda_method_counts = {}
138
+ @event_handler_methods = []
139
+ @emit_module_prefix = first_emit_for_module_bindings?(component)
140
+ @source_filename = source_filename
85
141
 
86
142
  files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
87
143
  if component.stimulus_methods.any?
@@ -90,7 +146,29 @@ module JsxRosetta
90
146
  contents: render_stimulus_controller_js(component)
91
147
  )
92
148
  end
149
+ files.concat(lucide_icon_files(component))
93
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
94
172
  end
95
173
 
96
174
  def build_translator(component)
@@ -99,10 +177,15 @@ module JsxRosetta
99
177
  prop_aliases = component.props.each_with_object({}) do |prop, hash|
100
178
  hash[prop.alias_name] = prop.name if prop.alias_name
101
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.
102
184
  ViewComponent::ExpressionTranslator.new(
103
185
  prop_names: prop_names,
104
186
  local_binding_names: component.local_binding_names,
105
- prop_aliases: prop_aliases
187
+ prop_aliases: prop_aliases,
188
+ imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
106
189
  )
107
190
  end
108
191
 
@@ -151,42 +234,234 @@ module JsxRosetta
151
234
  # letter when forming the class name. Pure-PascalCase names pass
152
235
  # through unchanged.
153
236
  def class_name(component)
237
+ return rails_view_class_name if @rails_view
238
+
154
239
  base = "#{component.name[0].upcase}#{component.name[1..]}"
155
- @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/")
156
296
  end
157
297
 
158
298
  def ruby_path(component)
299
+ return "#{rails_view_dir}/#{@rails_view.action}.rb" if @rails_view
300
+
159
301
  "#{AST::Inflector.underscore(class_name(component))}.rb"
160
302
  end
161
303
 
162
304
  def stimulus_path(component)
305
+ return "#{rails_view_dir}/#{@rails_view.action}_controller.js" if @rails_view
306
+
163
307
  "#{AST::Inflector.underscore(class_name(component))}_controller.js"
164
308
  end
165
309
 
310
+ def rails_view_dir
311
+ ((@rails_view.namespace || []) + [@rails_view.controller]).join("/")
312
+ end
313
+
166
314
  def stimulus_identifier(component)
167
315
  AST::Inflector.underscore(component.name).tr("_", "-")
168
316
  end
169
317
 
170
318
  def render_ruby_class(component, translator)
171
319
  class_body = render_class_body(component, translator)
172
- 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)}"
173
323
  wrap_in_namespace("#{prefix}#{class_body}")
174
324
  end
175
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
+
176
363
  # Top-level `const`/`let` declarations outside the component
177
- # function — captured at lowering time and surfaced here as a TODO
178
- # comment block above the class definition. We don't try to
179
- # translate the JS; the human reviewer either copies the value as
180
- # 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.
181
367
  def render_module_bindings_prefix(component)
182
368
  return "" if component.module_bindings.empty?
183
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)
184
435
  lines = ["# TODO: module-level constants — translate to Ruby constants " \
185
436
  "or move to a Rails initializer:"]
186
- component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
437
+ bindings.each { |b| lines.concat(comment_lines(b.source)) }
187
438
  "#{lines.join("\n")}\n"
188
439
  end
189
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
+
190
465
  def wrap_in_namespace(body)
191
466
  return "# frozen_string_literal: true\n\n#{body}" unless @namespace
192
467
 
@@ -213,7 +488,7 @@ module JsxRosetta
213
488
  # body is intentionally minimal so it doesn't drift from the source
214
489
  # over time; a richer comment belongs in the host repo's review.
215
490
  "# #{cls} — generated by jsx_rosetta from JSX. Review before shipping.\n" \
216
- "class #{cls} < #{PHLEX_BASE_CLASS}\n#{sections}\nend\n"
491
+ "class #{cls} < #{parent_class}\n#{sections}\nend\n"
217
492
  end
218
493
 
219
494
  # For data-factory components (`export const createColumns = (args)
@@ -252,12 +527,32 @@ module JsxRosetta
252
527
  lambda_methods = (@lambda_methods || []).map do |entry|
253
528
  render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
254
529
  end
255
- all = render_methods + lambda_methods
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
256
534
  return nil if all.empty?
257
535
 
258
536
  " private\n\n#{all.join("\n\n")}"
259
537
  end
260
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
+
261
556
  # Emit one RenderMethod as a private method on the class. Params are
262
557
  # pushed into the translator scope so identifier references inside
263
558
  # the body resolve to method-local arguments.
@@ -317,6 +612,15 @@ module JsxRosetta
317
612
  end
318
613
 
319
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
+
320
624
  return "nil" if prop.default.nil?
321
625
 
322
626
  case prop.default
@@ -336,41 +640,105 @@ module JsxRosetta
336
640
  end
337
641
  end
338
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
+
339
658
  def render_view_template(component, translator)
340
659
  body = render_template_body(component, translator)
341
- prefix = render_template_prefix(component)
660
+ prefix = render_template_prefix(component, translator)
342
661
  body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
343
662
  " def view_template\n#{body_with_prefix}\n end"
344
663
  end
345
664
 
346
- def render_template_prefix(component)
665
+ def render_template_prefix(component, translator)
347
666
  lines = []
348
- lines.concat(render_react_hooks_todo(component.react_hooks))
667
+ lines.concat(render_react_hooks_todo(component.react_hooks, translator))
349
668
  lines.concat(render_local_bindings_todo(component.local_bindings))
350
669
  return "" if lines.empty?
351
670
 
352
671
  "#{lines.map { |l| " #{l}" }.join("\n")}\n"
353
672
  end
354
673
 
355
- def render_react_hooks_todo(hooks)
674
+ def render_react_hooks_todo(hooks, translator)
356
675
  return [] if hooks.empty?
357
676
 
358
677
  # Preserve the source order of the first occurrence per library so
359
678
  # the React block (typical) lands before Apollo/Next.js blocks when
360
679
  # all three are present. group_by preserves first-seen order.
361
- hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls) }
680
+ hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls, translator) }
362
681
  end
363
682
 
364
- def hook_todo_block_lines(library, calls)
683
+ def hook_todo_block_lines(library, calls, translator)
365
684
  header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
366
685
  lines = header_lines.map { |line| "# #{line}" }
367
686
  calls.each do |call|
687
+ lines.concat(router_push_hint_lines(call.source, translator))
368
688
  lines << "# operation: #{call.operation}" if call.operation
369
689
  lines.concat(comment_lines(call.source))
370
690
  end
371
691
  lines
372
692
  end
373
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
+
374
742
  def render_local_bindings_todo(bindings)
375
743
  return [] if bindings.empty?
376
744
 
@@ -409,6 +777,7 @@ module JsxRosetta
409
777
  when IR::Text then render_text(node, indent: indent)
410
778
  when IR::Interpolation then render_interpolation(node, translator, indent: indent)
411
779
  when IR::Comment then render_comment(node, indent: indent)
780
+ when IR::LayoutYield then "#{" " * indent}yield"
412
781
  end
413
782
  end
414
783
 
@@ -441,36 +810,81 @@ module JsxRosetta
441
810
 
442
811
  def render_element(element, translator, indent:)
443
812
  todos = []
444
- attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos, indent: indent)
813
+ attrs_source = format_attributes(element.attributes, translator,
814
+ context: :html, tag: element.tag, todos: todos, indent: indent)
445
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
446
819
 
447
- body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
448
- "#{spaces(indent)}#{method_call}"
449
- else
450
- inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
451
- "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
452
- 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)
453
824
 
454
- 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)
455
842
  end
456
843
 
457
844
  def render_component_invocation(invocation, translator, indent:)
458
845
  todos = []
459
- kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: indent)
846
+ kwargs = component_invocation_kwargs(invocation.props, translator,
847
+ todos: todos, indent: indent, tag: invocation.name)
460
848
  class_ref = component_class_reference(invocation.name)
461
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
462
853
 
854
+ def component_invocation_body(invocation, new_call, translator, indent)
463
855
  render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
464
- body = if render_prop
465
- render_with_render_prop(new_call, render_prop, translator, indent)
466
- elsif invocation.children.empty?
467
- "#{spaces(indent)}render #{new_call}"
468
- else
469
- inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
470
- "#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
471
- end
856
+ return render_with_render_prop(new_call, render_prop, translator, indent) if render_prop
472
857
 
473
- 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)
474
888
  end
475
889
 
476
890
  # Emit a render-prop child as a Ruby block on the parent `render` call.
@@ -497,9 +911,14 @@ module JsxRosetta
497
911
  # JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
498
912
  # `Foo` again under namespace (Ruby's constant lookup finds the
499
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.
500
918
  def component_class_reference(jsx_tag)
501
919
  segments = jsx_tag.split(".")
502
- segments[-1] = "#{segments.last}#{@suffix}" if @suffix
920
+ suffix = effective_suffix_for(segments.last)
921
+ segments[-1] = "#{segments.last}#{suffix}" if suffix
503
922
  segments.join("::")
504
923
  end
505
924
 
@@ -508,12 +927,80 @@ module JsxRosetta
508
927
  end
509
928
 
510
929
  def render_conditional(conditional, translator, indent:)
930
+ if guard_ladder?(conditional, translator)
931
+ return render_guard_ladder_collapse(conditional, translator, indent: indent)
932
+ end
933
+
511
934
  lines = []
512
935
  emit_conditional_branches(conditional, translator, indent, lines, leading_keyword: "if")
513
936
  lines << "#{spaces(indent)}end"
514
937
  lines.join("\n")
515
938
  end
516
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
+
517
1004
  # Walk a Conditional and its `alternate` chain, emitting `if` for the
518
1005
  # first test, `elsif` for each alternate that is itself a Conditional,
519
1006
  # and a final `else` for a non-Conditional alternate. Flattens the
@@ -522,8 +1009,13 @@ module JsxRosetta
522
1009
  # this, deeply nested conditional chains explode the file's
523
1010
  # indentation and trip Style/IfInsideElse + Metrics/BlockNesting.
524
1011
  def emit_conditional_branches(conditional, translator, indent, lines, leading_keyword:)
525
- test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
526
- lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
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
527
1019
  lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
528
1020
  lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
529
1021
 
@@ -565,32 +1057,51 @@ module JsxRosetta
565
1057
  when IR::ArrayLiteral
566
1058
  [render_array_literal_value(iterable, translator, todos: []), nil]
567
1059
  when IR::Interpolation
568
- safe_test_expression(iterable.expression, translator, fallback: "[]")
1060
+ safe_iterable_expression(iterable.expression, translator)
569
1061
  else
570
1062
  ["[]", iterable.inspect]
571
1063
  end
572
1064
  end
573
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
+
574
1081
  # Translate an expression intended to drive an `if` or `.each` call.
575
- # Returns `[ruby_source, todo_text]`. When the translator can parse
576
- # the expression, `todo_text` is nil. When it can't, the caller's
577
- # `fallback` (e.g. `"false"` for conditions, `"[]"` for iterables)
578
- # is returned along with the original expression so a TODO comment
579
- # can be emitted above the call. Without this, JS operators like
580
- # `!==`, `===`, optional chaining, and `in` would leak into the
581
- # 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.
582
1093
  #
583
- # A translated value of `"nil"` is treated as untranslatable: the
584
- # translator returns `"nil"` for known-local bindings (so the file
585
- # loads as a leaf reference), but driving an `if` with `nil` silently
586
- # disables the whole branch. Fall through to the TODO path instead so
587
- # the human reviewer sees what needs filling in.
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.
588
1099
  def safe_test_expression(expression, translator, fallback:)
589
- translated = translator.translate(expression)
590
- return [translated.ruby, nil] if translated && translated.ruby != "nil"
1100
+ translated = translator.translate_condition(expression)
1101
+ return [translated.ruby, nil, translated.promoted_locals] if translated && translated.ruby != "nil"
591
1102
 
592
1103
  compact = expression.tr("\n", " ").squeeze(" ")
593
- [fallback, compact]
1104
+ [fallback, compact, []]
594
1105
  end
595
1106
 
596
1107
  def render_slot(slot, indent:)
@@ -656,22 +1167,31 @@ module JsxRosetta
656
1167
  # of `h1()`). The `context:` param selects naming convention:
657
1168
  # - :html (HTML element attrs — preserve camelCase for SVG)
658
1169
  # - :component (Ruby method args — snake_case via Inflector.underscore)
659
- def format_attributes(attributes, translator, context: :html, todos: [], indent: 0)
1170
+ def format_attributes(attributes, translator, context: :html, tag: nil, todos: [], indent: 0)
660
1171
  events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
661
1172
  spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
662
1173
 
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
+
663
1181
  parts = { sym: [], str: [] }
664
1182
  plain_attrs.each do |a|
665
- append_attribute_part(a, translator, parts, context: context, todos: todos, indent: indent)
1183
+ append_attribute_part(a, translator, parts, context: context, tag: tag, todos: todos, indent: indent)
666
1184
  end
667
1185
  parts[:sym] << data_action_entry(events, translator) if events.any?
668
1186
 
669
1187
  joined = build_attribute_list(parts, spreads, translator)
670
1188
  joined.empty? ? "" : "(#{joined})"
1189
+ ensure
1190
+ @form_action_rewritable = prev_form_rewritable
671
1191
  end
672
1192
 
673
- def append_attribute_part(attribute, translator, parts, context:, todos:, indent: 0)
674
- part = phlex_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
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)
675
1195
  return unless part
676
1196
 
677
1197
  (part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
@@ -686,23 +1206,102 @@ module JsxRosetta
686
1206
 
687
1207
  # Emit one attribute as either a {string_key: false, source: "id: @x"}
688
1208
  # (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
689
- # (rare; non-identifier name — goes into a **{ ... } splat).
690
- def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0)
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)
691
1215
  case attribute
1216
+ when IR::CvaCallSite then cva_call_site_attribute_part(attribute, translator)
692
1217
  when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
693
1218
  when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
694
- when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator, todos: todos)}" }
1219
+ when IR::Style then style_attribute_part(attribute, translator, todos: todos)
695
1220
  when IR::Attribute
696
- plain_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
1221
+ plain_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
697
1222
  end
698
1223
  end
699
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
+
700
1251
  def class_attribute_part(expression, translator)
701
1252
  translated = translator.translate(expression)
702
1253
  ruby = translated ? translated.ruby : expression.inspect
703
1254
  { string_key: false, source: "class: #{ruby}" }
704
1255
  end
705
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
+
706
1305
  # Map a JSX attribute name to its Ruby kwarg form. For HTML element
707
1306
  # attrs (`context: :html`), only hyphens convert to underscores —
708
1307
  # camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
@@ -712,8 +1311,12 @@ module JsxRosetta
712
1311
  # follow snake_case convention (`defaultValue` → `default_value`).
713
1312
  # Names that aren't valid Ruby identifiers after conversion (rare:
714
1313
  # `xml:lang` and friends) fall back to a quoted string key.
715
- def plain_attribute_part(attribute, translator, context:, todos:, indent: 0)
716
- value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos, indent: indent)
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
+
717
1320
  ruby_name = case context
718
1321
  when :component then AST::Inflector.underscore(attribute.name)
719
1322
  else attribute.name.tr("-", "_")
@@ -725,7 +1328,11 @@ module JsxRosetta
725
1328
  end
726
1329
  end
727
1330
 
728
- def attribute_value_to_ruby(name, value, translator, todos:, indent: 0)
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
+
729
1336
  case value
730
1337
  when true then "true"
731
1338
  when String then AST::Inflector.ruby_string_literal(value)
@@ -733,9 +1340,89 @@ module JsxRosetta
733
1340
  when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos, indent: indent)
734
1341
  when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos, indent: indent)
735
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)
736
1352
  end
737
1353
  end
738
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
1390
+ end
1391
+ end
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
+
739
1426
  # Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
740
1427
  # entries become Ruby kwargs (snake_cased to match Ruby convention);
741
1428
  # non-identifier keys (numeric, hyphenated) fall back to string keys.
@@ -805,8 +1492,13 @@ module JsxRosetta
805
1492
  when IR::ArrayLiteral
806
1493
  render_array_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
807
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)
808
1496
  when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
809
1497
  todos: todos)
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)
810
1502
  when String then AST::Inflector.ruby_string_literal(value)
811
1503
  when true then "true"
812
1504
  else
@@ -843,6 +1535,31 @@ module JsxRosetta
843
1535
  @lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
844
1536
  end
845
1537
 
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
+
846
1563
  # Attribute-position interpolation. Three failure modes:
847
1564
  # 1. Translator returns non-nil, no unresolved identifiers — emit
848
1565
  # the Ruby reference directly. Common case.
@@ -859,9 +1576,7 @@ module JsxRosetta
859
1576
  # template literals with method calls). Same TODO + nil path.
860
1577
  def interpolated_attribute_value(name, value, translator, todos:)
861
1578
  translated = translator.translate(value.expression)
862
- if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
863
- return translated.ruby
864
- end
1579
+ return translated.ruby if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
865
1580
 
866
1581
  compact = value.expression.tr("\n", " ").squeeze(" ")
867
1582
  todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
@@ -872,13 +1587,84 @@ module JsxRosetta
872
1587
  unresolved_identifiers.any? { |name| name[0] == name[0].upcase }
873
1588
  end
874
1589
 
875
- def component_invocation_kwargs(props, translator, todos: [], indent: 0)
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)
876
1662
  events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
877
1663
  spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
878
1664
 
879
1665
  parts = { sym: [], str: [] }
880
1666
  plain_attrs.each do |a|
881
- append_attribute_part(a, translator, parts, context: :component, todos: todos, indent: indent)
1667
+ append_attribute_part(a, translator, parts, context: :component, tag: tag, todos: todos, indent: indent)
882
1668
  end
883
1669
  parts[:sym] << data_action_entry(events, translator) if events.any?
884
1670
 
@@ -995,15 +1781,77 @@ module JsxRosetta
995
1781
  "#{lines.join("\n")}\n"
996
1782
  end
997
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.
998
1792
  def stimulus_method_lines(method)
999
- body_lines = method.body_source.strip.split("\n")
1000
- commented = body_lines.map { |line| " // #{line}" }
1001
- header = [" // TODO: translate from the original JSX handler:"]
1793
+ lines = []
1002
1794
  if method.name != method.original_name
1003
- header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
1004
- "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"
1797
+ end
1798
+
1799
+ if safe_to_paste_handler?(method)
1800
+ lines.concat(pasted_handler_lines(method))
1801
+ else
1802
+ lines.concat(todo_handler_lines(method))
1005
1803
  end
1006
- header + commented + [
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,
1007
1855
  " #{method.name}(event) {",
1008
1856
  " // ...",
1009
1857
  " }"
@@ -1013,6 +1861,159 @@ module JsxRosetta
1013
1861
  def spaces(count)
1014
1862
  " " * count
1015
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
1016
2017
  end
1017
2018
  end
1018
2019
  end