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
@@ -42,6 +42,32 @@ module JsxRosetta
42
42
  "Image" => { method: :image_tag, positional: :src }.freeze
43
43
  }.freeze
44
44
 
45
+ # Per-library TODO header lines surfaced above the verbatim hook
46
+ # source. Each library has a different Rails analog, so we don't
47
+ # collapse them into a single generic block. Keys must mirror the
48
+ # `:library` values produced by IR::Lowering. First line is rendered
49
+ # with the `<%#` opener; subsequent lines are indented continuation.
50
+ HOOK_TODO_HEADERS = {
51
+ react: [
52
+ "TODO: React hooks detected. None translate automatically. Hotwire/Stimulus",
53
+ "handles behavior; controllers/views handle state; turbo-frames handle async",
54
+ "loading. Original source:"
55
+ ].freeze,
56
+ apollo: [
57
+ "TODO: Apollo data-fetching hooks detected. None translate automatically.",
58
+ "Move the fetch to the Rails controller (or a model/service); pass the",
59
+ "result in as a prop. For useMutation, use a form POST + redirect or a",
60
+ "Turbo Stream response. Original source:"
61
+ ].freeze,
62
+ next_js: [
63
+ "TODO: Next.js navigation hooks detected. None translate automatically.",
64
+ "Rails analogs: useRouter -> redirect_to / form actions;",
65
+ "usePathname -> request.path; useSearchParams / useParams -> params;",
66
+ "useSelectedLayoutSegment(s) -> match against request.path in the view.",
67
+ "Original source:"
68
+ ].freeze
69
+ }.freeze
70
+
45
71
  def initialize(helpers: nil, layout: :sidecar)
46
72
  super()
47
73
  @helpers = case helpers
@@ -56,16 +82,18 @@ module JsxRosetta
56
82
  @layout = layout
57
83
  end
58
84
 
