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 "types"
4
4
  require_relative "module_shape_classifier"
5
+ require_relative "../ast/inflector"
5
6
 
6
7
  module JsxRosetta
7
8
  module IR
@@ -54,12 +55,12 @@ module JsxRosetta
54
55
  end
55
56
  end
56
57
 
57
- def self.lower(file, source:)
58
- new(source).lower_file(file)
58
+ def self.lower(file, source:, keep_slot: false)
59
+ new(source, keep_slot: keep_slot).lower_file(file)
59
60
  end
60
61
 
61
- def self.lower_all(file, source:)
62
- new(source).lower_all_components(file)
62
+ def self.lower_all(file, source:, keep_slot: false)
63
+ new(source, keep_slot: keep_slot).lower_all_components(file)
63
64
  end
64
65
 
65
66
  REACT_HOOKS = %w[
@@ -67,8 +68,53 @@ module JsxRosetta
67
68
  useReducer useImperativeHandle useLayoutEffect useDebugValue
68
69
  ].freeze
69
70
 
71
+ # Apollo Client hooks. `useQuery` / `useLazyQuery` / `useSubscription`
72
+ # take a GraphQL document as the first argument; `useMutation` returns
73
+ # a `[mutate, { loading, ... }]` tuple. None of these have a direct
74
+ # translation — they encode data fetching, which in Rails lives in
75
+ # the controller/model. Captured here so the backend can emit a
76
+ # per-hook TODO with the operation name preserved when extractable.
77
+ APOLLO_HOOKS = %w[
78
+ useQuery useLazyQuery useMutation useSubscription useApolloClient
79
+ ].freeze
80
+
81
+ # Next.js navigation hooks (App Router and Pages Router). Each has a
82
+ # Rails-side analog:
83
+ # useRouter → controller actions / redirect_to
84
+ # usePathname → request.path
85
+ # useSearchParams → params
86
+ # useParams → params (route params)
87
+ # useSelectedLayoutSegment(s) → not directly translatable; usually
88
+ # used to highlight nav links — the Rails view can pattern-match
89
+ # against request.path.
90
+ NEXT_HOOKS = %w[
91
+ useRouter usePathname useSearchParams useParams
92
+ useSelectedLayoutSegment useSelectedLayoutSegments
93
+ ].freeze
94
+
95
+ FRAMEWORK_HOOKS_BY_LIBRARY = {
96
+ react: REACT_HOOKS,
97
+ apollo: APOLLO_HOOKS,
98
+ next_js: NEXT_HOOKS
99
+ }.freeze
100
+
70
101
  JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
71
102
 
103
+ # Mirrors React's `isUnitlessNumber` table — CSS properties that take
104
+ # a bare number rather than a length. Numeric style values for any
105
+ # property NOT in this set get a `px` suffix appended at lowering time.
106
+ UNITLESS_CSS_PROPERTIES = %w[
107
+ animation-iteration-count aspect-ratio border-image-outset
108
+ border-image-slice border-image-width box-flex box-flex-group
109
+ box-ordinal-group column-count columns flex flex-grow flex-negative
110
+ flex-order flex-positive flex-shrink font-weight grid-area
111
+ grid-column grid-column-end grid-column-span grid-column-start
112
+ grid-row grid-row-end grid-row-span grid-row-start line-clamp
113
+ line-height opacity order orphans scale tab-size widows z-index
114
+ zoom fill-opacity flood-opacity stop-opacity stroke-dasharray
115
+ stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width
116
+ ].to_set.freeze
117
+
72
118
  # Pre-lowering AST scan: maps a node type to a callable returning the
73
119
  # AST nodes that contribute return values. Used by body_returns_jsx?.
74
120
  JSX_RETURN_PROBES = {
@@ -80,6 +126,14 @@ module JsxRosetta
80
126
  "LogicalExpression" => ->(n) { [n[:left], n[:right]] }
81
127
  }.freeze
82
128
 
129
+ # Known wrapper-call names that lowering peers through to find the
130
+ # inner component definition. Both bare (`memo(...)`) and React-
131
+ # namespaced (`React.memo(...)`) forms count. Wrappers we don't
132
+ # unwrap (e.g. `React.lazy` — different shape, no inline function
133
+ # body) stay off this list on purpose; declarations using them
134
+ # won't be recognized as components, same as pre-unwrap behavior.
135
+ HOC_WRAPPER_NAMES = %w[memo forwardRef observer connect withRouter withTranslation].to_set.freeze
136
+
83
137
  SHAPE_MESSAGES = {
84
138
  hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
85
139
  "this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
@@ -100,8 +154,14 @@ module JsxRosetta
100
154
  unknown: nil
101
155
  }.freeze
102
156
 
103
- def initialize(source)
157
+ def initialize(source, keep_slot: false)
104
158
  @source = source
159
+ # When false (default), the shadcn `<Comp asChild>` pattern that
160
+ # routes through Radix's Slot.Root gets its Slot branch dropped at
161
+ # lowering time, leaving only the non-Slot HTML/component branch.
162
+ # When true, preserve the full polymorphic conditional (legacy
163
+ # behavior; useful if the consumer shims Components::Slot::Root).
164
+ @keep_slot = keep_slot
105
165
  @prop_names = []
106
166
  @local_jsx = {}
107
167
  @local_bindings = []
@@ -112,6 +172,18 @@ module JsxRosetta
112
172
  @stimulus_methods = []
113
173
  @stimulus_seen_names = {}
114
174
  @react_hooks = []
175
+ @render_methods = []
176
+ @render_method_seen = {}
177
+ # File-level imports; populated once at lower_file / lower_all_components
178
+ # entry and consulted by JSX lowering to decide whether a member-chain
179
+ # tag like `SeparatorPrimitive.Root` should resolve through the Radix
180
+ # registry into an HTML Element.
181
+ @module_imports = []
182
+ # Class-component non-render members (constructor, lifecycle hooks,
183
+ # custom handlers). Keyed by class name; populated by
184
+ # extract_class_component, drained by lower_component to surface
185
+ # the verbatim sources as a TODO comment block.
186
+ @pending_class_other_members = {}
115
187
  end
116
188
 
117
189
  def lower_file(file)
@@ -119,17 +191,63 @@ module JsxRosetta
119
191
  raise no_component_error(file.program) if candidates.empty?
120
192
 
121
193
  name, function = candidates.first
122
- module_bindings = capture_module_bindings(file.program, candidates)
123
- attach_module_bindings(lower_component(name, function), module_bindings)
194
+ @module_bindings = capture_module_bindings(file.program, candidates)
195
+ @module_imports = capture_module_imports(file.program)
196
+ @server_data_source = capture_server_data_source(file.program)
197
+ attach_module_metadata(lower_component(name, function),
198
+ @module_bindings, @module_imports, @server_data_source)
124
199
  end
125
200
 
126
201
  def lower_all_components(file)
127
202
  candidates = find_component_functions(file.program)
128
203
  raise no_component_error(file.program) if candidates.empty?
129
204
 
130
- module_bindings = capture_module_bindings(file.program, candidates)
131
- candidates.map do |name, function|
132
- attach_module_bindings(lower_component(name, function), module_bindings)
205
+ @module_bindings = capture_module_bindings(file.program, candidates)
206
+ @module_imports = capture_module_imports(file.program)
207
+ @server_data_source = capture_server_data_source(file.program)
208
+ candidates.each_with_index.map do |(name, function), idx|
209
+ # Only the first sibling carries the server_data_source — a page
210
+ # file has at most one such export, and attaching it to every
211
+ # sibling would duplicate the TODO block across N files.
212
+ sds = idx.zero? ? @server_data_source : nil
213
+ attach_module_metadata(lower_component(name, function),
214
+ @module_bindings, @module_imports, sds)
215
+ end
216
+ end
217
+
218
+ SERVER_DATA_HOOK_NAMES = %w[getServerSideProps getStaticProps].freeze
219
+
220
+ # Capture a top-level `export function getServerSideProps()` or
221
+ # `export const getServerSideProps = ...` (or getStaticProps) so the
222
+ # Phlex backend can surface the body as a TODO comment block. Returns
223
+ # nil when no such export is present (the common case for ordinary
224
+ # components — only Next.js pages have these).
225
+ def capture_server_data_source(program)
226
+ program.body.each do |stmt|
227
+ next unless stmt.of_type?("ExportNamedDeclaration")
228
+
229
+ decl = stmt[:declaration]
230
+ next unless decl.is_a?(AST::Node)
231
+
232
+ name = server_data_hook_name(decl)
233
+ return ServerDataSource.new(hook_name: name, source: source_of(stmt)) if name
234
+ end
235
+ nil
236
+ end
237
+
238
+ def server_data_hook_name(decl)
239
+ case decl.type
240
+ when "FunctionDeclaration"
241
+ id = decl[:id]
242
+ id&.[](:name) if id.is_a?(AST::Node) && SERVER_DATA_HOOK_NAMES.include?(id[:name])
243
+ when "VariableDeclaration"
244
+ first = decl[:declarations].first
245
+ return nil unless first.is_a?(AST::Node)
246
+
247
+ id = first[:id]
248
+ return nil unless id.is_a?(AST::Node) && id.of_type?("Identifier")
249
+
250
+ id[:name] if SERVER_DATA_HOOK_NAMES.include?(id[:name])
133
251
  end
