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
@@ -55,12 +55,12 @@ module JsxRosetta
55
55
  end
56
56
  end
57
57
 
58
- def self.lower(file, source:)
59
- new(source).lower_file(file)
58
+ def self.lower(file, source:, keep_slot: false)
59
+ new(source, keep_slot: keep_slot).lower_file(file)
60
60
  end
61
61
 
62
- def self.lower_all(file, source:)
63
- 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)
64
64
  end
65
65
 
66
66
  REACT_HOOKS = %w[
@@ -126,6 +126,14 @@ module JsxRosetta
126
126
  "LogicalExpression" => ->(n) { [n[:left], n[:right]] }
127
127
  }.freeze
128
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
+
129
137
  SHAPE_MESSAGES = {
130
138
  hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
131
139
  "this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
@@ -146,8 +154,14 @@ module JsxRosetta
146
154
  unknown: nil
147
155
  }.freeze
148
156
 
149
- def initialize(source)
157
+ def initialize(source, keep_slot: false)
150
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
151
165
  @prop_names = []
152
166
  @local_jsx = {}
153
167
  @local_bindings = []
@@ -160,6 +174,11 @@ module JsxRosetta
160
174
  @react_hooks = []
161
175
  @render_methods = []
162
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 = []
163
182
  # Class-component non-render members (constructor, lifecycle hooks,
164
183
  # custom handlers). Keyed by class name; populated by
165
184
  # extract_class_component, drained by lower_component to surface
@@ -172,17 +191,63 @@ module JsxRosetta
172
191
  raise no_component_error(file.program) if candidates.empty?
173
192
 
174
193
  name, function = candidates.first
175
- module_bindings = capture_module_bindings(file.program, candidates)
176
- 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)
177
199
  end
178
200
 
179
201
  def lower_all_components(file)
180
202
  candidates = find_component_functions(file.program)
181
203
  raise no_component_error(file.program) if candidates.empty?
182
204
 
183
- module_bindings = capture_module_bindings(file.program, candidates)
184
- candidates.map do |name, function|
185
- 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])
186
251
  end
187
252
  end
188
253
 
@@ -206,32 +271,300 @@ module JsxRosetta
206
271
  case stmt.type
207
272
  when "VariableDeclaration"
208
273
  stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
209
- when "ExportNamedDeclaration"
274
+ when "FunctionDeclaration"
275
+ record_module_function_binding(stmt, component_names, bindings)
276
+ when "ExportNamedDeclaration", "ExportDefaultDeclaration"
210
277
  decl = stmt[:declaration]
211
278
  walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
212
279
  end
213
280
  end
214
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
+
215
297
  def record_module_binding(stmt, declarator, component_names, bindings)
216
298
  init = declarator[:init]
217
299
  return unless init.is_a?(AST::Node)
218
300
 
219
- # Component declarators (`const Foo = () => ...`) are handled by
220
- # the component pipeline; skip them here so the source doesn't
221
- # show up twice.
222
- return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
223
- component_names.include?(declarator[:id]&.[](:name))
224
-
225
301
  name = declarator[:id]&.[](:name)
226
302
  return unless name
227
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
+
228
329
  bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
229
330
  end
230
331
 
231
- def attach_module_bindings(component, module_bindings)
232
- 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"
233
406
 
234
- component.with(module_bindings: module_bindings)
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
510
+
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
235
568
  end
236
569
 
237
570
  private
@@ -317,10 +650,29 @@ module JsxRosetta
317
650
  when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
318
651
  when "VariableDeclaration" then extract_arrow_components(declaration)
319
652
  when "ClassDeclaration" then extract_class_component(declaration)
653
+ when "CallExpression" then extract_hoc_default_export(declaration)
320
654
  else []
321
655
  end
322
656
  end
323
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
+
324
676
  # Recognize a class component by the presence of a `render()` method.
325
677
  # We don't require `extends React.Component` because TypeScript codebases
326
678
  # often declare the parent via an `extends` of a typed alias. The render
@@ -447,20 +799,86 @@ module JsxRosetta
447
799
  variable_declaration[:declarations].filter_map do |declarator|
448
800
  init = declarator[:init]
449
801
  next nil unless init.is_a?(AST::Node)
450
- next nil unless %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
451
802
 
452
803
  name = declarator[:id]&.[](:name)
453
- 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
454
812
  end
455
813
  end
456
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
+
457
870
  def lower_component(name, function)
458
871
  if name.nil? || name.empty?
459
872
  raise lowering_error("anonymous component functions are not supported", node: function)
460
873
  end
461
874
 
462
875
  reset_per_component_state!
463
- props, rest_prop_name = lower_params(function[:params])
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)
464
882
  @prop_names = props.map(&:name)
465
883
  absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
466
884
 
@@ -479,13 +897,22 @@ module JsxRosetta
479
897
  local_bindings: @local_bindings,
480
898
  local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
481
899
  module_bindings: [],