59
- def emit(component)
60
- prop_names = component.props.map(&:name)
61
- prop_names << component.rest_prop_name if component.rest_prop_name
62
- translator = ExpressionTranslator.new(
63
- prop_names: prop_names, local_binding_names: component.local_binding_names
64
- )
65
-
85
+ def emit(component, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument
86
+ translator = build_translator(component)
66
87
  base_name = "#{AST::Inflector.underscore(component.name)}_component"
67
88
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
68
89
 
90
+ # Data-factory components (column-descriptor modules) have no
91
+ # template — they're pure-data classes. Skip the .erb pair and
92
+ # emit a single .rb with the factory method. JSX render lambdas
93
+ # inside the data have nowhere to live in the ViewComponent
94
+ # ERB-template world, so we surface a TODO note in the class.
95
+ return emit_data_factory(component) if component.mode == :data_factory
96
+
69
97
  files = [
70
98
  File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
71
99
  File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
@@ -79,6 +107,85 @@ module JsxRosetta
79
107
  files
80
108
  end
81
109
 
110
+ # For data-factory components emit a plain Ruby class (no ApplicationViewComponent
111
+ # base, no ERB template). The user can mix it into a ViewComponent or call
112
+ # the method directly — the goal is to surface the translated data array,
113
+ # not to render it in isolation.
114
+ def emit_data_factory(component)
115
+ method_name = AST::Inflector.underscore(component.name)
116
+ param_names = component.props.map(&:name)
117
+ translator = ExpressionTranslator.new(prop_names: [], local_binding_names: param_names)
118
+ signature = data_factory_signature(method_name, param_names)
119
+ body = translator.with_locals(param_names) do
120
+ inline_render_value(component.body, translator, indent: 4)
121
+ end
122
+ contents = <<~RUBY
123
+ # frozen_string_literal: true
124
+
125
+ # TODO: data-factory module — the translated array contains JSX render
126
+ # lambdas as `nil` placeholders. Wire each up to a Phlex helper or a
127
+ # method on the consuming ViewComponent.
128
+ class #{class_name_for(component)}
129
+ def #{signature}
130
+ #{body}
131
+ end
132
+ end
133
+ RUBY
134
+ [File.new(path: "#{AST::Inflector.underscore(component.name)}.rb", contents: contents)]
135
+ end
136
+
137
+ def class_name_for(component)
138
+ component.name[0].upcase + component.name[1..]
139
+ end
140
+
141
+ def data_factory_signature(method_name, param_names)
142
+ return method_name if param_names.empty?
143
+
144
+ kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
145
+ "#{method_name}(#{kwargs.join(", ")})"
146
+ end
147
+
148
+ # Recursively render a non-JSX value (ObjectLiteral / ArrayLiteral /
149
+ # Lambda / Interpolation / primitives) without the kwarg-list
150
+ # context the Phlex backend uses. IR::Lambda and unmatched cases
151
+ # both fall through to `nil` — there's no Phlex class to host a
152
+ # rendered method body, and the class-level TODO comment above the
153
+ # emitted file already flags both for the reviewer.
154
+ def inline_render_value(value, translator, indent: 0)
155
+ case value
156
+ when IR::ObjectLiteral then render_factory_object_literal(value, translator, indent: indent)
157
+ when IR::ArrayLiteral then render_factory_array_literal(value, translator, indent: indent)
158
+ when IR::Interpolation then translator.translate(value.expression)&.ruby || "nil"
159
+ when String then value.inspect
160
+ when true then "true"
161
+ else "nil"
162
+ end
163
+ end
164
+
165
+ def render_factory_object_literal(obj, translator, indent:)
166
+ parts = obj.properties.map do |(key, value)|
167
+ rendered = inline_render_value(value, translator, indent: indent + 2)
168
+ snake = AST::Inflector.underscore(key)
169
+ if snake.match?(/\A[a-z_][a-z0-9_]*\z/)
170
+ "#{snake}: #{rendered}"
171
+ else
172
+ "#{key.inspect} => #{rendered}"
173
+ end
174
+ end
175
+ "{ #{parts.join(", ")} }"
176
+ end
177
+
178
+ def render_factory_array_literal(arr, translator, indent:)
179
+ parts = arr.elements.map do |el|
180
+ el.nil? ? "nil" : inline_render_value(el, translator, indent: indent + 2)
181
+ end
182
+ return "[]" if parts.empty?
183
+
184
+ pad = " " * (indent + 2)
185
+ close_pad = " " * indent
186
+ "[\n#{pad}#{parts.join(",\n#{pad}")}\n#{close_pad}]"
187
+ end
188
+
82
189
  def erb_path(base_name)
83
190
  @layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
84
191
  end
@@ -123,6 +230,20 @@ module JsxRosetta
123
230
 
124
231
  private
125
232
 
233
+ def build_translator(component)
234
+ prop_names = component.props.map(&:name)
235
+ prop_names << component.rest_prop_name if component.rest_prop_name
236
+ prop_aliases = component.props.each_with_object({}) do |prop, hash|
237
+ hash[prop.alias_name] = prop.name if prop.alias_name
238
+ end
239
+ ExpressionTranslator.new(
240
+ prop_names: prop_names,
241
+ local_binding_names: component.local_binding_names,
242
+ prop_aliases: prop_aliases,
243
+ imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
244
+ )
245
+ end
246
+
126
247
  def initializable_props(component)
127
248
  component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
128
249
  end
@@ -131,21 +252,50 @@ module JsxRosetta
131
252
  props = initializable_props(component)
132
253
  rest_name = component.rest_prop_name
133
254
 
134
- body = if props.empty? && rest_name.nil?
135
- <<~RUBY
136
- # frozen_string_literal: true
137
-
138
- class #{component.name}Component < ::ViewComponent::Base
139
- end
140
- RUBY
141
- else
142
- render_ruby_class_with_props(component, props, rest_name, translator)
143
- end
255
+ body = render_class_with_optional_props(component, props, rest_name, translator)
256
+ body = inject_render_method_skeletons(body, component)
144
257
 
145
258
  prefix = render_module_bindings_prefix(component)
146
259
  prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
147
260
  end
148
261
 
262
+ def render_class_with_optional_props(component, props, rest_name, translator)
263
+ if props.empty? && rest_name.nil?
264
+ <<~RUBY
265
+ # frozen_string_literal: true
266
+
267
+ class #{component.name}Component < ::ViewComponent::Base
268
+ end
269
+ RUBY
270
+ else
271
+ render_ruby_class_with_props(component, props, rest_name, translator)
272
+ end
273
+ end
274
+
275
+ # For each RenderMethod, emit a method skeleton on the class just
276
+ # before the closing `end`. ERB-rendered VC bodies don't translate
277
+ # cleanly to Ruby methods (Phlex does — see its renderer), so the
278
+ # skeleton stays empty and the JSX source is preserved as a comment
279
+ # for the reviewer to translate by hand.
280
+ def inject_render_method_skeletons(body, component)
281
+ return body if component.render_methods.empty?
282
+
283
+ skeletons = component.render_methods.map { |rm| render_method_skeleton(rm) }
284
+ body.sub(/(\n)end\n\z/, "\n\n#{skeletons.join("\n\n")}\\1end\n")
285
+ end
286
+
287
+ def render_method_skeleton(render_method)
288
+ snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
289
+ signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
290
+ [
291
+ " # TODO: translate the JSX body for #{render_method.name} — was a",
292
+ " # local arrow returning JSX in the source component.",
293
+ " def #{signature}",
294
+ " \"\"",
295
+ " end"
296
+ ].join("\n")
297
+ end
298
+
149
299
  def render_module_bindings_prefix(component)
150
300
  return "" if component.module_bindings.empty?
151
301
 
@@ -169,14 +319,15 @@ module JsxRosetta
169
319
  end
170
320
 
171
321
  def render_ruby_class_with_props(component, props, rest_name, translator)
322
+ rest_snake = rest_name && AST::Inflector.underscore(rest_name)
172
323
  kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
173
- kwargs << "**#{rest_name}" if rest_name
324
+ kwargs << "**#{rest_snake}" if rest_snake
174
325
 
175
326
  assignments = props.map do |prop|
176
327
  snake = AST::Inflector.underscore(prop.name)
177
328
  " @#{snake} = #{snake}"
178
329
  end
179
- assignments << " @#{rest_name} = #{rest_name}" if rest_name
330
+ assignments << " @#{rest_snake} = #{rest_snake}" if rest_snake
180
331
 
181
332
  <<~RUBY
182
333
  # frozen_string_literal: true
@@ -265,14 +416,20 @@ module JsxRosetta
265
416
  def render_react_hooks_todo(hooks)
266
417
  return "" if hooks.empty?
267
418
 
268
- lines = [
269
- "<%# TODO: React hooks detected. None translate automatically. Hotwire/Stimulus",
270
- " handles behavior; controllers/views handle state; turbo-frames handle async",
271
- " loading. Original source:"
272
- ]
273
- hooks.each { |hook| lines << " #{hook.source}" }
419
+ blocks = hooks.group_by(&:library).map { |library, calls| hook_todo_block(library, calls) }
420
+ "#{blocks.join("\n")}\n"
421
+ end
422
+
423
+ def hook_todo_block(library, calls)
424
+ header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
425
+ lines = ["<%# #{header_lines.first}"]
426
+ header_lines.drop(1).each { |line| lines << " #{line}" }
427
+ calls.each do |call|
428
+ lines << " operation: #{call.operation}" if call.operation
429
+ lines << " #{call.source}"
430
+ end
274
431
  lines << "%>"
275
- "#{lines.join("\n")}\n"
432
+ lines.join("\n")
276
433
  end
277
434
 
278
435
  def render_ir_node(node, translator, indent:)
@@ -283,6 +440,7 @@ module JsxRosetta
283
440
  when IR::Conditional then render_conditional(node, translator, indent: indent)
284
441
  when IR::Loop then render_loop(node, translator, indent: indent)
285
442
  when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
443
+ when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
286
444
  when IR::Slot then render_slot(node, indent: indent)
287
445
  when IR::Text then "#{spaces(indent)}#{node.value}"
288
446
  when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
@@ -290,6 +448,23 @@ module JsxRosetta
290
448
  end
291
449
  end
292
450
 
451
+ # `{renderHeader(arg)}` → `<%= render_header(arg) %>`. The matching
452
+ # method definition is emitted on the component class via
453
+ # `render_render_methods_section`. The class method returns an
454
+ # HTML-safe string (Rails' `content_tag` / `safe_join` is the
455
+ # canonical approach), and `<%= %>` interpolates it into the template.
456
+ def render_local_render_call(call, translator, indent:)
457
+ if call.args.empty?
458
+ "#{spaces(indent)}<%= #{call.method_name} %>"
459
+ else
460
+ arg_sources = call.args.map do |arg|
461
+ translated = translator.translate(arg.expression)
462
+ translated ? translated.ruby : arg.expression
463
+ end
464
+ "#{spaces(indent)}<%= #{call.method_name}(#{arg_sources.join(", ")}) %>"
465
+ end
466
+ end
467
+
293
468
  def render_orphan_render_prop(render_prop, translator, indent:)
294
469
  translator.with_locals(render_prop.params) do
295
470
  render_ir_node(render_prop.body, translator, indent: indent)
@@ -531,9 +706,16 @@ module JsxRosetta
531
706
  lines.join("\n")
532
707
  end
533
708
 
709
+ # A translated value of `"nil"` is treated as untranslatable: the
710
+ # translator emits `"nil"` for known-local bindings (so the file
711
+ # loads as a leaf reference), but driving an `<% if %>` with `nil`
712
+ # silently disables the whole branch. Fall back to the verbatim
713
+ # expression so the human reviewer sees what needs translating.
534
714
  def render_test_expression(test, translator)
535
715
  translated = translator.translate(test.expression)
536
- translated ? translated.ruby : test.expression
716
+ return translated.ruby if translated && translated.ruby != "nil"
717
+
718
+ test.expression
537
719
  end
538
720
 
539
721
  def render_slot(slot, indent:)
@@ -644,7 +826,7 @@ module JsxRosetta
644
826
  # literal portions literally and the interpolations as ERB tags.
645
827
  def plain_attribute_value_erb(interpolation, translator)
646
828
  translated = translator.translate(interpolation.expression)
647
- if double_quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
829
+ if quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
648
830
  inlined_ruby_string(translated.ruby)
649
831
  else
650
832
  interpolation_to_erb(interpolation, translator)
@@ -653,7 +835,7 @@ module JsxRosetta
653
835
 
654
836
  def render_style_binding(binding, translator)
655
837
  translated = translator.translate(binding.expression)
656
- if double_quoted_ruby_string?(translated&.ruby)
838
+ if quoted_ruby_string?(translated&.ruby)
657
839
  %(class="#{inlined_ruby_string(translated.ruby)}")
658
840
  elsif translated
659
841
  %(class="<%= #{translated.ruby} %>")
@@ -662,8 +844,11 @@ module JsxRosetta
662
844
  end
663
845
  end
664
846
 
665
- def double_quoted_ruby_string?(ruby)
666
- ruby.is_a?(String) && ruby.start_with?('"') && ruby.end_with?('"')
847
+ def quoted_ruby_string?(ruby)
848
+ ruby.is_a?(String) && (
849
+ (ruby.start_with?('"') && ruby.end_with?('"')) ||
850
+ (ruby.start_with?("'") && ruby.end_with?("'"))
851
+ )
667
852
  end
668
853
 
669
854
  # Given a Ruby double-quoted string with #{...} interpolations, emit it
@@ -727,6 +912,51 @@ module JsxRosetta
727
912
  when IR::Interpolation
728
913
  translated = translator.translate(value.expression)
729
914
  translated ? translated.ruby : "nil # TODO: translate #{value.expression.inspect}"
915
+ when IR::ComponentInvocation
916
+ component_invocation_inline_value(value, translator)
917
+ when IR::Element, IR::Fragment
918
+ "nil # TODO: couldn't inline JSX value: #{jsx_value_summary(value)}"
919
+ end
920
+ end
921
+
922
+ # JSX appearing as an attribute value — `icon={<Foo/>}` etc. — lowered
923
+ # in v0.5.x. Emit a component-instance value (`icon: FooComponent.new`)
924
+ # so the receiving ViewComponent can `render @icon`. With children we
925
+ # use Ruby block syntax (`FooComponent.new { ... }`) when the body
926
+ # fits one line; otherwise we drop with a TODO so the kwarg stays
927
+ # valid Ruby. Cases requiring a Phlex execution context (IR::Element,
928
+ # multi-element IR::Fragment) drop with a TODO too — out of MVP scope.
929
+ def component_invocation_inline_value(invocation, translator)
930
+ return inline_render_prop_todo if invocation.children.any?(IR::RenderProp)
931
+
932
+ kwargs = component_invocation_kwargs(invocation.props, translator)
933
+ class_name = component_class_name(invocation.name)
934
+ new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
935
+ return new_call if invocation.children.empty?
936
+
937
+ block_body = inline_children_body(invocation.children, translator)
938
+ return "nil # TODO: couldn't inline JSX value: <#{invocation.name}...>" unless block_body
939
+
940
+ "#{new_call} { #{block_body} }"
941
+ end
942
+
943
+ def inline_render_prop_todo
944
+ "nil # TODO: couldn't inline render-prop value"
945
+ end
946
+
947
+ def inline_children_body(children, translator)
948
+ rendered = children.map { |c| render_ir_node(c, translator, indent: 0).strip }
949
+ return nil if rendered.any? { |line| line.include?("\n") }
950
+
951
+ joined = rendered.join("; ")
952
+ joined.empty? || joined.length > 100 ? nil : joined
953
+ end
954
+
955
+ def jsx_value_summary(value)
956
+ case value
957
+ when IR::Element then "<#{value.tag}...>"
958
+ when IR::Fragment then "<>...</>"
959
+ else "<JSX>"
730
960
  end
731
961
  end
732
962
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require "open3"
6
+ require "pathname"
6
7
 
7
8
  require_relative "node_bridge"
8
9
 
@@ -15,6 +16,8 @@ module JsxRosetta
15
16
  # (default: current directory). TSX is detected
16
17
  # via the .tsx extension or --tsx.
17
18
  # parse FILE Print the parsed Babel AST as pretty JSON.
19
+ # pages-routes DIR [-o PATH] Walk a Next.js `pages/` directory and emit a
20
+ # Rails config/routes.rb skeleton.
18
21
  # version Print the gem version.
19
22
  # help Show usage.
20
23
  class CLI
@@ -22,6 +25,43 @@ module JsxRosetta
22
25
  EXIT_USAGE = 64
23
26
  EXIT_FAILURE = 1
24
27
 
28
+ USAGE_TEXT = <<~USAGE
29
+ Usage: jsx_rosetta <command> [args]
30
+
31
+ Commands:
32
+ install Install the gem's Node sidecar dependencies (runs `npm install`).
33
+ translate FILE [-o DIR] Translate JSX/TSX into ViewComponent files in DIR (default: ".").
34
+ Pass --tsx to force TypeScript parsing if the input is .jsx.
35
+ Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
36
+ instead of a ViewComponent class + sidecar template — appropriate
37
+ for pages tied to a route.
38
+ Pass --as=phlex to emit a single-file Phlex 2.x view class
39
+ (`<snake>.rb`) instead of a ViewComponent. Configure the class
40
+ name with --phlex-suffix=Component or --phlex-namespace=Components
41
+ (mutually exclusive; default is bare class name).
42
+ Pass --rails-routes DIR (with --as=phlex) to place the output
43
+ at <controller>/<action>.rb with class
44
+ Views::<Controller>::<Action> < Views::Base, derived from a
45
+ route table scanned out of DIR (a Next.js pages directory).
46
+ routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
47
+ and emit a reviewable Ruby script that calls `rails generate
48
+ controller` and prints suggested config/routes.rb additions.
49
+ pages-routes DIR [-o PATH] Walk a Next.js `pages/` directory tree and emit a
50
+ Rails config/routes.rb skeleton derived from the file
51
+ layout. Use --ext .tsx,.jsx,.ts,.js to override the
52
+ default `.tsx,.jsx` filter, and --allow-any-dir to
53
+ skip the `basename == 'pages'` safety check.
54
+ Pass --controllers DIR to also emit one
55
+ `<controller>_controller.rb` per controller in DIR
56
+ (existing files are not overwritten).
57
+ parse FILE Parse the input and print the Babel AST as JSON.
58
+ version Print the gem version.
59
+ help Show this help.
60
+
61
+ Environment:
62
+ JSX_ROSETTA_NODE Absolute path to a node executable (default: PATH lookup).
63
+ USAGE
64
+
25
65
  def initialize(argv = ARGV.dup, stdout: $stdout, stderr: $stderr)
26
66
  @argv = argv
27
67
  @stdout = stdout
@@ -34,6 +74,7 @@ module JsxRosetta
34
74
  when "install" then run_install
35
75
  when "translate" then run_translate
36
76
  when "routes" then run_routes
77
+ when "pages-routes" then run_pages_routes
37
78
  when "parse" then run_parse
38
79
  when "version", "-v", "--version" then run_version
39
80
  when nil, "help", "-h", "--help" then print_help(EXIT_OK)
@@ -61,6 +102,8 @@ module JsxRosetta
61
102
  input_path = positional.first
62
103
  return missing_argument("translate FILE", "translate") unless input_path
63
104
 
105
+ resolve_rails_view_route!(options, input_path)
106
+
64
107
  out_dir = options[:out] || "."
65
108
  typescript = options[:tsx] || input_path.end_with?(".tsx")
66
109
  backend = backend_for_as(options[:as])
@@ -72,7 +115,8 @@ module JsxRosetta
72
115
  backend: backend,
73
116
  backend_options: backend_options,
74
117
  typescript: typescript,
75
- source_filename: input_path
118
+ source_filename: input_path,
119
+ keep_slot: options[:keep_slot] || false
76
120
  )
77
121
 
78
122
  write_emitted_files(files, out_dir)
@@ -93,7 +137,40 @@ module JsxRosetta
93
137
  def backend_options_for(backend, options)
94
138
  return {} unless backend == :phlex
95
139
 
96
- { suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
140
+ base = { suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
141
+ base[:rails_view] = options[:rails_view_route] if options[:rails_view_route]
142
+ base[:route_table] = options[:route_table] if options[:route_table]
143
+ base
144
+ end
145
+
146
+ def resolve_rails_view_route!(options, input_path)
147
+ pages_dir = options[:rails_routes]
148
+ return unless pages_dir
149
+
150
+ raise ArgumentError, "--rails-routes requires --as=phlex" unless options[:as] == "phlex"
151
+ if options[:phlex_suffix] || options[:phlex_namespace]
152
+ raise ArgumentError, "--rails-routes cannot be combined with --phlex-suffix or --phlex-namespace"
153
+ end
154
+
155
+ ensure_pages_dir!(pages_dir, allow_any: options[:allow_any_dir])
156
+ rel = relative_path_under(input_path, pages_dir)
157
+ raise ArgumentError, "#{input_path} is not under #{pages_dir}" unless rel
158
+
159
+ routes, _skipped = PagesRouting.scan(pages_dir, extensions: options[:ext] || PagesRouting::DEFAULT_EXTENSIONS)
160
+ route = routes.find { |r| r.source_path == rel }
161
+ raise ArgumentError, "#{rel} has no route in #{pages_dir} (skipped or non-page file?)" unless route
162
+
163
+ options[:rails_view_route] = route
164
+ options[:route_table] = routes
165
+ end
166
+
167
+ def relative_path_under(file_path, dir)
168
+ file = Pathname.new(File.expand_path(file_path))
169
+ base = Pathname.new(File.expand_path(dir))
170
+ rel = file.relative_path_from(base).to_s
171
+ rel unless rel.start_with?("..")
172
+ rescue ArgumentError
173
+ nil
97
174
  end
98
175
 
99
176
  def write_emitted_files(files, out_dir)
@@ -129,6 +206,53 @@ module JsxRosetta
129
206
  EXIT_FAILURE
130
207
  end
131
208
 
209
+ def run_pages_routes
210
+ options, positional = parse_translate_options
211
+ input_dir = positional.first
212
+ return missing_argument("pages-routes DIR [-o OUT.rb]", "pages-routes") unless input_dir
213
+
214
+ ensure_pages_dir!(input_dir, allow_any: options[:allow_any_dir])
215
+ extensions = options[:ext] || PagesRouting::DEFAULT_EXTENSIONS
216
+ routes, skipped = PagesRouting.scan(input_dir, extensions: extensions)
217
+ contents = PagesRouting.emit(routes: routes, skipped: skipped, source_dir: input_dir)
218
+
219
+ if options[:out]
220
+ File.write(options[:out], contents)
221
+ @stdout.puts "wrote #{options[:out]}"
222
+ else
223
+ @stdout.print(contents)
224
+ end
225
+
226
+ write_controllers(routes, options[:controllers]) if options[:controllers]
227
+ EXIT_OK
228
+ rescue ArgumentError => e
229
+ @stderr.puts "jsx_rosetta pages-routes: #{e.message}"
230
+ EXIT_FAILURE
231
+ end
232
+
233
+ def write_controllers(routes, dir)
234
+ FileUtils.mkdir_p(dir)
235
+ PagesRouting.emit_controllers(routes: routes).each do |file|
236
+ target = File.join(dir, file.path)
237
+ if File.exist?(target)
238
+ @stdout.puts "skipped #{target} (exists)"
239
+ else
240
+ File.write(target, file.contents)
241
+ @stdout.puts "wrote #{target}"
242
+ end
243
+ end
244
+ end
245
+
246
+ def ensure_pages_dir!(dir, allow_any:)
247
+ return if allow_any
248
+ return if File.basename(dir) == "pages"
249
+ return if File.directory?(File.join(dir, "pages"))
250
+
251
+ raise ArgumentError,
252
+ "#{dir.inspect} does not look like a Next.js pages directory " \
253
+ "(basename != 'pages' and no nested 'pages/'). Pass --allow-any-dir to override."
254
+ end
255
+
132
256
  def run_parse
133
257
  options, positional = parse_translate_options
134
258
  input_path = positional.first
@@ -157,22 +281,59 @@ module JsxRosetta
157
281
 
158
282
  until @argv.empty?
159
283
  arg = @argv.shift
160
- case arg
161
- when "-o", "--out" then options[:out] = @argv.shift
162
- when "--tsx", "--typescript" then options[:tsx] = true
163
- when "--as" then options[:as] = @argv.shift
164
- when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
165
- when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
166
- when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
167
- when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
168
- when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
169
- else positional << arg
170
- end
284
+ positional << arg unless option_consumed?(arg, options)
171
285
  end
172
286
 
173
287
  [options, positional]
174
288
  end
175
289
 
290
+ def option_consumed?(arg, options)
291
+ consume_translate_option?(arg, options) ||
292
+ consume_phlex_option?(arg, options) ||
293
+ consume_pages_routes_option?(arg, options)
294
+ end
295
+
296
+ def consume_translate_option?(arg, options)
297
+ case arg
298
+ when "-o", "--out" then options[:out] = @argv.shift
299
+ when "--tsx", "--typescript" then options[:tsx] = true
300
+ when "--as" then options[:as] = @argv.shift
301
+ when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
302
+ when "--keep-slot" then options[:keep_slot] = true
303
+ else return false
304
+ end
305
+ true
306
+ end
307
+
308
+ def consume_phlex_option?(arg, options)
309
+ case arg
310
+ when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
311
+ when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
312
+ when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
313
+ when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
314
+ when "--rails-routes" then options[:rails_routes] = @argv.shift
315
+ when /\A--rails-routes=(.+)\z/ then options[:rails_routes] = ::Regexp.last_match(1)
316
+ else return false
317
+ end
318
+ true
319
+ end
320
+
321
+ def consume_pages_routes_option?(arg, options)
322
+ case arg
323
+ when "--ext" then options[:ext] = parse_ext_list(@argv.shift)
324
+ when /\A--ext=(.+)\z/ then options[:ext] = parse_ext_list(::Regexp.last_match(1))
325
+ when "--allow-any-dir" then options[:allow_any_dir] = true
326
+ when "--controllers" then options[:controllers] = @argv.shift
327
+ when /\A--controllers=(.+)\z/ then options[:controllers] = ::Regexp.last_match(1)
328
+ else return false
329
+ end
330
+ true
331
+ end
332
+
333
+ def parse_ext_list(value)
334
+ value.to_s.split(",").map(&:strip).reject(&:empty?).map { |ext| ext.start_with?(".") ? ext : ".#{ext}" }
335
+ end
336
+
176
337
  def missing_argument(usage, command)
177
338
  @stderr.puts "jsx_rosetta #{command}: missing required argument."
178
339
  @stderr.puts " usage: jsx_rosetta #{usage}"
@@ -180,30 +341,7 @@ module JsxRosetta
180
341
  end
181
342
 
182
343
  def print_help(exit_code)
183
- @stdout.puts <<~USAGE
184
- Usage: jsx_rosetta <command> [args]
185
-
186
- Commands:
187
- install Install the gem's Node sidecar dependencies (runs `npm install`).
188
- translate FILE [-o DIR] Translate JSX/TSX into ViewComponent files in DIR (default: ".").
189
- Pass --tsx to force TypeScript parsing if the input is .jsx.
190
- Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
191
- instead of a ViewComponent class + sidecar template — appropriate
192
- for pages tied to a route.
193
- Pass --as=phlex to emit a single-file Phlex 2.x view class
194
- (`<snake>.rb`) instead of a ViewComponent. Configure the class
195
- name with --phlex-suffix=Component or --phlex-namespace=Components
196
- (mutually exclusive; default is bare class name).
197
- routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
198
- and emit a reviewable Ruby script that calls `rails generate
199
- controller` and prints suggested config/routes.rb additions.
200
- parse FILE Parse the input and print the Babel AST as JSON.
201
- version Print the gem version.
202
- help Show this help.
203
-
204
- Environment:
205
- JSX_ROSETTA_NODE Absolute path to a node executable (default: PATH lookup).
206
- USAGE
344
+ @stdout.puts USAGE_TEXT
207
345
  exit_code
208
346
  end
209
347
  end