134
252
  end
135
253
 
@@ -153,32 +271,300 @@ module JsxRosetta
153
271
  case stmt.type
154
272
  when "VariableDeclaration"
155
273
  stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
156
- when "ExportNamedDeclaration"
274
+ when "FunctionDeclaration"
275
+ record_module_function_binding(stmt, component_names, bindings)
276
+ when "ExportNamedDeclaration", "ExportDefaultDeclaration"
157
277
  decl = stmt[:declaration]
158
278
  walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
159
279
  end
160
280
  end
161
281
 
282
+ # Top-level `function onError(){}` helpers — non-component, non-hook
283
+ # functions declared in the same file as the component. Without
284
+ # capture, a `<Button onClick={onError}>` use site translates to
285
+ # `on_click: on_error` which NameErrors at render time because
286
+ # nothing binds `on_error`. Recording the name here threads it into
287
+ # the translator's bailout set so the reference becomes `on_click: nil`
288
+ # plus a visible TODO.
289
+ def record_module_function_binding(stmt, component_names, bindings)
290
+ name = stmt[:id]&.[](:name)
291
+ return unless name
292
+ return if component_names.include?(name)
293
+
294
+ bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
295
+ end
296
+
162
297
  def record_module_binding(stmt, declarator, component_names, bindings)
163
298
  init = declarator[:init]
164
299
  return unless init.is_a?(AST::Node)
165
300
 
166
- # Component declarators (`const Foo = () => ...`) are handled by
167
- # the component pipeline; skip them here so the source doesn't
168
- # show up twice.
169
- return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
170
- component_names.include?(declarator[:id]&.[](:name))
171
-
172
301
  name = declarator[:id]&.[](:name)
173
302
  return unless name
174
303
 
304
+ # Component declarators (`const Foo = () => ...` and the
305
+ # HOC-wrapped `const Foo = memo(() => ...)` form) are handled by
306
+ # the component pipeline; skip them here so the source doesn't
307
+ # surface twice (once as a TODO, once as the class).
308
+ return if component_names.include?(name)
309
+
310
+ # shadcn-style `const fooVariants = cva(base, { variants, ... })` gets
311
+ # recognized at lowering and stored as a CvaBinding — the backend
312
+ # turns it into real Ruby constants and the use-site call collapses
313
+ # to a string interpolation. Falls through to the generic LocalBinding
314
+ # path when the cva shape doesn't match exactly.
315
+ if (cva = parse_cva_binding(init, name))
316
+ bindings << cva
317
+ return
318
+ end
319
+
320
+ # Literal-shaped `const FOO = "x"` / `const COLUMNS = [...]` /
321
+ # `const TAGS = {...}` lowers to a real Ruby constant emitted above
322
+ # the class. Anything richer (call expressions, identifier refs,
323
+ # JSX) bails to the LocalBinding TODO-block fallback below.
324
+ if (constant = parse_module_constant(init, name))
325
+ bindings << constant
326
+ return
327
+ end
328
+
175
329
  bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
176
330
  end
177
331
 
178
- def attach_module_bindings(component, module_bindings)
179
- return component if module_bindings.empty?
332
+ # Returns an IR::ModuleConstant when `init` reduces to a Ruby-literal-
333
+ # friendly value, or nil otherwise. Anything that depends on runtime
334
+ # state (call expressions, identifier references, function expressions,
335
+ # JSX) bails out so the existing LocalBinding TODO path still surfaces
336
+ # the original JS source.
337
+ def parse_module_constant(init, name)
338
+ value = literal_value(init)
339
+ return nil if value == :__not_literal__
340
+
341
+ ModuleConstant.new(
342
+ name: name,
343
+ constant_name: AST::Inflector.underscore(name).upcase,
344
+ value: value
345
+ )
346
+ end
347
+
348
+ # Returns the Ruby-literal-friendly value for `node`, or the sentinel
349
+ # `:__not_literal__` when the node isn't translatable. Sentinel rather
350
+ # than `nil` so a JS `null` (which legitimately maps to Ruby `nil`)
351
+ # is distinguishable from "couldn't parse this."
352
+ def literal_value(node)
353
+ return :__not_literal__ unless node.is_a?(AST::Node)
354
+
355
+ case node.type
356
+ when "StringLiteral", "NumericLiteral", "BooleanLiteral" then node[:value]
357
+ when "NullLiteral" then nil
358
+ when "TemplateLiteral" then literal_value_from_template(node)
359
+ when "ArrayExpression" then literal_value_from_array(node)
360
+ when "ObjectExpression" then literal_value_from_object(node)
361
+ when "UnaryExpression" then literal_value_from_unary(node)
362
+ when "TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion"
363
+ literal_value(node[:expression])
364
+ else :__not_literal__
365
+ end
366
+ end
367
+
368
+ def literal_value_from_template(node)
369
+ return :__not_literal__ unless (node[:expressions] || []).empty?
370
+
371
+ # `quasi[:value]` is a plain Hash with String keys ("cooked" / "raw"),
372
+ # not an AST::Node — Babel's AST wraps Hashes only when they carry a
373
+ # "type" field. Use the String key directly.
374
+ (node[:quasis] || []).map { |q| q[:value]["cooked"] }.join
375
+ end
376
+
377
+ def literal_value_from_array(node)
378
+ elements = node[:elements] || []
379
+ result = []
380
+ elements.each do |elem|
381
+ # Holes in array literals (`[1, , 3]`) come through as nil; map to
382
+ # Ruby `nil` to preserve length. Spread elements bail — we can't
383
+ # statically expand the spread target.
384
+ if elem.nil?
385
+ result << nil
386
+ next
387
+ end
388
+ return :__not_literal__ if elem.is_a?(AST::Node) && elem.type == "SpreadElement"
389
+
390
+ value = literal_value(elem)
391
+ return :__not_literal__ if value == :__not_literal__
392
+
393
+ result << value
394
+ end
395
+ result
396
+ end
397
+
398
+ def literal_value_from_object(node)
399
+ properties = node[:properties] || []
400
+ result = {}
401
+ properties.each do |prop|
402
+ return :__not_literal__ unless prop.is_a?(AST::Node) && prop.type == "ObjectProperty"
403
+ return :__not_literal__ if prop[:computed]
404
+ return :__not_literal__ if prop[:shorthand] && prop[:value].is_a?(AST::Node) &&
405
+ prop[:value].type == "Identifier"
406
+
407
+ key = property_key(prop)
408
+ return :__not_literal__ unless key.is_a?(String)
409
+
410
+ value = literal_value(prop[:value])
411
+ return :__not_literal__ if value == :__not_literal__
412
+
413
+ result[key] = value
414
+ end
415
+ result
416
+ end
417
+
418
+ def literal_value_from_unary(node)
419
+ return :__not_literal__ unless %w[- +].include?(node[:operator])
420
+
421
+ inner = literal_value(node[:argument])
422
+ return :__not_literal__ unless inner.is_a?(Numeric)
423
+
424
+ node[:operator] == "-" ? -inner : inner
425
+ end
426
+
427
+ # Returns a CvaBinding when `init` is a `cva(base, options)` call we
428
+ # know how to parse, or nil to fall through to LocalBinding.
429
+ def parse_cva_binding(init, name)
430
+ return nil unless cva_call?(init)
431
+
432
+ args = init[:arguments] || []
433
+ base_class = extract_cva_string(args[0])
434
+ return nil unless base_class
435
+
436
+ options = args[1]
437
+ return nil unless options.is_a?(AST::Node) && options.type == "ObjectExpression"
438
+
439
+ CvaBinding.new(
440
+ name: name,
441
+ base_class: base_class,
442
+ variants: extract_cva_variants(options),
443
+ default_variants: extract_cva_default_variants(options),
444
+ compound_source: extract_cva_compound_source(options)
445
+ )
446
+ end
447
+
448
+ def cva_call?(node)
449
+ return false unless node.is_a?(AST::Node) && node.type == "CallExpression"
450
+
451
+ callee = node[:callee]
452
+ callee.is_a?(AST::Node) && callee.type == "Identifier" && callee[:name] == "cva"
453
+ end
454
+
455
+ def extract_cva_string(node)
456
+ return nil unless node.is_a?(AST::Node)
457
+
458
+ case node.type
459
+ when "StringLiteral"
460
+ node[:value]
461
+ when "TemplateLiteral"
462
+ # Only handle templates with no interpolations — they're effectively
463
+ # a string literal (shadcn's cva bases sometimes use a template for
464
+ # multi-line readability).
465
+ return nil unless (node[:expressions] || []).empty?
466
+
467
+ (node[:quasis] || []).map { |q| q[:value][:cooked] }.join
468
+ end
469
+ end
470
+
471
+ def extract_cva_variants(options_node)
472
+ prop = find_object_property(options_node, "variants")
473
+ return {} unless object_expression?(prop&.[](:value))
474
+
475
+ prop[:value][:properties].each_with_object({}) do |axis, hash|
476
+ axis_name = property_key(axis)
477
+ options = extract_cva_axis_options(axis[:value])
478
+ hash[axis_name] = options if axis_name && !options.empty?
479
+ end
480
+ end
481
+
482
+ def extract_cva_axis_options(axis_value_node)
483
+ return {} unless object_expression?(axis_value_node)
484
+
485
+ axis_value_node[:properties].each_with_object({}) do |opt, hash|
486
+ opt_name = property_key(opt)
487
+ opt_value = extract_cva_string(opt[:value])
488
+ hash[opt_name] = opt_value if opt_name && opt_value
489
+ end
490
+ end
491
+
492
+ def object_expression?(node)
493
+ node.is_a?(AST::Node) && node.type == "ObjectExpression"
494
+ end
495
+
496
+ def extract_cva_default_variants(options_node)
497
+ prop = find_object_property(options_node, "defaultVariants")
498
+ return {} unless prop && prop[:value].is_a?(AST::Node) && prop[:value].type == "ObjectExpression"
499
+
500
+ prop[:value][:properties].each_with_object({}) do |p, hash|
501
+ key = property_key(p)
502
+ val = extract_cva_string(p[:value])
503
+ hash[key] = val if key && val
504
+ end
505
+ end
506
+
507
+ def extract_cva_compound_source(options_node)
508
+ prop = find_object_property(options_node, "compoundVariants")
509
+ return nil unless prop
180
510
 
