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.
@@ -31,6 +31,40 @@ module JsxRosetta
31
31
  VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
32
32
  VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
33
33
 
34
+ # Inline budget for object/array literal rendering. When the
35
+ # single-line rendering of a literal exceeds this width — measured
36
+ # from the opening bracket — it switches to a multi-line layout
37
+ # with one entry per line, indented two spaces past the parent's
38
+ # line indent. Closing bracket re-aligns to the parent indent.
39
+ # Chosen to keep typical attr lines under ~120 chars after the
40
+ # kwarg name and surrounding `render Foo.new(...)` wrapper.
41
+ LITERAL_INLINE_BUDGET = 80
42
+
43
+ # Per-library TODO header lines surfaced above the verbatim hook
44
+ # source. Each library has a different Rails analog, so we don't
45
+ # collapse them into a single generic block. Keys must mirror the
46
+ # `:library` values produced by IR::Lowering.
47
+ HOOK_TODO_HEADERS = {
48
+ react: [
49
+ "TODO: React hooks detected. None translate automatically.",
50
+ "Hotwire/Stimulus handles behavior; controllers/views handle state;",
51
+ "turbo-frames handle async loading. Original source:"
52
+ ].freeze,
53
+ apollo: [
54
+ "TODO: Apollo data-fetching hooks detected. None translate automatically.",
55
+ "Move the fetch to the Rails controller (or a model/service); pass the",
56
+ "result in as a prop. For useMutation, use a form POST + redirect or a",
57
+ "Turbo Stream response. Original source:"
58
+ ].freeze,
59
+ next_js: [
60
+ "TODO: Next.js navigation hooks detected. None translate automatically.",
61
+ "Rails analogs: useRouter -> redirect_to / form actions;",
62
+ "usePathname -> request.path; useSearchParams / useParams -> params;",
63
+ "useSelectedLayoutSegment(s) -> match against request.path in the view.",
64
+ "Original source:"
65
+ ].freeze
66
+ }.freeze
67
+
34
68
  # Structured intermediate for the data-action attribute — mirrors the
35
69
  # ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
36
70
  EventDescriptor = Data.define(:kind, :body)
@@ -44,17 +78,12 @@ module JsxRosetta
44
78
  end
45
79
 
46
80
  def emit(component)
47
- prop_names = component.props.map(&:name)
48
- prop_names << component.rest_prop_name if component.rest_prop_name
49
- translator = ViewComponent::ExpressionTranslator.new(
50
- prop_names: prop_names, local_binding_names: component.local_binding_names
51
- )
52
-
81
+ translator = build_translator(component)
53
82
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
54
83
  @lambda_methods = []
55
84
  @lambda_method_counts = {}
56
85
 
57
- files = [File.new(path: ruby_path(component), contents: render_ruby_class(component, translator))]
86
+ files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
58
87
  if component.stimulus_methods.any?
