jsx_rosetta 0.4.0 → 0.5.1

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.
@@ -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
@@ -57,15 +83,17 @@ module JsxRosetta
57
83
  end
58
84
 
59
85
  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
-
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,19 @@ 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
+ )
244
+ end
245
+
126
246
  def initializable_props(component)
127
247
  component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
128
248
  end
@@ -131,21 +251,50 @@ module JsxRosetta
131
251
  props = initializable_props(component)
132
252
  rest_name = component.rest_prop_name
133
253
 
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
254
+ body = render_class_with_optional_props(component, props, rest_name, translator)
255
+ body = inject_render_method_skeletons(body, component)
144
256
 
145
257
  prefix = render_module_bindings_prefix(component)
146
258
  prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
147
259
  end
148
260
 
261
+ def render_class_with_optional_props(component, props, rest_name, translator)
262
+ if props.empty? && rest_name.nil?
263
+ <<~RUBY
264
+ # frozen_string_literal: true
265
+
266
+ class #{component.name}Component < ::ViewComponent::Base
267
+ end
268
+ RUBY
269
+ else
270
+ render_ruby_class_with_props(component, props, rest_name, translator)
271
+ end
272
+ end
273
+
274
+ # For each RenderMethod, emit a method skeleton on the class just
275
+ # before the closing `end`. ERB-rendered VC bodies don't translate
276
+ # cleanly to Ruby methods (Phlex does — see its renderer), so the
277
+ # skeleton stays empty and the JSX source is preserved as a comment
278
+ # for the reviewer to translate by hand.
279
+ def inject_render_method_skeletons(body, component)
280
+ return body if component.render_methods.empty?
281
+
282
+ skeletons = component.render_methods.map { |rm| render_method_skeleton(rm) }
283
+ body.sub(/(\n)end\n\z/, "\n\n#{skeletons.join("\n\n")}\\1end\n")
284
+ end
285
+
286
+ def render_method_skeleton(render_method)
287
+ snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
288
+ signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
289
+ [
290
+ " # TODO: translate the JSX body for #{render_method.name} — was a",
291
+ " # local arrow returning JSX in the source component.",
292
+ " def #{signature}",
293
+ " \"\"",
294
+ " end"
295
+ ].join("\n")
296
+ end
297
+
149
298
  def render_module_bindings_prefix(component)
150
299
  return "" if component.module_bindings.empty?
151
300
 
@@ -169,14 +318,15 @@ module JsxRosetta
169
318
  end
170
319
 
171
320
  def render_ruby_class_with_props(component, props, rest_name, translator)
321
+ rest_snake = rest_name && AST::Inflector.underscore(rest_name)
172
322
  kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
173
- kwargs << "**#{rest_name}" if rest_name
323
+ kwargs << "**#{rest_snake}" if rest_snake
174
324
 
175
325
  assignments = props.map do |prop|
176
326
  snake = AST::Inflector.underscore(prop.name)
177
327
  " @#{snake} = #{snake}"
178
328
  end
179
- assignments << " @#{rest_name} = #{rest_name}" if rest_name
329
+ assignments << " @#{rest_snake} = #{rest_snake}" if rest_snake
180
330
 
181
331
  <<~RUBY
182
332
  # frozen_string_literal: true
@@ -265,14 +415,20 @@ module JsxRosetta
265
415
  def render_react_hooks_todo(hooks)
266
416
  return "" if hooks.empty?
267
417
 
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}" }
418
+ blocks = hooks.group_by(&:library).map { |library, calls| hook_todo_block(library, calls) }
419
+ "#{blocks.join("\n")}\n"
420
+ end
421
+
422
+ def hook_todo_block(library, calls)
423
+ header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
424
+ lines = ["<%# #{header_lines.first}"]
425
+ header_lines.drop(1).each { |line| lines << " #{line}" }
426
+ calls.each do |call|
427
+ lines << " operation: #{call.operation}" if call.operation
428
+ lines << " #{call.source}"
429
+ end
274
430
  lines << "%>"
275
- "#{lines.join("\n")}\n"
431
+ lines.join("\n")
276
432
  end