181
- component.with(module_bindings: module_bindings)
511
+ source_of(prop[:value]).strip
512
+ end
513
+
514
+ def find_object_property(obj_node, name)
515
+ (obj_node[:properties] || []).find { |p| property_key(p) == name }
516
+ end
517
+
518
+ def property_key(prop)
519
+ return nil unless prop.is_a?(AST::Node) && prop[:key].is_a?(AST::Node)
520
+
521
+ key = prop[:key]
522
+ case key.type
523
+ when "Identifier" then key[:name]
524
+ when "StringLiteral" then key[:value]
525
+ end
526
+ end
527
+
528
+ def attach_module_metadata(component, module_bindings, module_imports, server_data_source = nil)
529
+ component.with(
530
+ module_bindings: module_bindings,
531
+ module_imports: module_imports,
532
+ server_data_source: server_data_source
533
+ )
534
+ end
535
+
536
+ # Capture every top-level `import` declaration so the translator can
537
+ # recognize use-site references at expression-context. Without this,
538
+ # an import like `import styles from "./X.module.css"` lets every
539
+ # `styles.listContainer` use snake-case to a bare `styles` reference
540
+ # that NameErrors at render time.
541
+ def capture_module_imports(program)
542
+ imports = []
543
+ program.body.each do |stmt|
544
+ next unless stmt.is_a?(AST::Node) && stmt.type == "ImportDeclaration"
545
+
546
+ source = stmt[:source]&.[](:value).to_s
547
+ (stmt[:specifiers] || []).each do |spec|
548
+ name = spec[:local]&.[](:name)
549
+ next unless name
550
+
551
+ imports << ModuleImport.new(
552
+ name: name,
553
+ source: source,
554
+ kind: import_specifier_kind(spec),
555
+ imported_name: spec[:imported]&.[](:name)
556
+ )
557
+ end
558
+ end
559
+ imports
560
+ end
561
+
562
+ def import_specifier_kind(spec)
563
+ case spec.type
564
+ when "ImportDefaultSpecifier" then :default
565
+ when "ImportNamespaceSpecifier" then :namespace
566
+ else :named
567
+ end
182
568
  end
183
569
 
184
570
  private
@@ -210,6 +596,7 @@ module JsxRosetta
210
596
  return false if name.nil? || name.empty?
211
597
  return true if pascal_case?(name)
212
598
  return false if hook_name?(name)
599
+ return true if extract_data_factory_array(function)
213
600
 
214
601
  body_returns_jsx?(function[:body])
215
602
  end
@@ -247,6 +634,8 @@ module JsxRosetta
247
634
  [[stmt[:id]&.[](:name), stmt]]
248
635
  when "VariableDeclaration"
249
636
  extract_arrow_components(stmt)
637
+ when "ClassDeclaration"
638
+ extract_class_component(stmt)
250
639
  when "ExportNamedDeclaration", "ExportDefaultDeclaration"
251
640
  extract_exported_components(stmt[:declaration])
252
641
  else
@@ -260,66 +649,366 @@ module JsxRosetta
260
649
  case declaration.type
261
650
  when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
262
651
  when "VariableDeclaration" then extract_arrow_components(declaration)
652
+ when "ClassDeclaration" then extract_class_component(declaration)
653
+ when "CallExpression" then extract_hoc_default_export(declaration)
263
654
  else []
264
655
  end
265
656
  end
266
657
 