59
88
  files << File.new(
60
89
  path: stimulus_path(component),
@@ -64,6 +93,56 @@ module JsxRosetta
64
93
  files
65
94
  end
66
95
 
96
+ def build_translator(component)
97
+ prop_names = component.props.map(&:name)
98
+ prop_names << component.rest_prop_name if component.rest_prop_name
99
+ prop_aliases = component.props.each_with_object({}) do |prop, hash|
100
+ hash[prop.alias_name] = prop.name if prop.alias_name
101
+ end
102
+ ViewComponent::ExpressionTranslator.new(
103
+ prop_names: prop_names,
104
+ local_binding_names: component.local_binding_names,
105
+ prop_aliases: prop_aliases
106
+ )
107
+ end
108
+
109
+ # Strip trailing whitespace from each emitted line — easier than
110
+ # threading rstrip through every formatting helper, and a single
111
+ # source of truth keeps Layout/TrailingWhitespace at zero. Preserve
112
+ # the trailing newline of the file as-is. Also suppresses the
113
+ # intentional-`if false` cops file-wide so the user's rubocop
114
+ # doesn't drown out actionable findings.
115
+ def clean_output(source)
116
+ cleaned = "#{source.split("\n").map(&:rstrip).join("\n")}\n".sub(/\n\n+\z/, "\n")
117
+ suppress_intentional_if_false_cops(cleaned)
118
+ end
119
+
120
+ # When the file contains any `if false` / `elsif false` branch (the
121
+ # fallback we emit when a JSX condition can't be translated to Ruby),
122
+ # disable the cops that flag those at file scope. The corresponding
123
+ # `# TODO: translate condition:` comment already names the issue, so
124
+ # the cop's report is redundant noise. Only emits when needed.
125
+ def suppress_intentional_if_false_cops(source)
126
+ cops = []
127
+ cops << "Lint/LiteralAsCondition" if source.match?(/^\s*(?:if|elsif) false$/m)
128
+ # An elsif-false adjacent to its leading `if false` is the only
129
+ # configuration that triggers DuplicateElsifCondition — multiple
130
+ # `if false`s in separate scopes don't qualify. The pattern below
131
+ # matches an `if false` directly followed (after consequent lines)
132
+ # by an `elsif false` at the same indent.
133
+ cops << "Lint/DuplicateElsifCondition" if source.match?(/^(\s*)if false\b[\s\S]*?^\1elsif false\b/m)
134
+ return source if cops.empty?
135
+
136
+ disable = "# rubocop:disable #{cops.join(", ")}\n\n"
137
+ enable = "# rubocop:enable #{cops.join(", ")}\n"
138
+ # Magic comment must be followed by a blank line before any other
139
+ # comment (Layout/EmptyLineAfterMagicComment), and every file-level
140
+ # disable needs a matching enable (Lint/MissingCopEnableDirective).
141
+ with_disable = source.sub(/^# frozen_string_literal: true\n\n/,
142
+ "# frozen_string_literal: true\n\n#{disable}")
143
+ "#{with_disable.chomp}\n#{enable}"
144
+ end
145
+
67
146
  private
68
147
 
69
148
  # JSX-returning lowercase helpers (e.g. `textRender`, `getNodeIcon`)
@@ -117,22 +196,78 @@ module JsxRosetta
117
196
 
118
197
  def render_class_body(component, translator)
119
198
  initializer = render_initializer(component, translator)
120
- template = render_view_template(component, translator)
121
- # Render lambdas only AFTER the template — `@lambda_methods` is
122
- # populated as attribute values are rendered.
123
- lambda_methods = render_lambda_method_definitions(translator)
124
-
125
- sections = [initializer, template, lambda_methods].compact.join("\n\n")
126
- "class #{class_name(component)} < #{PHLEX_BASE_CLASS}\n#{sections}\nend\n"
199
+ template = if component.mode == :data_factory
200
+ render_data_factory_method(component, translator)
201
+ else
202
+ render_view_template(component, translator)
203
+ end
204
+ # Render private methods (render_methods + lambdas) AFTER the
205
+ # template — `@lambda_methods` is populated during attribute-value
206
+ # rendering, and render_methods bodies share the same indent.
207
+ private_section = render_private_methods(component, translator)
208
+
209
+ sections = [initializer, template, private_section].compact.join("\n\n")
210
+ cls = class_name(component)
211
+ # One-line docstring above the class — pacifies Style/Documentation
212
+ # without forcing the host project to disable the cop globally. The
213
+ # body is intentionally minimal so it doesn't drift from the source
214
+ # over time; a richer comment belongs in the host repo's review.
215
+ "# #{cls} — generated by jsx_rosetta from JSX. Review before shipping.\n" \
216
+ "class #{cls} < #{PHLEX_BASE_CLASS}\n#{sections}\nend\n"
217
+ end
218
+
219
+ # For data-factory components (`export const createColumns = (args)
220
+ # => [{...}, {...}]`) emit a public method that returns the
221
+ # translated data array. The method name is the snake_case of the
222
+ # original JS identifier (`createColumns` → `create_columns`).
223
+ # Param names come from the regular `props:` list so callers can
224
+ # invoke with keyword arguments matching the JS signature.
225
+ def render_data_factory_method(component, translator)
226
+ method_name = AST::Inflector.underscore(component.name)
227
+ param_names = component.props.map(&:name)
228
+ signature = data_factory_signature(method_name, param_names)
229
+ # Param refs translate as locals (`token`) inside the body rather
230
+ # than as ivars (`@token`) — the factory params are method-local,
231
+ # not constructor-stored. `with_locals` pushes the JS names onto
232
+ # the translator's local stack for the duration of the body.
233
+ body = translator.with_locals(param_names) do
234
+ render_inline_value(component.body, translator, todos: [], attr_name: nil, indent: 4)
235
+ end
236
+ " def #{signature}\n #{body}\n end"
127
237
  end
128
238
 
129
- def render_lambda_method_definitions(translator)
130
- return nil if @lambda_methods.nil? || @lambda_methods.empty?
239
+ def data_factory_signature(method_name, param_names)
240
+ return method_name if param_names.empty?
131
241
 
132
- defs = @lambda_methods.map do |entry|
242
+ kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
243
+ "#{method_name}(#{kwargs.join(", ")})"
244
+ end
245
+
246
+ # Coalesce RenderMethod (from local-arrow extraction) and Lambda
247
+ # (from Gap H object-literal extraction) into one `private` section.
248
+ # Emitting `private` twice is harmless but ugly; one block reads
249
+ # cleaner.
250
+ def render_private_methods(component, translator)
251
+ render_methods = component.render_methods.map { |rm| render_render_method_definition(rm, translator) }
252
+ lambda_methods = (@lambda_methods || []).map do |entry|
133
253
  render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
134
254
  end
135
- " private\n\n#{defs.join("\n\n")}"
255
+ all = render_methods + lambda_methods
256
+ return nil if all.empty?
257
+
258
+ " private\n\n#{all.join("\n\n")}"
259
+ end
260
+
261
+ # Emit one RenderMethod as a private method on the class. Params are
262
+ # pushed into the translator scope so identifier references inside
263
+ # the body resolve to method-local arguments.
264
+ def render_render_method_definition(render_method, translator)
265
+ snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
266
+ signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
267
+ body = translator.with_locals(render_method.params) do
268
+ render_ir_node(render_method.body, translator, indent: 4)
269
+ end
270
+ " def #{signature}\n#{body}\n end"
136
271
  end
137
272
 
138
273
  def render_lambda_method_definition(method_name, lambda, translator)
@@ -145,20 +280,30 @@ module JsxRosetta
145
280
  end
146
281
 
147
282
  def render_initializer(component, translator)
283
+ # Data-factory components consume their params as method args, not
284
+ # as constructor props — skip the initializer entirely.
285
+ return nil if component.mode == :data_factory
286
+
148
287
  props = initializable_props(component)
149
288
  rest_name = component.rest_prop_name
150
289
  return nil if props.empty? && rest_name.nil?
151
290
 
291
+ # Snake-case the rest-name kwarg so it matches the snake_case ivar
292
+ # the body uses (`**(@description_props || {})`). Emitting the
293
+ # camelCase JS name straight to the kwarg would create a different
294
+ # ivar than the body reads, silently dropping the splat's contents.
295
+ rest_snake = rest_name && AST::Inflector.underscore(rest_name)
152
296
  kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
153
- kwargs << "**#{rest_name}" if rest_name
297
+ kwargs << "**#{rest_snake}" if rest_snake
154
298
 
155
- assignments = props.map do |prop|
299
+ body = [" super()"]
300
+ body.concat(props.map do |prop|
156
301
  snake = AST::Inflector.underscore(prop.name)
157
302
  " @#{snake} = #{snake}"
158
- end
159
- assignments << " @#{rest_name} = #{rest_name}" if rest_name
303
+ end)
304
+ body << " @#{rest_snake} = #{rest_snake}" if rest_snake
160
305
 
161
- " def initialize(#{kwargs.join(", ")})\n#{assignments.join("\n")}\n end"
306
+ " def initialize(#{kwargs.join(", ")})\n#{body.join("\n")}\n end"
162
307
  end
163
308
 
164
309
  def initializable_props(component)
@@ -179,10 +324,13 @@ module JsxRosetta
179
324
  translated = translator.translate(prop.default.expression)
180
325
  translated ? translated.ruby : "nil"
181
326
  when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
182
- # Inline values: route through the recursive renderer. Pass an
183
- # empty todos array TODO markers wouldn't be safe inside a
184
- # parameter-list anyway.
185
- render_inline_value(prop.default, translator, todos: [], attr_name: prop.name)
327
+ # Inline values: route through the recursive renderer with
328
+ # `force_inline: true`. A wrapped multi-line default inside the
329
+ # `initialize(...)` parameter list would put the `{` at one
330
+ # column and the children at the indent-aligned column —
331
+ # legal Ruby but trips Layout/FirstHashElementIndentation. Empty
332
+ # todos array — TODO markers wouldn't survive a parameter list.
333
+ render_inline_value(prop.default, translator, todos: [], attr_name: prop.name, force_inline: true)
186
334
  else
187
335
  "nil"
188
336
  end
@@ -207,12 +355,19 @@ module JsxRosetta
207
355
  def render_react_hooks_todo(hooks)
208
356
  return [] if hooks.empty?
209
357
 
210
- lines = [
211
- "# TODO: React hooks detected. None translate automatically.",
212
- "# Hotwire/Stimulus handles behavior; controllers/views handle state;",
213
- "# turbo-frames handle async loading. Original source:"
214
- ]
215
- hooks.each { |hook| lines.concat(comment_lines(hook.source)) }
358
+ # Preserve the source order of the first occurrence per library so
359
+ # the React block (typical) lands before Apollo/Next.js blocks when
360
+ # all three are present. group_by preserves first-seen order.
361
+ hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls) }
362
+ end
363
+
364
+ def hook_todo_block_lines(library, calls)
365
+ header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
366
+ lines = header_lines.map { |line| "# #{line}" }
367
+ calls.each do |call|
368
+ lines << "# operation: #{call.operation}" if call.operation
369
+ lines.concat(comment_lines(call.source))
370
+ end
216
371
  lines