277
433
 
278
434
  def render_ir_node(node, translator, indent:)
@@ -283,6 +439,7 @@ module JsxRosetta
283
439
  when IR::Conditional then render_conditional(node, translator, indent: indent)
284
440
  when IR::Loop then render_loop(node, translator, indent: indent)
285
441
  when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
442
+ when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
286
443
  when IR::Slot then render_slot(node, indent: indent)
287
444
  when IR::Text then "#{spaces(indent)}#{node.value}"
288
445
  when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
@@ -290,6 +447,23 @@ module JsxRosetta
290
447
  end
291
448
  end
292
449
 
450
+ # `{renderHeader(arg)}` → `<%= render_header(arg) %>`. The matching
451
+ # method definition is emitted on the component class via
452
+ # `render_render_methods_section`. The class method returns an
453
+ # HTML-safe string (Rails' `content_tag` / `safe_join` is the
454
+ # canonical approach), and `<%= %>` interpolates it into the template.
455
+ def render_local_render_call(call, translator, indent:)
456
+ if call.args.empty?
457
+ "#{spaces(indent)}<%= #{call.method_name} %>"
458
+ else
459
+ arg_sources = call.args.map do |arg|
460
+ translated = translator.translate(arg.expression)
461
+ translated ? translated.ruby : arg.expression
462
+ end
463
+ "#{spaces(indent)}<%= #{call.method_name}(#{arg_sources.join(", ")}) %>"
464
+ end
465
+ end
466
+
293
467
  def render_orphan_render_prop(render_prop, translator, indent:)
294
468
  translator.with_locals(render_prop.params) do
295
469
  render_ir_node(render_prop.body, translator, indent: indent)
@@ -531,9 +705,16 @@ module JsxRosetta
531
705
  lines.join("\n")
532
706
  end
533
707
 
708
+ # A translated value of `"nil"` is treated as untranslatable: the
709
+ # translator emits `"nil"` for known-local bindings (so the file
710
+ # loads as a leaf reference), but driving an `<% if %>` with `nil`
711
+ # silently disables the whole branch. Fall back to the verbatim
712
+ # expression so the human reviewer sees what needs translating.
534
713
  def render_test_expression(test, translator)
535
714
  translated = translator.translate(test.expression)
536
- translated ? translated.ruby : test.expression
715
+ return translated.ruby if translated && translated.ruby != "nil"
716
+
717
+ test.expression
537
718
  end
538
719
 
539
720
  def render_slot(slot, indent:)
@@ -644,7 +825,7 @@ module JsxRosetta
644
825
  # literal portions literally and the interpolations as ERB tags.
645
826
  def plain_attribute_value_erb(interpolation, translator)
646
827
  translated = translator.translate(interpolation.expression)
647
- if double_quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
828
+ if quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
648
829
  inlined_ruby_string(translated.ruby)
649
830
  else
650
831
  interpolation_to_erb(interpolation, translator)
@@ -653,7 +834,7 @@ module JsxRosetta
653
834
 
654
835
  def render_style_binding(binding, translator)
655
836
  translated = translator.translate(binding.expression)
656
- if double_quoted_ruby_string?(translated&.ruby)
837
+ if quoted_ruby_string?(translated&.ruby)
657
838
  %(class="#{inlined_ruby_string(translated.ruby)}")
658
839
  elsif translated
659
840
  %(class="<%= #{translated.ruby} %>")
@@ -662,8 +843,11 @@ module JsxRosetta
662
843
  end
663
844
  end
664
845
 
665
- def double_quoted_ruby_string?(ruby)
666
- ruby.is_a?(String) && ruby.start_with?('"') && ruby.end_with?('"')
846
+ def quoted_ruby_string?(ruby)
847
+ ruby.is_a?(String) && (
848
+ (ruby.start_with?('"') && ruby.end_with?('"')) ||
849
+ (ruby.start_with?("'") && ruby.end_with?("'"))
850
+ )
667
851
  end
668
852
 
669
853
  # Given a Ruby double-quoted string with #{...} interpolations, emit it