658
+ # `export default memo(function X() {...})` — the declaration is a
659
+ # CallExpression whose argument is a named FunctionExpression. Peer
660
+ # through to the inner function and record the wrapper. Anonymous
661
+ # forms (`export default memo(function () {...})`) get skipped —
662
+ # `lower_component` rejects anonymous functions and the pre-unwrap
663
+ # behavior was the same.
664
+ def extract_hoc_default_export(call_expression)
665
+ unwrapped = unwrap_hoc(call_expression)
666
+ return [] unless unwrapped
667
+
668
+ inner = unwrapped[:function]
669
+ name = inner[:id]&.[](:name) if inner.respond_to?(:[])
670
+ return [] unless name
671
+
672
+ record_hoc_wrappers(name, unwrapped[:wrappers])
673
+ [[name, inner]]
674
+ end
675
+
676
+ # Recognize a class component by the presence of a `render()` method.
677
+ # We don't require `extends React.Component` because TypeScript codebases
678
+ # often declare the parent via an `extends` of a typed alias. The render
679
+ # method's signature (no args, returns JSX) is the JSX-component signal.
680
+ #
681
+ # The render ClassMethod's `[:params]` is always `[]` and `[:body]` is a
682
+ # BlockStatement — same shape as a function declaration's body, so the
683
+ # rest of the lowering pipeline works unchanged. Other class members
684
+ # (constructor, lifecycle hooks, custom handlers) get stashed on
685
+ # `@pending_class_other_members` keyed by class name, then surfaced as
686
+ # a LocalBinding-style TODO block by `lower_component`.
687
+ def extract_class_component(class_decl)
688
+ name = class_decl[:id]&.[](:name)
689
+ return [] unless name
690
+
691
+ render_method, other_members = partition_class_members(class_decl)
692
+ return [] unless render_method
693
+
694
+ @pending_class_other_members[name] = other_members
695
+ [[name, render_method]]
696
+ end
697
+
698
+ def partition_class_members(class_decl)
699
+ body = class_decl.child(:body)
700
+ return [nil, []] unless body
701
+
702
+ render_method = nil
703
+ others = []
704
+ body[:body].each do |member|
705
+ if class_render_method?(member)
706
+ render_method = member
707
+ else
708
+ others << member
709
+ end
710
+ end
711
+ [render_method, others]
712
+ end
713
+
714
+ def class_render_method?(member)
715
+ return false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
716
+
717
+ key = member.child(:key)
718
+ AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
719
+ end
720
+
721
+ # Surface every non-render class member (constructor, lifecycle
722
+ # methods like componentDidMount / componentDidCatch / getDerivedStateFromError,
723
+ # custom event handlers) as a LocalBinding-shaped TODO with the
724
+ # verbatim JS source preserved. The user either translates each to a
725
+ # Ruby method by hand or moves the behavior to Stimulus / controllers.
726
+ def absorb_class_other_members(name)
727
+ members = @pending_class_other_members.delete(name) || []
728
+ members.each do |member|
729
+ source = source_of(member).strip
730
+ member_name = class_member_label(member)
731
+ @local_bindings << LocalBinding.new(name: member_name, source: source)
732
+ end
733
+ end
734
+
735
+ def class_member_label(member)
736
+ key = member.child(:key)
737
+ return "<class member>" unless key
738
+
739
+ case key.type
740
+ when "Identifier" then key[:name]
741
+ when "StringLiteral" then key[:value]
742
+ else "<class member>"
743
+ end
744
+ end
745
+
746
+ # Pre-scan the render method body for `this.props.X` member access
747
+ # patterns. Each unique X becomes a synthesized IR::Prop entry on the
748
+ # component, so the generated class emits a matching `initialize(x:)`
749
+ # and the translator (which sees `@x`) resolves cleanly. Without this
750
+ # scan, render references would land in `unresolved_identifiers` and
751
+ # the generated initializer would be empty.
752
+ def absorb_class_render_props(render_method)
753
+ body = render_method.child(:body)
754
+ return [] unless body
755
+
756
+ prop_names = []
757
+ scan_this_props(body, prop_names)
758
+ prop_names.uniq.map { |name| Prop.new(name: name, default: nil, alias_name: nil) }
759
+ end
760
+
761
+ def scan_this_props(node, accumulator)
762
+ return unless node.is_a?(AST::Node)
763
+
764
+ if node.of_type?("MemberExpression") && this_props_access?(node)
765
+ accumulator << node[:property][:name]
766
+ elsif node.of_type?("VariableDeclarator") && this_props_destructure?(node)
767
+ destructured_names_of(node[:id]).each { |name| accumulator << name }
768
+ end
769
+ node.each_child { |child| scan_this_props(child, accumulator) }
770
+ end
771
+
772
+ # Match `this.props.X` exactly — `this.props` member access where the
773
+ # property side is also a MemberExpression. We don't follow deeper
774
+ # chains here; only the immediate `.X` after `.props` becomes a prop
775
+ # name. `this.props.foo.bar` still yields prop name `foo`.
776
+ def this_props_access?(member_expr)
777
+ object = member_expr.child(:object)
778
+ return false unless AST::Node.matches?(object, "MemberExpression")
779
+ return false unless AST::Node.matches?(object.child(:object), "ThisExpression")
780
+
781
+ object_prop = object.child(:property)
782
+ AST::Node.matches?(object_prop, "Identifier") && object_prop[:name] == "props"
783
+ end
784
+
785
+ # `const { foo, bar } = this.props;` — destructure off this.props. Each
786
+ # destructured name becomes a prop. We don't need to walk the rest of
787
+ # the chain because the destructure consumes one level of `.props`.
788
+ def this_props_destructure?(declarator)
789
+ init = declarator[:init]
790
+ return false unless AST::Node.matches?(init, "MemberExpression")
791
+ return false unless AST::Node.matches?(init.child(:object), "ThisExpression")
792
+ return false unless destructure_pattern?(declarator[:id])
793
+
794
+ prop = init.child(:property)
795
+ AST::Node.matches?(prop, "Identifier") && prop[:name] == "props"
796
+ end
797
+
267
798
  def extract_arrow_components(variable_declaration)
268
799
  variable_declaration[:declarations].filter_map do |declarator|
269
800
  init = declarator[:init]
270
801
  next nil unless init.is_a?(AST::Node)
271
- next nil unless %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
272
802
 
273
803
  name = declarator[:id]&.[](:name)
274
- name ? [name, init] : nil
804
+ next nil unless name
805
+
806
+ if %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
807
+ [name, init]
808
+ elsif (unwrapped = unwrap_hoc(init))
809
+ record_hoc_wrappers(name, unwrapped[:wrappers])
810
+ [name, unwrapped[:function]]
811
+ end
275
812
  end
276
813
  end
277
814
 
815
+ # Peer through a CallExpression initializer to find an inline
816
+ # function/arrow argument that's the real component definition.
817
+ # Recurses through nested wrappers so `memo(forwardRef(fn))` flattens
818
+ # to `["memo", "forwardRef"]` + the innermost `fn`. Returns
819
+ # `{ function: AST, wrappers: [String] }` or nil when no unwrap
820
+ # applies (the initializer is a CallExpression but doesn't match
821
+ # a known wrapper shape).
822
+ def unwrap_hoc(node)
823
+ wrappers = []
824
+ current = node
825
+ while current.is_a?(AST::Node) && current.type == "CallExpression"
826
+ callee_name = hoc_callee_name(current[:callee])
827
+ break unless callee_name
828
+
829
+ inner = current[:arguments].first
830
+ break unless inner.is_a?(AST::Node)
831
+
832
+ wrappers << callee_name
833
+ if %w[ArrowFunctionExpression FunctionExpression].include?(inner.type)
834
+ return { function: inner, wrappers: wrappers }
835
+ end
836
+
837
+ current = inner
838
+ end
839
+ nil
840
+ end
841
+
842
+ # Extract the wrapper name from a CallExpression callee. Accepts the
843
+ # bare form (`memo(...)`) and the React-namespace form (`React.memo(...)`).
844
+ # Returns the local name ("memo") in both cases so the wrapper TODO
845
+ # text doesn't have to handle both spellings.
846
+ def hoc_callee_name(callee)
847
+ return nil unless callee.is_a?(AST::Node)
848
+
849
+ name =
850
+ case callee.type
851
+ when "Identifier"
852
+ callee[:name]
853
+ when "MemberExpression"
854
+ object = callee[:object]
855
+ property = callee[:property]
856
+ return nil unless object.is_a?(AST::Node) && object.of_type?("Identifier")
857
+ return nil unless object[:name] == "React"
858
+ return nil unless property.is_a?(AST::Node) && property.of_type?("Identifier")
859
+
860
+ property[:name]
861
+ end
862
+ name if name && HOC_WRAPPER_NAMES.include?(name)
863
+ end
864
+
865
+ def record_hoc_wrappers(name, wrappers)
866
+ @component_hoc_wrappers ||= {}
867
+ @component_hoc_wrappers[name] = wrappers
868
+ end
869
+
278
870
  def lower_component(name, function)
279
871
  if name.nil? || name.empty?
280
872
  raise lowering_error("anonymous component functions are not supported", node: function)
281
873
  end
282
874
 
283
- props, rest_prop_name = lower_params(function[:params])
875
+ reset_per_component_state!
876
+ hoc_wrappers = (@component_hoc_wrappers || {})[name] || []
877
+ # forwardRef's inner function is `(props, ref) => …` — the second
878
+ # param has no Rails analog, so drop it before lower_params sees
879
+ # it (otherwise it'd land as a `ref:` ivar with no use site).
880
+ params_for_lowering = drop_forward_ref_param(function[:params], hoc_wrappers)
881
+ props, rest_prop_name = lower_params(params_for_lowering)
284
882
  @prop_names = props.map(&:name)
285
- @local_bindings = []
286
- @local_binding_names = []
287
- @local_arrows = {}
288
- @local_polymorphic_tags = {}
289
- @local_destructures = {}
290
- @stimulus_methods = []
291
- @stimulus_seen_names = {}
292
- @react_hooks = []
293
-
294
- body = lower_function_body(function[:body])
295
-
883
+ absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
884
+
885
+ body, mode = lower_component_body(function)
886
+ # Unconsumed local arrows (`const handleClick = () => ...` that didn't
887
+ # become a Stimulus method or a render-method) are still real bindings
888
+ # in the source. Add their names here so the translator treats use
889
+ # sites as known-unresolvable — `on_click: handle_click` (NameError)
890
+ # becomes `on_click: nil` (file loads, marker visible upstream).
891
+ unconsumed_arrow_names = @local_arrows.keys
296
892
  Component.new(
297
893
  name: name,
298
894
  props: props,
299
895
  body: body,
300
896
  rest_prop_name: rest_prop_name,
301
897
  local_bindings: @local_bindings,
302
- local_binding_names: @local_binding_names.uniq,
898
+ local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
303
899
  module_bindings: [],
900
+ module_imports: [],
304
901
  stimulus_methods: @stimulus_methods,
305
- react_hooks: @react_hooks
902
+ react_hooks: @react_hooks,
903
+ render_methods: @render_methods,
904
+ mode: mode,
905
+ server_data_source: nil,
906
+ hoc_wrappers: hoc_wrappers
306
907
  )
307
908
  end
308
909
 