217
372
  end
218
373
 
@@ -249,6 +404,7 @@ module JsxRosetta
249
404
  when IR::Conditional then render_conditional(node, translator, indent: indent)
250
405
  when IR::Loop then render_loop(node, translator, indent: indent)
251
406
  when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
407
+ when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
252
408
  when IR::Slot then render_slot(node, indent: indent)
253
409
  when IR::Text then render_text(node, indent: indent)
254
410
  when IR::Interpolation then render_interpolation(node, translator, indent: indent)
@@ -256,6 +412,23 @@ module JsxRosetta
256
412
  end
257
413
  end
258
414
 
415
+ # Emit a call to a previously-extracted RenderMethod. The method body
416
+ # uses `tag.*`/`render` helpers (Phlex executes inside the view), so
417
+ # invoking it inline produces output at the right place in the
418
+ # template. Arg expressions are translated; any that fail translation
419
+ # fall back to verbatim source.
420
+ def render_local_render_call(call, translator, indent:)
421
+ if call.args.empty?
422
+ "#{spaces(indent)}#{call.method_name}"
423
+ else
424
+ arg_sources = call.args.map do |arg|
425
+ translated = translator.translate(arg.expression)
426
+ translated ? translated.ruby : arg.expression
427
+ end
428
+ "#{spaces(indent)}#{call.method_name}(#{arg_sources.join(", ")})"
429
+ end
430
+ end
431
+
259
432
  # An orphan RenderProp (i.e. one that didn't get consumed as a block