900
+ module_imports: [],
482
901
  stimulus_methods: @stimulus_methods,
483
902
  react_hooks: @react_hooks,
484
903
  render_methods: @render_methods,
485
- mode: mode
904
+ mode: mode,
905
+ server_data_source: nil,
906
+ hoc_wrappers: hoc_wrappers
486
907
  )
487
908
  end
488
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
+
489
916
  # A "data factory" function — common for AG-Grid / antd column
490
917
  # descriptor modules — is a function whose body just returns an array
491
918
  # of object literals (`export const createColumns = (...) => [{...},
@@ -1116,14 +1543,82 @@ module JsxRosetta
1116
1543
  # Gap J: `const { Content } = Layout; <Content/>` should resolve
1117
1544
  # to `Layout::Content`, not a bare `ContentComponent`.
1118
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
1119
1552
  elsif html_element?(tag)
1120
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
+ )
1121
1563
  else
1122
1564
  ComponentInvocation.new(name: tag, props: attributes, children: children)
1123
1565
  end
1124
1566
  end
1125
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
+
1126
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
+
1127
1622
  Conditional.new(
1128
1623
  test: Interpolation.new(expression: source_of(poly[:test])),
1129
1624
  consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
@@ -1140,6 +1635,41 @@ module JsxRosetta
1140
1635
  end
1141
1636
  end
1142
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
+
1143
1673
  def lower_jsx_fragment(fragment)
1144
1674
  Fragment.new(children: lower_children(fragment.jsx_children))
1145
1675
  end
@@ -1489,10 +2019,116 @@ module JsxRosetta
1489
2019
  if value.is_a?(AST::JSXExpressionContainer)
1490
2020
  decomposed = try_lower_class_helper(value.expression)
1491
2021
  return decomposed if decomposed
2022
+
2023
+ cva_call = try_lower_cva_call_site(value.expression)
2024
+ return cva_call if cva_call
1492
2025
  end
1493
2026
  StyleBinding.new(expression: style_binding_expression(value))
1494
2027
  end
1495
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
+
1496
2132
  def try_lower_class_helper(expression)
1497
2133
  return nil unless AST::Node.matches?(expression, "CallExpression")
1498
2134
 
@@ -1573,9 +2209,18 @@ module JsxRosetta
1573
2209
  def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
1574
2210
  base = name_hint || default_stimulus_method_name(attr_name)
1575
2211
  method_name = stimulus_method_name(base)
1576
- 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 }
1577
2218
  @stimulus_methods << StimulusMethod.new(
1578
- 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"
1579
2224
  )
1580
2225
  @local_arrows.delete(name_hint) if name_hint
1581
2226
  StimulusBinding.new(event: event, method_name: method_name)
@@ -1589,7 +2234,11 @@ module JsxRosetta
1589
2234
  method_name = stimulus_method_name(identifier_name)
1590
2235
  body_source = "// originally bound to: #{identifier_name}"
1591
2236
  @stimulus_methods << StimulusMethod.new(
1592
- 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
1593
2242
  )
1594
2243
  StimulusBinding.new(event: event, method_name: method_name)
1595
2244
  end
@@ -1620,18 +2269,39 @@ module JsxRosetta
1620
2269
  # Recursively lower an arbitrary JS expression into structured IR
1621
2270
  # when possible: ObjectExpression → ObjectLiteral, ArrayExpression
1622
2271
  # → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
1623
- # Lambda. Everything else falls back to the verbatim Interpolation
1624
- # 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.
1625
2276
  def lower_value_expression(expression)
1626
2277
  case expression.type
1627
2278
  when "ObjectExpression" then lower_object_literal(expression)
1628
2279
  when "ArrayExpression" then lower_array_literal(expression)
1629
2280
  when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
2281
+ when "JSXElement", "JSXFragment" then lower_jsx_value(expression)
1630
2282
  else
1631
2283
  Interpolation.new(expression: source_of(expression))
1632
2284
  end
1633
2285
  end
1634
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
+
1635
2305
  def lower_object_literal(object_expression)
1636
2306
  properties = []
1637
2307
  object_expression[:properties].each do |prop|
@@ -1673,10 +2343,16 @@ module JsxRosetta
1673
2343
  return Interpolation.new(expression: source_of(arrow))
1674
2344
  end
1675
2345
 
2346
+ param_names = params.map { |p| p[:name] }
1676
2347
  body = lower_lambda_body(arrow[:body])
1677
- 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
1678
2354
 
1679
- Lambda.new(params: params.map { |p| p[:name] }, body: body)
2355
+ EventHandler.new(params: param_names, body_source: source_of(arrow[:body]))
1680
2356
  end
1681
2357
 
1682
2358
  def lower_lambda_body(body)
@@ -1707,6 +2383,19 @@ module JsxRosetta
1707
2383
  first == first.downcase
1708
2384
  end
1709
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
+
1710
2399
  def source_of(node)
1711
2400
  @source[node.start_pos...node.end_pos]
1712
2401
  end