910
+ def drop_forward_ref_param(params, wrappers)
911
+ return params unless wrappers.include?("forwardRef") && params.size > 1
912
+
913
+ params[0..-2]
914
+ end
915
+
916
+ # A "data factory" function — common for AG-Grid / antd column
917
+ # descriptor modules — is a function whose body just returns an array
918
+ # of object literals (`export const createColumns = (...) => [{...},
919
+ # {...}]`). When we recognize this shape, we lower the body via the
920
+ # recursive ObjectLiteral/ArrayLiteral path (Gap H) and let the
921
+ # backend emit a snake_case method that returns the data, instead of
922
+ # a `view_template`. JSX inside object properties still extracts to
923
+ # private methods on the class via the IR::Lambda extraction.
924
+ def lower_component_body(function)
925
+ factory_array = extract_data_factory_array(function)
926
+ return [lower_value_expression(factory_array), :data_factory] if factory_array
927
+
928
+ [lower_function_body(function[:body]), :view]
929
+ end
930
+
931
+ def extract_data_factory_array(function)
932
+ body = function[:body]
933
+ return nil unless body.is_a?(AST::Node)
934
+
935
+ # Implicit-return arrow: body IS the ArrayExpression.
936
+ return body if data_factory_candidate_array?(body)
937
+
938
+ return nil unless body.of_type?("BlockStatement")
939
+
940
+ return_stmt = body[:body].last
941
+ return nil unless AST::Node.matches?(return_stmt, "ReturnStatement")
942
+
943
+ arg = return_stmt[:argument]
944
+ data_factory_candidate_array?(arg) ? arg : nil
945
+ end
946
+
947
+ def data_factory_candidate_array?(node)
948
+ return false unless AST::Node.matches?(node, "ArrayExpression")
949
+
950
+ # At least one element should be an object literal — otherwise
951
+ # this is probably a primitive list, which doesn't warrant the
952
+ # extra emission machinery and can stay as a regular
953
+ # `body_returns_jsx?` rejection.
954
+ node[:elements].any? { |el| AST::Node.matches?(el, "ObjectExpression") }
955
+ end
956
+
957
+ def reset_per_component_state!
958
+ @local_bindings = []
959
+ @local_binding_names = []
960
+ @local_arrows = {}
961
+ @local_polymorphic_tags = {}
962
+ @local_destructures = {}
963
+ @stimulus_methods = []
964
+ @stimulus_seen_names = {}
965
+ @react_hooks = []
966
+ @render_methods = []
967
+ @render_method_seen = {}
968
+ end
969
+
970
+ def absorb_class_metadata(name, render_method, props)
971
+ absorb_class_other_members(name)
972
+ props.concat(absorb_class_render_props(render_method))
973
+ @prop_names = props.map(&:name)
974
+ end
975
+
309
976
  def lower_params(params)
310
977
  return [[], nil] if params.nil? || params.empty?
311
978
 
979
+ # Multi-positional params — typical for data-factory functions
980
+ # like `createColumns(token, sortedInfo)` and lowercase JSX-helpers
981
+ # like `getAlertIcon(level, status, token, isSnoozed = false)`.
982
+ # Each becomes a Prop with no default. We support Identifier and
983
+ # `AssignmentPattern` (default values get dropped — translating
984
+ # JS defaults to Ruby isn't worth the risk here). Other shapes
985
+ # in a multi-param signature fall through to legacy first-param-
986
+ # only handling so we don't regress files that used to translate.
987
+ if params.size > 1 && params.all? { |p| multi_param_supported?(p) }
988
+ return [params.map { |p| Prop.new(name: multi_param_name(p), default: nil, alias_name: nil) }, nil]
989
+ end
990
+
312
991
  first_param = params.first
313
992
  case first_param.type
314
993
  when "ObjectPattern"
315
994
  lower_object_pattern_params(first_param)
316
995
  when "Identifier"
317
- [[Prop.new(name: first_param[:name], default: nil)], nil]
996
+ [[Prop.new(name: first_param[:name], default: nil, alias_name: nil)], nil]
318
997
  else
319
998
  raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
320
999
  end
321
1000
  end
322
1001
 
1002
+ def multi_param_supported?(param)
1003
+ return true if AST::Node.matches?(param, "Identifier")
1004
+
1005
+ AST::Node.matches?(param, "AssignmentPattern") && AST::Node.matches?(param[:left], "Identifier")
1006
+ end
1007
+
1008
+ def multi_param_name(param)
1009
+ param.type == "Identifier" ? param[:name] : param[:left][:name]
1010
+ end
1011
+
323
1012
  def lower_object_pattern_params(pattern)
324
1013
  props = []
325
1014
  rest_name = nil
@@ -347,7 +1036,20 @@ module JsxRosetta
347
1036
  # `nil # TODO: ...`. The trailing `#` comment inside a method
348
1037
  # parameter list swallows the closing `)` and breaks Ruby syntax.
349
1038
  default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
350
- Prop.new(name: prop_name, default: default)
1039
+ alias_name = destructure_alias_for(prop_name, value)
1040
+ Prop.new(name: prop_name, default: default, alias_name: alias_name)
1041
+ end
1042
+
1043
+ # `{ "data-testid": dataTestId }` — extract the alias so use sites of
1044
+ # `dataTestId` resolve to the prop's `@data_testid` ivar instead of
1045
+ # leaking as a bare snake_case ref. Returns nil for the non-aliased
1046
+ # case (`{ loading }` — key.name == value.name).
1047
+ def destructure_alias_for(prop_name, value)
1048
+ target = value.type == "AssignmentPattern" ? value[:left] : value
1049
+ return nil unless AST::Node.matches?(target, "Identifier")
1050
+ return nil if target[:name] == prop_name
1051
+
1052
+ target[:name]
351
1053
  end
352
1054
 
353
1055
  def lower_function_body(body)
@@ -614,29 +1316,54 @@ module JsxRosetta
614
1316
  init = declarator[:init]
615
1317
  return unless init.is_a?(AST::Node)
616
1318
 
617
- is_hook = hook_call?(init)
618
- @react_hooks << ReactHookCall.new(hook: init[:callee][:name], source: source_of(stmt).strip) if is_hook
1319
+ # `const { foo, bar } = this.props` — destructured names already
1320
+ # got synthesized into `props:` by absorb_class_render_props at
1321
+ # class-component setup time. Skip the LocalBinding TODO + the
1322
+ # local_binding_names capture so the translator picks up the prop
1323
+ # form (`@foo`) instead of emitting a `nil` placeholder.
1324
+ return if this_props_destructure?(declarator)
619
1325
 
620
- id_node = declarator[:id]
621
- if destructure_pattern?(id_node)
622
- # Capture destructured names so the ExpressionTranslator recognizes
623
- # them as known-local bindings and emits a `nil` placeholder
624
- # instead of a bare unresolved reference (which NameErrors at
625
- # render time). Hook destructures (`const [open, setOpen] = useState(0)`)
626
- # contribute names but not a separate LocalBinding TODO — the
627
- # hook's source already shows the binding to the reviewer.
628
- # Also track member-expression destructures (Gap J) so
629
- # `const { Content } = Layout` lets `<Content/>` resolve to
630
- # `Layout::Content`.
631
- record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
632
- return
633
- end
1326
+ library = hook_library_for(init)
1327
+ record_hook_call(stmt, init, library) if library
634
1328
 
635
- return if is_hook
1329
+ id_node = declarator[:id]
1330
+ return handle_destructure_binding(stmt, declarator, init, seen, !library.nil?) if destructure_pattern?(id_node)
1331
+ return handle_identifier_hook_binding(id_node) if library
636
1332
 
637
1333
  name = id_node&.[](:name)
638
1334
  return unless name
639
1335
 
1336
+ dispatch_identifier_binding(stmt, init, name, seen)
1337
+ end
1338
+
1339
+ def record_hook_call(stmt, call_expression, library)
1340
+ @react_hooks << ReactHookCall.new(
1341
+ hook: call_expression[:callee][:name],
1342
+ source: source_of(stmt).strip,
1343
+ library: library,
1344
+ operation: apollo_operation_name(call_expression, library)
1345
+ )
1346
+ end
1347
+
1348
+ # `const [a, b] = ...` or `const { a, b } = ...`. Capture every bound
1349
+ # name so the translator recognizes them as known locals. Hook
1350
+ # destructures (`const [open, setOpen] = useState(0)`) contribute
1351
+ # names but not a separate LocalBinding TODO — the hook's source
1352
+ # already shows the binding to the reviewer.
1353
+ def handle_destructure_binding(stmt, declarator, init, seen, is_hook)
1354
+ record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
1355
+ end
1356
+
1357
+ # Identifier-bound hook result (`const handleChange = useCallback(...)`).
1358
+ # The hook source is already in @react_hooks; just mark the binding
1359
+ # name as known-local so use sites translate to `nil` instead of a
1360
+ # bare snake_case ref that NameErrors at render time.
1361
+ def handle_identifier_hook_binding(id_node)
1362
+ name = id_node&.[](:name)
1363
+ @local_binding_names << name if name
1364
+ end
1365
+
1366
+ def dispatch_identifier_binding(stmt, init, name, seen)
640
1367
  case init.type