260
433
  # by a parent ComponentInvocation). Emit the body inline within the
261
434
  # appropriate translator scope; the param names are pushed but no
@@ -268,7 +441,7 @@ module JsxRosetta
268
441
 
269
442
  def render_element(element, translator, indent:)
270
443
  todos = []
271
- attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos)
444
+ attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos, indent: indent)
272
445
  method_call = "#{element.tag}#{attrs_source}"
273
446
 
274
447
  body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
@@ -283,7 +456,7 @@ module JsxRosetta
283
456
 
284
457
  def render_component_invocation(invocation, translator, indent:)
285
458
  todos = []
286
- kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos)
459
+ kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: indent)
287
460
  class_ref = component_class_reference(invocation.name)
288
461
  new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
289
462
 
@@ -335,17 +508,34 @@ module JsxRosetta
335
508
  end
336
509
 
337
510
  def render_conditional(conditional, translator, indent:)
338
- test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
339
511
  lines = []
512
+ emit_conditional_branches(conditional, translator, indent, lines, leading_keyword: "if")
513
+ lines << "#{spaces(indent)}end"
514
+ lines.join("\n")
515
+ end
516
+
517
+ # Walk a Conditional and its `alternate` chain, emitting `if` for the
518
+ # first test, `elsif` for each alternate that is itself a Conditional,
519
+ # and a final `else` for a non-Conditional alternate. Flattens the
520
+ # `if X / else / if Y / end / end` shape that JS `else if` chains
521
+ # produce into idiomatic Ruby `if X / elsif Y / else / end`. Without
522
+ # this, deeply nested conditional chains explode the file's
523
+ # indentation and trip Style/IfInsideElse + Metrics/BlockNesting.
524
+ def emit_conditional_branches(conditional, translator, indent, lines, leading_keyword:)
525
+ test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
340
526
  lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
341
- lines << "#{spaces(indent)}if #{test_ruby}"
527
+ lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
342
528
  lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
343
- if conditional.alternate
529
+
530
+ alt = conditional.alternate
531
+ return unless alt
532
+
533
+ if alt.is_a?(IR::Conditional)
534
+ emit_conditional_branches(alt, translator, indent, lines, leading_keyword: "elsif")
535
+ else
344
536
  lines << "#{spaces(indent)}else"
345
- lines << render_ir_node(conditional.alternate, translator, indent: indent + 2)
537
+ lines << render_ir_node(alt, translator, indent: indent + 2)
346
538
  end
347
- lines << "#{spaces(indent)}end"
348
- lines.join("\n")
349
539
  end
350
540
 
351
541
  def render_loop(loop_node, translator, indent:)
@@ -389,9 +579,15 @@ module JsxRosetta
389
579
  # can be emitted above the call. Without this, JS operators like
390
580
  # `!==`, `===`, optional chaining, and `in` would leak into the
391
581
  # emitted Ruby and produce SyntaxError on load.
582
+ #
583
+ # A translated value of `"nil"` is treated as untranslatable: the
584
+ # translator returns `"nil"` for known-local bindings (so the file
585
+ # loads as a leaf reference), but driving an `if` with `nil` silently
586
+ # disables the whole branch. Fall through to the TODO path instead so
587
+ # the human reviewer sees what needs filling in.
392
588
  def safe_test_expression(expression, translator, fallback:)
393
589
  translated = translator.translate(expression)
394
- return [translated.ruby, nil] if translated
590
+ return [translated.ruby, nil] if translated && translated.ruby != "nil"
395
591
 
396
592
  compact = expression.tr("\n", " ").squeeze(" ")
397
593
  [fallback, compact]
@@ -406,7 +602,7 @@ module JsxRosetta
406
602
  end
407
603
 
408
604
  def render_text(text, indent:)
409
- "#{spaces(indent)}plain #{text.value.inspect}"
605
+ "#{spaces(indent)}plain #{AST::Inflector.ruby_string_literal(text.value)}"
410
606
  end
411
607
 
412
608
  def render_interpolation(interpolation, translator, indent:)
@@ -443,8 +639,9 @@ module JsxRosetta
443
639
  # renders something visible at runtime.
444
640
  def render_untranslated_interpolation(expression, indent)
445
641
  compact = expression.tr("\n", " ").squeeze(" ")
642
+ placeholder = AST::Inflector.ruby_string_literal("[untranslated: #{compact}]")
446
643
  "#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
447
- "#{spaces(indent)}plain #{"[untranslated: #{compact}]".inspect}"
644
+ "#{spaces(indent)}plain #{placeholder}"
448
645
  end
449
646
 
450
647
  def render_comment(comment, indent:)
@@ -459,31 +656,30 @@ module JsxRosetta
459
656
  # of `h1()`). The `context:` param selects naming convention:
460
657
  # - :html (HTML element attrs — preserve camelCase for SVG)
461
658
  # - :component (Ruby method args — snake_case via Inflector.underscore)
462
- def format_attributes(attributes, translator, context: :html, todos: [])
659
+ def format_attributes(attributes, translator, context: :html, todos: [], indent: 0)
463
660
  events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
464
661
  spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
465
662
 
466
- sym_parts = []
467
- str_parts = []
663
+ parts = { sym: [], str: [] }
468
664
  plain_attrs.each do |a|
469
- append_attribute_part(a, translator, sym_parts, str_parts, context: context, todos: todos)
665
+ append_attribute_part(a, translator, parts, context: context, todos: todos, indent: indent)
470
666
  end
471
- sym_parts << data_action_entry(events, translator) if events.any?
667
+ parts[:sym] << data_action_entry(events, translator) if events.any?
472
668
 
473
- joined = build_attribute_list(sym_parts, str_parts, spreads, translator)
669
+ joined = build_attribute_list(parts, spreads, translator)
474
670
  joined.empty? ? "" : "(#{joined})"
475
671
  end
476
672
 
477
- def append_attribute_part(attribute, translator, sym_parts, str_parts, context:, todos:)
478
- part = phlex_attribute_part(attribute, translator, context: context, todos: todos)
673
+ def append_attribute_part(attribute, translator, parts, context:, todos:, indent: 0)
674
+ part = phlex_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
479
675
  return unless part
480
676
 
481
- (part[:string_key] ? str_parts : sym_parts) << part[:source]
677
+ (part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
482
678
  end
483
679
 
484
- def build_attribute_list(sym_parts, str_parts, spreads, translator)
485
- pieces = sym_parts.dup
486
- pieces << "**{ #{str_parts.join(", ")} }" if str_parts.any?
680
+ def build_attribute_list(parts, spreads, translator)
681
+ pieces = parts[:sym].dup
682
+ pieces << "**{ #{parts[:str].join(", ")} }" if parts[:str].any?
487
683
  pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
488
684
  pieces.join(", ")
489
685
  end
@@ -491,12 +687,13 @@ module JsxRosetta
491
687
  # Emit one attribute as either a {string_key: false, source: "id: @x"}
492
688
  # (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
493
689
  # (rare; non-identifier name — goes into a **{ ... } splat).
494
- def phlex_attribute_part(attribute, translator, context:, todos:)
690
+ def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0)
495
691
  case attribute
496
692
  when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
497
693
  when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
498
- when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator)}" }
499
- when IR::Attribute then plain_attribute_part(attribute, translator, context: context, todos: todos)
694
+ when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator, todos: todos)}" }
695
+ when IR::Attribute
696
+ plain_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
500
697
  end