641
1368
  when "JSXElement", "JSXFragment"
642
1369
  @local_jsx[name] = init
@@ -717,16 +1444,50 @@ module JsxRosetta
717
1444
 
718
1445
  def detect_bare_hook_call(stmt)
719
1446
  expr = stmt.child(:expression)
720
- return unless expr&.of_type?("CallExpression") && hook_call?(expr)
1447
+ return unless expr&.of_type?("CallExpression")
1448
+
1449
+ library = hook_library_for(expr)
1450
+ return unless library
721
1451
 
722
- @react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
1452
+ record_hook_call(stmt, expr, library)
723
1453
  end
724
1454
 
725
1455
  def hook_call?(call_expression)
726
- return false unless call_expression.of_type?("CallExpression")
1456
+ !hook_library_for(call_expression).nil?
1457
+ end
1458
+
1459
+ # Resolve a CallExpression to the library whose hook set its callee
1460
+ # belongs to (`:react`, `:apollo`, `:next_js`), or nil when it isn't
1461
+ # a recognized hook invocation. Lookup is by bare-Identifier callee
1462
+ # only — member-expression callees (`Apollo.useQuery`) aren't
1463
+ # recognized; we follow what production code actually writes.
1464
+ def hook_library_for(call_expression)
1465
+ return nil unless call_expression.is_a?(AST::Node) && call_expression.of_type?("CallExpression")
727
1466
 
728
1467
  callee = call_expression.child(:callee)
729
- callee&.of_type?("Identifier") && REACT_HOOKS.include?(callee[:name])
1468
+ return nil unless callee&.of_type?("Identifier")
1469
+
1470
+ name = callee[:name]
1471
+ FRAMEWORK_HOOKS_BY_LIBRARY.each do |library, names|
1472
+ return library if names.include?(name)
1473
+ end
1474
+ nil
1475
+ end
1476
+
1477
+ # For Apollo's document-first hooks (`useQuery(GET_USERS, ...)`),
1478
+ # extract the operation name from a bare-Identifier first argument
1479
+ # so the backend can echo it in the TODO. Returns nil for inline
1480
+ # documents (`gql\`...\``), member-expression args, or non-Apollo
1481
+ # hooks — the caller already has the verbatim source in `source`,
1482
+ # which surfaces those cases to the reviewer.
1483
+ def apollo_operation_name(call_expression, library)
1484
+ return nil unless library == :apollo
1485
+
1486
+ args = call_expression[:arguments]
1487
+ first_arg = args.is_a?(Array) ? args.first : nil
1488
+ return nil unless AST::Node.matches?(first_arg, "Identifier")
1489
+
1490
+ first_arg[:name]
730
1491
  end
731
1492
 
732
1493
  # Recognize the asChild-style polymorphic tag pattern:
@@ -782,14 +1543,82 @@ module JsxRosetta
782
1543
  # Gap J: `const { Content } = Layout; <Content/>` should resolve
783
1544
  # to `Layout::Content`, not a bare `ContentComponent`.
784
1545
  ComponentInvocation.new(name: "#{parent}.#{tag}", props: attributes, children: children)
1546
+ elsif next_js_layout_yield?(tag, attributes)
1547
+ # `<Component {...pageProps} />` — the canonical Next.js _app
1548
+ # content slot. Lowering to LayoutYield lets the Phlex backend
1549
+ # emit `yield` (Rails layout convention) instead of trying to
1550
+ # render the prop verbatim.
1551
+ LayoutYield.new
785
1552
  elsif html_element?(tag)
786
1553
  Element.new(tag: tag, attributes: attributes, children: children)
1554
+ elsif (radix = radix_primitive_for(tag))
1555
+ # `<SeparatorPrimitive.Root .../>` (imported from radix-ui) lowers
1556
+ # to a plain `<div role="separator">` so the consumer doesn't have
1557
+ # to define a Components::SeparatorPrimitive::Root shim.
1558
+ Element.new(
1559
+ tag: radix[:tag],
1560
+ attributes: merge_radix_attrs(radix[:attrs], attributes),
1561
+ children: children
1562
+ )
787
1563
  else
788
1564
  ComponentInvocation.new(name: tag, props: attributes, children: children)
789
1565
  end
790
1566
  end
791
1567
 
1568
+ # Returns the Radix registry entry for `<LocalName.Member />` when:
1569
+ # - the tag is a two-segment member chain
1570
+ # - the root segment was imported from a Radix-shaped package
1571
+ # - the (LocalName, Member) pair is in the registry
1572
+ # Otherwise nil — the caller falls through to a ComponentInvocation.
1573
+ def radix_primitive_for(tag)
1574
+ segments = tag.split(".")
1575
+ return nil if segments.length != 2
1576
+
1577
+ local, member = segments
1578
+ return nil unless imported_from_radix?(local)
1579
+
1580
+ RadixRegistry.lookup(local, member)
1581
+ end
1582
+
1583
+ def imported_from_radix?(local_name)
1584
+ @module_imports.any? do |imp|
1585
+ imp.name == local_name && RADIX_SOURCE_PATTERN.match?(imp.source)
1586
+ end
1587
+ end
1588
+
1589
+ # Combine the registry's fixed attrs (role, type, etc.) with the
1590
+ # consumer's own JSX attributes. Consumer attrs win on collision — the
1591
+ # JSX is the source of truth; the registry just supplies safe defaults.
1592
+ # Collision keys normalize away case + hyphens/underscores so future
1593
+ # registry entries like `data-state` don't slip past a consumer's
1594
+ # `dataState`.
1595
+ def merge_radix_attrs(fixed_attrs, jsx_attrs)
1596
+ user_keys = jsx_attrs.filter_map do |a|
1597
+ a.respond_to?(:name) ? normalize_attr_key(a.name) : nil
1598
+ end.to_set
1599
+ injected = fixed_attrs.filter_map do |name, value|
1600
+ attr_name = name.to_s
1601
+ next if user_keys.include?(normalize_attr_key(attr_name))
1602
+
1603
+ Attribute.new(name: attr_name, value: value.to_s)
1604
+ end
1605
+ injected + jsx_attrs
1606
+ end
1607
+
1608
+ def normalize_attr_key(name)
1609
+ name.to_s.downcase.tr("-_", "")
1610
+ end
1611
+
792
1612
  def lower_polymorphic_tag_use(poly, attributes, children)