501
698
  end
502
699
 
@@ -515,8 +712,8 @@ module JsxRosetta
515
712
  # follow snake_case convention (`defaultValue` → `default_value`).
516
713
  # Names that aren't valid Ruby identifiers after conversion (rare:
517
714
  # `xml:lang` and friends) fall back to a quoted string key.
518
- def plain_attribute_part(attribute, translator, context:, todos:)
519
- value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos)
715
+ def plain_attribute_part(attribute, translator, context:, todos:, indent: 0)
716
+ value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos, indent: indent)
520
717
  ruby_name = case context
521
718
  when :component then AST::Inflector.underscore(attribute.name)
522
719
  else attribute.name.tr("-", "_")
@@ -524,17 +721,17 @@ module JsxRosetta
524
721
  if ruby_name.match?(VALID_IDENTIFIER)
525
722
  { string_key: false, source: "#{ruby_name}: #{value_ruby}" }
526
723
  else
527
- { string_key: true, source: "#{attribute.name.inspect} => #{value_ruby}" }
724
+ { string_key: true, source: "#{AST::Inflector.ruby_string_literal(attribute.name)} => #{value_ruby}" }
528
725
  end
529
726
  end
530
727
 
531
- def attribute_value_to_ruby(name, value, translator, todos:)
728
+ def attribute_value_to_ruby(name, value, translator, todos:, indent: 0)
532
729
  case value
533
730
  when true then "true"
534
- when String then value.inspect
731
+ when String then AST::Inflector.ruby_string_literal(value)
535
732
  when IR::Interpolation then interpolated_attribute_value(name, value, translator, todos: todos)
536
- when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
537
- when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
733
+ when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos, indent: indent)
734
+ when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos, indent: indent)
538
735
  when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
539
736
  end
540
737
  end
@@ -542,41 +739,75 @@ module JsxRosetta
542
739
  # Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
543
740
  # entries become Ruby kwargs (snake_cased to match Ruby convention);
544
741
  # non-identifier keys (numeric, hyphenated) fall back to string keys.
545
- def render_object_literal_value(object_literal, translator, todos:)
742
+ # When the single-line rendering exceeds LITERAL_INLINE_BUDGET, or
743
+ # when any rendered child value spans multiple lines, the layout
744
+ # switches to one entry per line, indented two spaces past `indent`.
745
+ def render_object_literal_value(object_literal, translator, todos:, indent: 0, force_inline: false)
746
+ child_indent = indent + 2
546
747
  parts = object_literal.properties.map do |(key, value)|
547
- render_object_property(key, value, translator, todos: todos)
748
+ render_object_property(key, value, translator, todos: todos, indent: child_indent, force_inline: force_inline)
548
749
  end
549
- "{ #{parts.join(", ")} }"
750
+ wrap_literal_parts(parts, open: "{", close: "}", indent: indent, inline_sep: ", ", inline_pad: " ",
751
+ force_inline: force_inline)
550
752
  end
551
753
 
552
- def render_object_property(key, value, translator, todos:)
553
- value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key)
754
+ def render_object_property(key, value, translator, todos:, indent: 0, force_inline: false)
755
+ value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key, indent: indent,
756
+ force_inline: force_inline)
554
757
  snake = AST::Inflector.underscore(key)
555
758
  if snake.match?(VALID_IDENTIFIER)
556
759
  "#{snake}: #{value_ruby}"
557
760
  else
558
- "#{key.inspect} => #{value_ruby}"
761
+ "#{AST::Inflector.ruby_string_literal(key)} => #{value_ruby}"
559
762
  end
560
763
  end
561
764
 
562
- def render_array_literal_value(array_literal, translator, todos:)
765
+ def render_array_literal_value(array_literal, translator, todos:, indent: 0, force_inline: false)
766
+ child_indent = indent + 2
563
767
  parts = array_literal.elements.map do |el|