1613
+ if (chosen = drop_slot_branch(poly))
1614
+ # The shadcn `<Comp asChild>` pattern routes through Radix's
1615
+ # Slot.Root, which has no Ruby class on the Phlex side. Drop the
1616
+ # Slot branch and render the underlying HTML/component branch
1617
+ # directly. Pass `--keep-slot` to preserve the conditional if
1618
+ # the consumer is shimming Slot::Root themselves.
1619
+ return build_polymorphic_branch(chosen, attributes, children)
1620
+ end
1621
+
793
1622
  Conditional.new(
794
1623
  test: Interpolation.new(expression: source_of(poly[:test])),
795
1624
  consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
@@ -806,6 +1635,41 @@ module JsxRosetta
806
1635
  end
807
1636
  end
808
1637
 
1638
+ # Returns the non-Slot branch when exactly one of the polymorphic
1639
+ # branches resolves to a Radix Slot reference (`Slot` or `Slot.Root`
1640
+ # rooted at a `radix-ui` import). Returns nil otherwise — including
1641
+ # when `--keep-slot` is in effect — so the caller emits the full
1642
+ # conditional unchanged.
1643
+ def drop_slot_branch(poly)
1644
+ return nil if @keep_slot
1645
+
1646
+ t = poly[:true_branch]
1647
+ f = poly[:false_branch]
1648
+ t_is_slot = radix_slot_branch?(t)
1649
+ f_is_slot = radix_slot_branch?(f)
1650
+ return f if t_is_slot && !f_is_slot
1651
+ return t if f_is_slot && !t_is_slot
1652
+
1653
+ nil
1654
+ end
1655
+
1656
+ # True iff `branch` references a Slot import from a Radix-shaped
1657
+ # package. The local binding is one of {`Slot`, `SlotPrimitive`} —
1658
+ # both correspond to the canonical "import from radix-ui / @radix-ui/
1659
+ # react-slot" pattern. Anything else (e.g. a user-defined
1660
+ # `SlotMachine` from a random package whose path happens to contain
1661
+ # "radix") falls through and renders the conditional unchanged.
1662
+ def radix_slot_branch?(branch)
1663
+ return false unless branch[:kind] == :component
1664
+
1665
+ root = branch[:tag].split(".").first
1666
+ return false unless root && SLOT_LOCAL_NAME_PATTERN.match?(root)
1667
+
1668
+ @module_imports.any? do |imp|
1669
+ imp.name == root && RADIX_SOURCE_PATTERN.match?(imp.source)
1670
+ end
1671
+ end
1672
+
809
1673
  def lower_jsx_fragment(fragment)
810
1674
  Fragment.new(children: lower_children(fragment.jsx_children))
811
1675
  end
@@ -899,7 +1763,66 @@ module JsxRosetta
899
1763
 
900
1764
  def lower_call_expression(expression)
901
1765
  loop_node = try_lower_map_loop(expression)
902
- loop_node || Interpolation.new(expression: source_of(expression))
1766
+ return loop_node if loop_node
1767
+
1768
+ local_call = try_lower_local_arrow_call(expression)
1769
+ return local_call if local_call
1770
+
1771
+ Interpolation.new(expression: source_of(expression))
1772
+ end
1773
+
1774
+ # Recognize `{renderHeader()}` where `renderHeader` is a locally-bound
1775
+ # arrow whose body returns JSX. Extract the arrow as a RenderMethod
1776
+ # on the component and emit a LocalRenderCall at this use site so the
1777
+ # backend can call the generated method instead of dropping the
1778
+ # expression as "[untranslated: renderHeader()]". Args must be simple
1779
+ # identifiers (props, locals) since we don't translate arbitrary
1780
+ # argument expressions here — the backend's ExpressionTranslator
1781
+ # handles them via the Interpolation it sees.
1782
+ def try_lower_local_arrow_call(call_expression)
1783
+ match = local_arrow_call_match(call_expression)
1784
+ return nil unless match
1785
+
1786
+ # Consume the arrow so it doesn't ALSO get promoted to a Stimulus
1787
+ # method if it later appears in event-handler position.
1788
+ @local_arrows.delete(match[:callee_name])
1789
+
1790
+ method_name = unique_render_method_name(match[:callee_name])
1791
+ @render_methods << RenderMethod.new(
1792
+ name: method_name,
1793
+ params: match[:arrow][:params].map { |p| p[:name] },
1794
+ body: match[:body]
1795
+ )
1796
+
1797
+ LocalRenderCall.new(
1798
+ method_name: method_name,
1799
+ args: match[:args].map { |arg| Interpolation.new(expression: source_of(arg)) }
1800
+ )
1801
+ end
1802
+
1803
+ def local_arrow_call_match(call_expression)
1804
+ callee = call_expression.child(:callee)
1805
+ return nil unless callee&.of_type?("Identifier")
1806
+
1807
+ arrow = @local_arrows[callee[:name]]
1808
+ return nil unless arrow
1809
+ return nil unless arrow[:params].all? { |p| AST::Node.matches?(p, "Identifier") }
1810
+
1811
+ args = call_expression[:arguments]
1812
+ return nil if args.size != arrow[:params].size
1813
+ return nil unless args.all? { |a| AST::Node.matches?(a, "Identifier", "MemberExpression") }
1814
+
1815
+ body = lower_lambda_body(arrow[:body])
1816
+ return nil unless body
1817
+
1818
+ { callee_name: callee[:name], arrow: arrow, args: args, body: body }
1819
+ end
1820
+
1821
+ def unique_render_method_name(js_name)
1822
+ snake = AST::Inflector.underscore(js_name)
1823
+ @render_method_seen[snake] ||= 0
1824
+ @render_method_seen[snake] += 1
1825
+ @render_method_seen[snake] == 1 ? snake : "#{snake}_#{@render_method_seen[snake]}"
903
1826
  end
904
1827
 
905
1828
  def try_lower_map_loop(call_expression)
@@ -1061,21 +1984,33 @@ module JsxRosetta
1061
1984
  end
1062
1985
  return nil if property_name.nil?
1063
1986
 
1064
- value = lower_style_value(property[:value])
1987
+ value = lower_style_value(property[:value], property_name)
1065
1988
  return nil if value.nil?
1066
1989
 
1067
1990
  StyleDeclaration.new(property: property_name, value: value)
1068
1991
  end
1069
1992
 
1070
- def lower_style_value(value)
1993
+ def lower_style_value(value, property_name)
1071
1994
  case value.type
1072
1995
  when "StringLiteral" then value[:value]
1073
- when "NumericLiteral" then value[:value].to_s
1996
+ when "NumericLiteral" then numeric_style_value(value[:value], property_name)
1074
1997
  when "Identifier", "MemberExpression"
1075
1998
  Interpolation.new(expression: source_of(value))
1076
1999
  end
1077
2000
  end
1078
2001
 
2002
+ # Mirrors React's `isUnitlessNumber` table: properties that take bare
2003
+ # numbers (`zIndex: 5`) rather than lengths. Everything else gets a
2004
+ # `px` suffix appended when the JSX source provides a unitless number
2005
+ # — `marginBottom: 16` → `margin-bottom: 16px;`. Without this, the
2006
+ # browser silently ignores the declaration as invalid CSS.
2007
+ def numeric_style_value(number, property_name)
2008
+ return number.to_s if number.zero?
2009
+ return number.to_s if UNITLESS_CSS_PROPERTIES.include?(property_name)
2010
+
2011
+ "#{number}px"
2012
+ end
2013
+
1079
2014
  def css_property_from_camel(name)
1080
2015
  name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
1081
2016
  end
@@ -1084,10 +2019,116 @@ module JsxRosetta
1084
2019
  if value.is_a?(AST::JSXExpressionContainer)
1085
2020
  decomposed = try_lower_class_helper(value.expression)
1086
2021
  return decomposed if decomposed
2022
+
2023
+ cva_call = try_lower_cva_call_site(value.expression)
2024
+ return cva_call if cva_call
1087
2025
  end
1088
2026
  StyleBinding.new(expression: style_binding_expression(value))
1089
2027
  end
1090
2028
 
2029
+ # Recognize the cva call shape — `cn(<cvaName>({ axes }), <classArg>)`
2030
+ # or the bare `<cvaName>({ axes })` direct form — against a CvaBinding
2031
+ # captured during module-level lowering. Returns an IR::CvaCallSite,
2032
+ # or nil so the caller falls through to the generic StyleBinding.
2033
+ # AST-driven instead of regexing over verbatim source, which lets us
2034
+ # handle reversed-arg `cn(<classArg>, <cvaName>(...))`, the no-cn
2035
+ # direct form, and literal-pinned axes naturally.
2036
+ def try_lower_cva_call_site(expression)
2037
+ return nil unless expression.respond_to?(:type)
2038
+ return nil unless expression.type == "CallExpression"
2039
+
2040
+ callee = expression.child(:callee)
2041
+ return nil unless callee
2042
+
2043
+ if callee.of_type?("Identifier") && %w[cn clsx classnames].include?(callee[:name])
2044
+ build_cva_call_site_from_class_helper(expression[:arguments] || [])
2045
+ else
2046
+ build_cva_call_site_from_direct(expression)
2047
+ end
2048
+ end
2049
+
2050
+ # `cn(<cvaCall>, <classArg>)` or `cn(<classArg>, <cvaCall>)` — accept
2051
+ # the first argument that resolves to a known cva call; the remaining
2052
+ # argument (if any) becomes the optional `class_arg`. Anything more
2053
+ # complex (3+ args, nested cn, multiple cva calls) bails to nil.
2054
+ def build_cva_call_site_from_class_helper(args)
2055
+ return nil unless args.length.between?(1, 2)
2056
+
2057
+ cva_arg_index = args.find_index { |a| cva_call_against_known_binding?(a) }
2058
+ return nil unless cva_arg_index
2059
+
2060
+ cva_arg = args[cva_arg_index]
2061
+ class_arg = args.length == 2 ? args[1 - cva_arg_index] : nil
2062
+ build_cva_call_site_node(cva_arg, class_arg)
2063
+ end
2064
+
2065
+ # Bare `<cvaName>({ axes })` — same shape with no class_arg.
2066
+ def build_cva_call_site_from_direct(expression)
2067
+ return nil unless cva_call_against_known_binding?(expression)
2068
+
2069
+ build_cva_call_site_node(expression, nil)
2070
+ end
2071
+
2072
+ def build_cva_call_site_node(cva_call, class_arg_node)
2073
+ callee_name = cva_call[:callee][:name]
2074
+ options = cva_call[:arguments]&.first
2075
+ return nil unless options && options.type == "ObjectExpression"
2076
+
2077
+ axes = options[:properties].filter_map { |prop| build_cva_axis_pair(prop) }
2078
+ class_arg = class_arg_node && Interpolation.new(expression: source_of(class_arg_node))
2079
+ CvaCallSite.new(binding_name: callee_name, axes: axes, class_arg: class_arg)
2080
+ end
2081
+
2082
+ # Pull one axis-value pair off the cva options object. Shorthand
2083
+ # (`{ variant }`) and explicit (`{ variant: someExpr }`) both work;
2084
+ # spread (`{ ...rest }`) and computed keys bail to nil so the call
2085
+ # site falls through to the generic translator with a TODO.
2086
+ def build_cva_axis_pair(prop)
2087
+ return nil unless prop.type == "ObjectProperty"
2088
+
2089
+ axis = property_key_name(prop)
2090
+ return nil unless axis
2091
+
2092
+ value_node = prop[:value]
2093
+ kind, source = classify_cva_axis_value(value_node)
2094
+ CvaAxisPair.new(axis: axis, kind: kind, source: source)
2095
+ end
2096
+
2097
+ def property_key_name(prop)
2098
+ case prop[:key].type
2099
+ when "Identifier" then prop[:key][:name]
2100
+ when "StringLiteral" then prop[:key][:value]
2101
+ end
2102
+ end
2103
+
2104
+ def classify_cva_axis_value(node)
2105
+ case node.type
2106
+ when "StringLiteral" then [:literal_string, node[:value]]
2107
+ when "NumericLiteral", "BooleanLiteral" then [:literal_other, source_of(node)]
2108
+ when "NullLiteral" then [:literal_nil, nil]
2109
+ when "Identifier"
2110
+ # Shorthand `{ variant }` and explicit `{ variant: ident }` both
2111
+ # land here; the source is the identifier name itself.
2112
+ node[:name] == "undefined" ? [:literal_nil, nil] : [:prop_ref, node[:name]]
2113
+ else
2114
+ # Member chains, calls, etc. — pass the source through as a
2115
+ # raw expression. The backend re-translates it through
2116
+ # ExpressionTranslator like any other prop reference.
2117
+ [:prop_ref, source_of(node)]
2118
+ end
2119
+ end
2120
+
2121
+ def cva_call_against_known_binding?(node)
2122
+ return false unless node.respond_to?(:type)
2123
+ return false unless node.type == "CallExpression"
2124
+
2125
+ callee = node.child(:callee)
2126
+ return false unless callee&.of_type?("Identifier")
2127
+
2128
+ binding_name = callee[:name]
2129
+ @module_bindings.any? { |b| b.is_a?(CvaBinding) && b.name == binding_name }
2130
+ end
2131
+
1091
2132
  def try_lower_class_helper(expression)
1092
2133
  return nil unless AST::Node.matches?(expression, "CallExpression")
1093
2134
 
@@ -1168,9 +2209,18 @@ module JsxRosetta
1168
2209
  def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
1169
2210
  base = name_hint || default_stimulus_method_name(attr_name)
1170
2211
  method_name = stimulus_method_name(base)
1171
- body_source = source_of(arrow_node[:body])
2212
+ body_node = arrow_node[:body]
2213
+ body_source = source_of(body_node)
2214
+ # Preserve nil entries for non-Identifier params (ObjectPattern,
2215
+ # ArrayPattern, RestElement) so emit-time bails to TODO rather
2216
+ # than pasting a body that references undefined locals.
2217
+ params = Array(arrow_node[:params]).map { |p| p.type == "Identifier" ? p[:name] : nil }
1172
2218
  @stimulus_methods << StimulusMethod.new(
1173
- name: method_name, body_source: body_source, original_name: base
2219
+ name: method_name,
2220
+ body_source: body_source,
2221
+ original_name: base,
2222
+ params: params,
2223
+ body_is_block: body_node.type == "BlockStatement"
1174
2224
  )
1175
2225
  @local_arrows.delete(name_hint) if name_hint
1176
2226
  StimulusBinding.new(event: event, method_name: method_name)
@@ -1184,7 +2234,11 @@ module JsxRosetta
1184
2234
  method_name = stimulus_method_name(identifier_name)
1185
2235
  body_source = "// originally bound to: #{identifier_name}"
1186
2236
  @stimulus_methods << StimulusMethod.new(
1187
- name: method_name, body_source: body_source, original_name: identifier_name
2237
+ name: method_name,
2238
+ body_source: body_source,
2239
+ original_name: identifier_name,
2240
+ params: [],
2241
+ body_is_block: false
1188
2242
  )
1189
2243
  StimulusBinding.new(event: event, method_name: method_name)
1190
2244
  end
@@ -1215,18 +2269,39 @@ module JsxRosetta
1215
2269
  # Recursively lower an arbitrary JS expression into structured IR
1216
2270
  # when possible: ObjectExpression → ObjectLiteral, ArrayExpression
1217
2271
  # → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
1218
- # Lambda. Everything else falls back to the verbatim Interpolation
1219
- # so simpler ExpressionTranslator paths still get a crack at it.
2272
+ # Lambda, JSXElement / JSXFragment the same Element / Fragment /
2273
+ # ComponentInvocation that JSX children lower to. Everything else
2274
+ # falls back to the verbatim Interpolation so simpler
2275
+ # ExpressionTranslator paths still get a crack at it.
1220
2276
  def lower_value_expression(expression)
1221
2277
  case expression.type
1222
2278
  when "ObjectExpression" then lower_object_literal(expression)
1223
2279
  when "ArrayExpression" then lower_array_literal(expression)
1224
2280
  when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
2281
+ when "JSXElement", "JSXFragment" then lower_jsx_value(expression)
1225
2282
  else
1226
2283
  Interpolation.new(expression: source_of(expression))
1227
2284
  end
1228
2285
  end
1229
2286
 
2287
+ # JSX appearing in a non-child position — typically as an attribute
2288
+ # value (`icon={<Foo/>}`, `fallback={<Loading/>}`). Lowered through
2289
+ # the same `lower_jsx` pipeline as children, so the resulting IR
2290
+ # node (Element / ComponentInvocation / Fragment) carries all the
2291
+ # structure the backend can use to emit a real Phlex render call
2292
+ # in place of the old "[untranslated: …]" drop. Single-child
2293
+ # Fragments (`<><Foo/></>`, often emitted by React idioms or as a
2294
+ # workaround for "one node required") collapse to the inner child
2295
+ # so the kwarg value isn't an awkwardly-wrapped Fragment.
2296
+ def lower_jsx_value(expression)
2297
+ lowered = lower_jsx(expression)
2298
+ if lowered.is_a?(Fragment) && lowered.children.length == 1
2299
+ lowered.children.first
2300
+ else
2301
+ lowered
2302
+ end
2303
+ end
2304
+
1230
2305
  def lower_object_literal(object_expression)
1231
2306
  properties = []
1232
2307
  object_expression[:properties].each do |prop|
@@ -1268,10 +2343,16 @@ module JsxRosetta
1268
2343
  return Interpolation.new(expression: source_of(arrow))
1269
2344
  end
1270
2345
 
2346
+ param_names = params.map { |p| p[:name] }
1271
2347
  body = lower_lambda_body(arrow[:body])
1272
- return Interpolation.new(expression: source_of(arrow)) unless body
2348
+ # JSX-bearing body → render lambda (translated to a Phlex render
2349
+ # method). Non-JSX body → opaque event handler (the body becomes
2350
+ # a verbatim TODO inside a stub method on the class). Without
2351
+ # this fork, every `onClick={() => doX()}` on a PascalCase tag
2352
+ # used to bail to Interpolation and drop with a TODO.
2353
+ return Lambda.new(params: param_names, body: body) if body
1273
2354
 
1274
- Lambda.new(params: params.map { |p| p[:name] }, body: body)
2355
+ EventHandler.new(params: param_names, body_source: source_of(arrow[:body]))
1275
2356
  end
1276
2357
 
1277
2358
  def lower_lambda_body(body)
@@ -1302,6 +2383,19 @@ module JsxRosetta
1302
2383
  first == first.downcase
1303
2384
  end
1304
2385
 
2386
+ # `<Component {...pageProps} />` — Next.js _app content slot. Tag must
2387
+ # be exactly "Component" and the props must include a spread of
2388
+ # "pageProps". Strict shape match keeps this from false-firing on
2389
+ # ordinary code that happens to render a prop-named `<Component>` —
2390
+ # the `{...pageProps}` spread is the unambiguous Next.js signal.
2391
+ def next_js_layout_yield?(tag, attributes)
2392
+ return false unless tag == "Component"
2393
+
2394
+ attributes.any? do |attr|
2395
+ attr.is_a?(SpreadAttribute) && attr.expression.strip == "pageProps"
2396
+ end
2397
+ end
2398
+
1305
2399
  def source_of(node)
1306
2400
  @source[node.start_pos...node.end_pos]
1307
2401
  end