564
- el.nil? ? "nil" : render_inline_value(el, translator, todos: todos, attr_name: nil)
768
+ if el.nil?
769
+ "nil"
770
+ else
771
+ render_inline_value(el, translator, todos: todos, attr_name: nil, indent: child_indent,
772
+ force_inline: force_inline)
773
+ end
565
774
  end
566
- "[#{parts.join(", ")}]"
775
+ wrap_literal_parts(parts, open: "[", close: "]", indent: indent, inline_sep: ", ", inline_pad: "",
776
+ force_inline: force_inline)
777
+ end
778
+
779
+ # Pick single-line vs multi-line layout for a rendered literal.
780
+ # Multi-line is forced when any rendered part already contains a
781
+ # newline (a nested literal that wrapped); otherwise we wrap only
782
+ # when the single-line form exceeds LITERAL_INLINE_BUDGET.
783
+ def wrap_literal_parts(parts, **opts)
784
+ open = opts[:open]
785
+ close = opts[:close]
786
+ return "#{open}#{close}" if parts.empty?
787
+
788
+ inline = "#{open}#{opts[:inline_pad]}#{parts.join(opts[:inline_sep])}#{opts[:inline_pad]}#{close}"
789
+ any_multiline = parts.any? { |p| p.include?("\n") }
790
+ return inline if opts[:force_inline]
791
+ return inline if !any_multiline && inline.length <= LITERAL_INLINE_BUDGET
792
+
793
+ child_pad = " " * (opts[:indent] + 2)
794
+ close_pad = " " * opts[:indent]
795
+ "#{open}\n#{child_pad}#{parts.join(",\n#{child_pad}")}\n#{close_pad}#{close}"
567
796
  end
568
797
 
569
798
  # An inline value can appear as a kwarg value, an array element, or a
570
799
  # hash property value. Recursive shapes route back through the new IR
571
800
  # types; primitives fall through the same paths as attribute_value_to_ruby.
572
- def render_inline_value(value, translator, todos:, attr_name:)
801
+ def render_inline_value(value, translator, todos:, attr_name:, indent: 0, force_inline: false)
573
802
  case value
574
- when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
575
- when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
803
+ when IR::ObjectLiteral
804
+ render_object_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
805
+ when IR::ArrayLiteral
806
+ render_array_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
576
807
  when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
577
808
  when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
578
809
  todos: todos)
579
- when String then value.inspect
810
+ when String then AST::Inflector.ruby_string_literal(value)
580
811
  when true then "true"
581
812
  else
582
813
  "nil"
@@ -612,43 +843,51 @@ module JsxRosetta
612
843
  @lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
613
844
  end
614
845
 
615
- # Attribute-position interpolation. Two failure modes:
616
- # 1. Translator returns non-nil but with unresolved identifiers —
617
- # the Ruby reference is fine to emit (it'll surface as a
618
- # NameError at render time if it's wrong). Inline TODO comments
619
- # aren't safe in attribute position (would break hash splat or
620
- # method-call syntax), so the marker is suppressed.
621
- # 2. Translator returns nil — the original JS expression couldn't
846
+ # Attribute-position interpolation. Three failure modes:
847
+ # 1. Translator returns non-nil, no unresolved identifiers — emit
848
+ # the Ruby reference directly. Common case.
849
+ # 2. Translator returns non-nil but with unresolved identifiers
850
+ # starting with uppercase (PascalCase / SCREAMING_SNAKE_CASE
851
+ # almost always imported constants or enums) — emitting bare
852
+ # `default_page_size` from `DEFAULT_PAGE_SIZE` produces a
853
+ # runtime NameError with no marker. Drop the value to `nil`
854
+ # and surface a TODO with the verbatim source. Lowercase
855
+ # unresolved identifiers may be Rails helpers (`current_user`)
856
+ # and are passed through as before.
857
+ # 3. Translator returns nil — the original JS expression couldn't
622
858
  # be parsed at all (e.g. `<LeftOutlined .../>`, array literals,
623
- # template literals with method calls). We emit `nil` for the
624
- # kwarg AND record the original expression in `todos` so the
625
- # caller can prepend a `# TODO:` comment line above the element.
859
+ # template literals with method calls). Same TODO + nil path.
626
860
  def interpolated_attribute_value(name, value, translator, todos:)
627
861
  translated = translator.translate(value.expression)
628
- return translated.ruby if translated
862
+ if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
863
+ return translated.ruby
864
+ end
629
865
 
630
866
  compact = value.expression.tr("\n", " ").squeeze(" ")
631
867
  todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
632
868
  "nil"
633
869
  end
634
870
 
635
- def component_invocation_kwargs(props, translator, todos: [])
871
+ def uppercase_unresolved?(unresolved_identifiers)
872
+ unresolved_identifiers.any? { |name| name[0] == name[0].upcase }
873
+ end
874
+
875
+ def component_invocation_kwargs(props, translator, todos: [], indent: 0)
636
876
  events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
637
877
  spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
638
878
 
639
- sym_parts = []
640
- str_parts = []
879
+ parts = { sym: [], str: [] }
641
880
  plain_attrs.each do |a|
642
- append_attribute_part(a, translator, sym_parts, str_parts, context: :component, todos: todos)
881
+ append_attribute_part(a, translator, parts, context: :component, todos: todos, indent: indent)
643
882
  end
644
- sym_parts << data_action_entry(events, translator) if events.any?
883
+ parts[:sym] << data_action_entry(events, translator) if events.any?
645
884
 
646
- build_attribute_list(sym_parts, str_parts, spreads, translator)
885
+ build_attribute_list(parts, spreads, translator)
647
886
  end
648
887
 
649
888
  def class_list_to_ruby_string(class_list, translator)
650
889
  parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
651
- %("#{parts.join(" ")}")
890
+ wrap_concatenated_string(parts.join(" "))
652
891
  end
653
892
 
654
893
  def class_segment_to_ruby(segment, translator)
@@ -660,23 +899,46 @@ module JsxRosetta
660
899
  when IR::ConditionalSegment
661
900
  cond_translated = translator.translate(segment.condition.expression)
662
901
  cond_ruby = cond_translated&.ruby || segment.condition.expression
663
- %(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
902
+ %(\#{#{cond_ruby} ? #{AST::Inflector.ruby_string_literal(segment.class_name)} : ''})
664
903
  end
665
904
  end
666
905
 
667
- def style_to_ruby_string(style, translator)
668
- parts = style.declarations.map { |decl| style_declaration_to_ruby(decl, translator) }
669
- %("#{parts.join(" ")}")
906
+ def style_to_ruby_string(style, translator, todos: [])
907
+ parts = style.declarations.filter_map { |decl| style_declaration_to_ruby(decl, translator, todos: todos) }
908
+ wrap_concatenated_string(parts.join(" "))
670
909
  end
671
910
 
672
- def style_declaration_to_ruby(decl, translator)
673
- value = case decl.value
674
- when String then decl.value
675
- when IR::Interpolation
676
- translated = translator.translate(decl.value.expression)
677
- "\#{#{translated&.ruby || decl.value.expression}}"
678
- end
679
- "#{decl.property}: #{value};"
911
+ # Wrap a built-up Ruby string body in single quotes when it contains
912
+ # no interpolation (`\#{...}`) and no escaping pitfalls; otherwise
913
+ # use double quotes so the interpolation is honored. Keeps class /
914
+ # style attribute output passing Style/StringLiterals when no
915
+ # dynamic segments are present (the common case for hardcoded
916
+ # `style="margin-bottom: 16px"`).
917
+ def wrap_concatenated_string(body)
918
+ return %("#{body}") if body.include?("\#{") || body.include?("'") || body.include?("\\")
919
+
920
+ "'#{body}'"
921
+ end
922
+
923
+ def style_declaration_to_ruby(decl, translator, todos: [])
924
+ case decl.value
925
+ when String
926
+ "#{decl.property}: #{decl.value};"
927
+ when IR::Interpolation
928
+ translated = translator.translate(decl.value.expression)
929
+ # Translation failed (e.g., interpolation rooted at an
930
+ # unresolvable local). Emitting the verbatim JS source inside
931
+ # `\#{}` would render valid Ruby that NameErrors at runtime.
932
+ # Drop the declaration and surface a TODO above the element so
933
+ # the reviewer sees what was lost.
934
+ if translated.nil?
935
+ todos << "style declaration #{decl.property.inspect} dropped — " \
936
+ "couldn't translate: #{decl.value.expression}"
937
+ nil
938
+ else
939
+ "#{decl.property}: \#{#{translated.ruby}};"
940
+ end
941
+ end
680
942
  end
681
943
 
682
944
  # Wrap the spread expression in `(… || {})` so a nil-valued prop
@@ -712,7 +974,7 @@ module JsxRosetta
712
974
  end
713
975
 
714
976
  def render_single_event_descriptor(descriptor)
715
- descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
977
+ descriptor.kind == :literal ? AST::Inflector.ruby_string_literal(descriptor.body) : descriptor.body
716
978
  end
717
979
 
718
980
  def descriptor_in_string(descriptor)