jsx_rosetta 0.3.0 → 0.4.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.
@@ -0,0 +1,756 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/inflector"
4
+ require_relative "../ir/types"
5
+ require_relative "base"
6
+ require_relative "view_component/expression_translator"
7
+
8
+ module JsxRosetta
9
+ module Backend
10
+ # Emits a Phlex 2.x view class (one Ruby file per component) from
11
+ # an IR::Component. Single-file output by design — the JSX `<h1>...`
12
+ # template lives as Ruby inside `view_template`, not in a sibling
13
+ # .erb. When the source uses `onClick`/`onChange` etc., a sibling
14
+ # Stimulus controller `_controller.js` is emitted alongside (same
15
+ # convention as the ViewComponent backend).
16
+ #
17
+ # Naming strategies (mutually exclusive):
18
+ # default class FlashyHeader < Phlex::HTML
19
+ # suffix: "Component" class FlashyHeaderComponent < Phlex::HTML
20
+ # namespace: "Components" module Components
21
+ # class FlashyHeader < Phlex::HTML
22
+ #
23
+ # Hyphenated attributes (`data-testid`, `aria-label`, etc.) emit as
24
+ # string-keyed hash entries inside a splat — `**{ "data-testid" => @x }`
25
+ # — since Ruby kwargs can't carry hyphens. Snake_case-friendly attrs
26
+ # emit as regular keyword arguments.
27
+ class Phlex < Base
28
+ DEFAULT_SLOT_NAME = "children"
29
+ DEFAULT_SUFFIX = "Component"
30
+ PHLEX_BASE_CLASS = "Phlex::HTML"
31
+ VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
32
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
33
+
34
+ # Structured intermediate for the data-action attribute — mirrors the
35
+ # ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
36
+ EventDescriptor = Data.define(:kind, :body)
37
+
38
+ def initialize(suffix: nil, namespace: nil)
39
+ super()
40
+ raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
41
+
42
+ @suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
43
+ @namespace = namespace
44
+ end
45
+
46
+ 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
+
53
+ @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
54
+ @lambda_methods = []
55
+ @lambda_method_counts = {}
56
+
57
+ files = [File.new(path: ruby_path(component), contents: render_ruby_class(component, translator))]
58
+ if component.stimulus_methods.any?
59
+ files << File.new(
60
+ path: stimulus_path(component),
61
+ contents: render_stimulus_controller_js(component)
62
+ )
63
+ end
64
+ files
65
+ end
66
+
67
+ private
68
+
69
+ # JSX-returning lowercase helpers (e.g. `textRender`, `getNodeIcon`)
70
+ # have lowercase-starting names. Ruby class names must be constants
71
+ # (begin with an uppercase letter), so we capitalize the first
72
+ # letter when forming the class name. Pure-PascalCase names pass
73
+ # through unchanged.
74
+ def class_name(component)
75
+ base = "#{component.name[0].upcase}#{component.name[1..]}"
76
+ @suffix ? "#{base}#{@suffix}" : base
77
+ end
78
+
79
+ def ruby_path(component)
80
+ "#{AST::Inflector.underscore(class_name(component))}.rb"
81
+ end
82
+
83
+ def stimulus_path(component)
84
+ "#{AST::Inflector.underscore(class_name(component))}_controller.js"
85
+ end
86
+
87
+ def stimulus_identifier(component)
88
+ AST::Inflector.underscore(component.name).tr("_", "-")
89
+ end
90
+
91
+ def render_ruby_class(component, translator)
92
+ class_body = render_class_body(component, translator)
93
+ prefix = render_module_bindings_prefix(component)
94
+ wrap_in_namespace("#{prefix}#{class_body}")
95
+ end
96
+
97
+ # Top-level `const`/`let` declarations outside the component
98
+ # function — captured at lowering time and surfaced here as a TODO
99
+ # comment block above the class definition. We don't try to
100
+ # translate the JS; the human reviewer either copies the value as
101
+ # a Ruby constant or moves it to a Rails initializer.
102
+ def render_module_bindings_prefix(component)
103
+ return "" if component.module_bindings.empty?
104
+
105
+ lines = ["# TODO: module-level constants — translate to Ruby constants " \
106
+ "or move to a Rails initializer:"]
107
+ component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
108
+ "#{lines.join("\n")}\n"
109
+ end
110
+
111
+ def wrap_in_namespace(body)
112
+ return "# frozen_string_literal: true\n\n#{body}" unless @namespace
113
+
114
+ indented = body.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
115
+ "# frozen_string_literal: true\n\nmodule #{@namespace}\n#{indented}end\n"
116
+ end
117
+
118
+ def render_class_body(component, translator)
119
+ 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"
127
+ end
128
+
129
+ def render_lambda_method_definitions(translator)
130
+ return nil if @lambda_methods.nil? || @lambda_methods.empty?
131
+
132
+ defs = @lambda_methods.map do |entry|
133
+ render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
134
+ end
135
+ " private\n\n#{defs.join("\n\n")}"
136
+ end
137
+
138
+ def render_lambda_method_definition(method_name, lambda, translator)
139
+ snake_params = lambda.params.map { |p| AST::Inflector.underscore(p) }
140
+ signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
141
+ body = translator.with_locals(lambda.params) do
142
+ render_ir_node(lambda.body, translator, indent: 4)
143
+ end
144
+ " def #{signature}\n#{body}\n end"
145
+ end
146
+
147
+ def render_initializer(component, translator)
148
+ props = initializable_props(component)
149
+ rest_name = component.rest_prop_name
150
+ return nil if props.empty? && rest_name.nil?
151
+
152
+ kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
153
+ kwargs << "**#{rest_name}" if rest_name
154
+
155
+ assignments = props.map do |prop|
156
+ snake = AST::Inflector.underscore(prop.name)
157
+ " @#{snake} = #{snake}"
158
+ end
159
+ assignments << " @#{rest_name} = #{rest_name}" if rest_name
160
+
161
+ " def initialize(#{kwargs.join(", ")})\n#{assignments.join("\n")}\n end"
162
+ end
163
+
164
+ def initializable_props(component)
165
+ component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
166
+ end
167
+
168
+ def ruby_kwarg(prop, translator)
169
+ snake_name = AST::Inflector.underscore(prop.name)
170
+ default = ruby_default_for(prop, translator)
171
+ "#{snake_name}: #{default}"
172
+ end
173
+
174
+ def ruby_default_for(prop, translator)
175
+ return "nil" if prop.default.nil?
176
+
177
+ case prop.default
178
+ when IR::Interpolation
179
+ translated = translator.translate(prop.default.expression)
180
+ translated ? translated.ruby : "nil"
181
+ 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)
186
+ else
187
+ "nil"
188
+ end
189
+ end
190
+
191
+ def render_view_template(component, translator)
192
+ body = render_template_body(component, translator)
193
+ prefix = render_template_prefix(component)
194
+ body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
195
+ " def view_template\n#{body_with_prefix}\n end"
196
+ end
197
+
198
+ def render_template_prefix(component)
199
+ lines = []
200
+ lines.concat(render_react_hooks_todo(component.react_hooks))
201
+ lines.concat(render_local_bindings_todo(component.local_bindings))
202
+ return "" if lines.empty?
203
+
204
+ "#{lines.map { |l| " #{l}" }.join("\n")}\n"
205
+ end
206
+
207
+ def render_react_hooks_todo(hooks)
208
+ return [] if hooks.empty?
209
+
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)) }
216
+ lines
217
+ end
218
+
219
+ def render_local_bindings_todo(bindings)
220
+ return [] if bindings.empty?
221
+
222
+ unique_sources = bindings.map(&:source).uniq
223
+ ["# TODO: translate JS to Ruby — original:"] + unique_sources.flat_map { |src| comment_lines(src) }
224
+ end
225
+
226
+ # Prefix every line of `source` with `# ` so multi-line JS bodies
227
+ # remain inside a Ruby comment block (single `#` on the first line
228
+ # would leave subsequent lines as bare Ruby and break parsing).
229
+ def comment_lines(source)
230
+ source.split("\n").map { |line| "# #{line}" }
231
+ end
232
+
233
+ def render_template_body(component, translator)
234
+ root = component.body
235
+ root = decorate_with_stimulus_controller(root) if component.stimulus_methods.any? && root.is_a?(IR::Element)
236
+ render_ir_node(root, translator, indent: 4)
237
+ end
238
+
239
+ def decorate_with_stimulus_controller(element)
240
+ attr = IR::Attribute.new(name: "data-controller", value: @stimulus_identifier)
241
+ IR::Element.new(tag: element.tag, attributes: [attr] + element.attributes, children: element.children)
242
+ end
243
+
244
+ def render_ir_node(node, translator, indent:)
245
+ case node
246
+ when IR::Element then render_element(node, translator, indent: indent)
247
+ when IR::ComponentInvocation then render_component_invocation(node, translator, indent: indent)
248
+ when IR::Fragment then render_fragment(node, translator, indent: indent)
249
+ when IR::Conditional then render_conditional(node, translator, indent: indent)
250
+ when IR::Loop then render_loop(node, translator, indent: indent)
251
+ when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
252
+ when IR::Slot then render_slot(node, indent: indent)
253
+ when IR::Text then render_text(node, indent: indent)
254
+ when IR::Interpolation then render_interpolation(node, translator, indent: indent)
255
+ when IR::Comment then render_comment(node, indent: indent)
256
+ end
257
+ end
258
+
259
+ # An orphan RenderProp (i.e. one that didn't get consumed as a block
260
+ # by a parent ComponentInvocation). Emit the body inline within the
261
+ # appropriate translator scope; the param names are pushed but no
262
+ # block syntax is generated.
263
+ def render_orphan_render_prop(render_prop, translator, indent:)
264
+ translator.with_locals(render_prop.params) do
265
+ render_ir_node(render_prop.body, translator, indent: indent)
266
+ end
267
+ end
268
+
269
+ def render_element(element, translator, indent:)
270
+ todos = []
271
+ attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos)
272
+ method_call = "#{element.tag}#{attrs_source}"
273
+
274
+ body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
275
+ "#{spaces(indent)}#{method_call}"
276
+ else
277
+ inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
278
+ "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
279
+ end
280
+
281
+ prepend_attribute_todos(todos, indent, body)
282
+ end
283
+
284
+ def render_component_invocation(invocation, translator, indent:)
285
+ todos = []
286
+ kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos)
287
+ class_ref = component_class_reference(invocation.name)
288
+ new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
289
+
290
+ render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
291
+ body = if render_prop
292
+ render_with_render_prop(new_call, render_prop, translator, indent)
293
+ elsif invocation.children.empty?
294
+ "#{spaces(indent)}render #{new_call}"
295
+ else
296
+ inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
297
+ "#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
298
+ end
299
+
300
+ prepend_attribute_todos(todos, indent, body)
301
+ end
302
+
303
+ # Emit a render-prop child as a Ruby block on the parent `render` call.
304
+ # `<Form.List>{(fields) => <p/>}</Form.List>` →
305
+ # `render Form::List.new do |fields|\n p\nend`. Param names are
306
+ # snake_cased and pushed into the translator scope so identifier
307
+ # references inside the body resolve to the block locals.
308
+ def render_with_render_prop(new_call, render_prop, translator, indent)
309
+ snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
310
+ param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
311
+ inner = translator.with_locals(render_prop.params) do
312
+ render_ir_node(render_prop.body, translator, indent: indent + 2)
313
+ end
314
+ "#{spaces(indent)}render #{new_call} do#{param_str}\n#{inner}\n#{spaces(indent)}end"
315
+ end
316
+
317
+ def prepend_attribute_todos(todos, indent, body)
318
+ return body if todos.empty?
319
+
320
+ prefix = todos.map { |t| "#{spaces(indent)}# TODO: #{t}" }.join("\n")
321
+ "#{prefix}\n#{body}"
322
+ end
323
+
324
+ # JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
325
+ # `Foo` again under namespace (Ruby's constant lookup finds the
326
+ # peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
327
+ def component_class_reference(jsx_tag)
328
+ segments = jsx_tag.split(".")
329
+ segments[-1] = "#{segments.last}#{@suffix}" if @suffix
330
+ segments.join("::")
331
+ end
332
+
333
+ def render_fragment(fragment, translator, indent:)
334
+ fragment.children.map { |child| render_ir_node(child, translator, indent: indent) }.join("\n")
335
+ end
336
+
337
+ def render_conditional(conditional, translator, indent:)
338
+ test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
339
+ lines = []
340
+ lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
341
+ lines << "#{spaces(indent)}if #{test_ruby}"
342
+ lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
343
+ if conditional.alternate
344
+ lines << "#{spaces(indent)}else"
345
+ lines << render_ir_node(conditional.alternate, translator, indent: indent + 2)
346
+ end
347
+ lines << "#{spaces(indent)}end"
348
+ lines.join("\n")
349
+ end
350
+
351
+ def render_loop(loop_node, translator, indent:)
352
+ iterable_ruby, todo = render_loop_iterable(loop_node.iterable, translator)
353
+ js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
354
+ ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
355
+ binding_str = ruby_bindings.size == 1 ? "|#{ruby_bindings.first}|" : "|#{ruby_bindings.join(", ")}|"
356
+
357
+ body = translator.with_locals(js_bindings) do
358
+ render_ir_node(loop_node.body, translator, indent: indent + 2)
359
+ end
360
+
361
+ lines = []
362
+ lines << "#{spaces(indent)}# TODO: translate iterable: #{todo}" if todo
363
+ lines << "#{spaces(indent)}#{iterable_ruby}.each do #{binding_str}"
364
+ lines << body
365
+ lines << "#{spaces(indent)}end"
366
+ lines.join("\n")
367
+ end
368
+
369
+ # Translate the iterable side of a `.each` call. Handles both the
370
+ # traditional Interpolation form and the new ArrayLiteral form
371
+ # (literal array `.map(...)`) introduced by Gap H. Returns
372
+ # [ruby_source, todo_text]; todo_text is nil when translation succeeded.
373
+ def render_loop_iterable(iterable, translator)
374
+ case iterable
375
+ when IR::ArrayLiteral
376
+ [render_array_literal_value(iterable, translator, todos: []), nil]
377
+ when IR::Interpolation
378
+ safe_test_expression(iterable.expression, translator, fallback: "[]")
379
+ else
380
+ ["[]", iterable.inspect]
381
+ end
382
+ end
383
+
384
+ # Translate an expression intended to drive an `if` or `.each` call.
385
+ # Returns `[ruby_source, todo_text]`. When the translator can parse
386
+ # the expression, `todo_text` is nil. When it can't, the caller's
387
+ # `fallback` (e.g. `"false"` for conditions, `"[]"` for iterables)
388
+ # is returned along with the original expression so a TODO comment
389
+ # can be emitted above the call. Without this, JS operators like
390
+ # `!==`, `===`, optional chaining, and `in` would leak into the
391
+ # emitted Ruby and produce SyntaxError on load.
392
+ def safe_test_expression(expression, translator, fallback:)
393
+ translated = translator.translate(expression)
394
+ return [translated.ruby, nil] if translated
395
+
396
+ compact = expression.tr("\n", " ").squeeze(" ")
397
+ [fallback, compact]
398
+ end
399
+
400
+ def render_slot(slot, indent:)
401
+ if slot.name == DEFAULT_SLOT_NAME
402
+ "#{spaces(indent)}yield"
403
+ else
404
+ "#{spaces(indent)}# TODO: named slot #{slot.name.inspect}"
405
+ end
406
+ end
407
+
408
+ def render_text(text, indent:)
409
+ "#{spaces(indent)}plain #{text.value.inspect}"
410
+ end
411
+
412
+ def render_interpolation(interpolation, translator, indent:)
413
+ translated = translator.translate(interpolation.expression)
414
+ return render_untranslated_interpolation(interpolation.expression, indent) unless translated
415
+
416
+ unresolved = translated.unresolved_identifiers
417
+ if unresolved.empty?
418
+ "#{spaces(indent)}plain #{translated.ruby}#{react_node_hint(translated.ruby)}"
419
+ else
420
+ names = unresolved.map(&:inspect).join(", ")
421
+ "#{spaces(indent)}# TODO: unresolved identifier #{names}\n" \
422
+ "#{spaces(indent)}plain #{translated.ruby}"
423
+ end
424
+ end
425
+
426
+ # Gap G: when the translated value is a bare `@ivar` reference, the
427
+ # prop may be a ReactNode (children-typed prop) rather than a plain
428
+ # string. `plain` HTML-escapes its argument; `raw` doesn't. We can't
429
+ # tell at translation time which is intended, so default to `plain`
430
+ # (safe for string props) and emit a comment hint pointing at `raw`.
431
+ def react_node_hint(ruby)
432
+ return "" unless ruby.is_a?(String)
433
+ return "" unless ruby.match?(/\A@[a-z_][a-z0-9_]*\z/)
434
+
435
+ " # NOTE: use `raw` instead of `plain` if this is a ReactNode-typed prop"
436
+ end
437
+
438
+ # The original JS expression couldn't be translated to Ruby. We can't
439
+ # emit `plain <verbatim-JS>` because raw JS (TypeScript casts, JSX
440
+ # method chains, ternary spreads, etc.) usually isn't valid Ruby.
441
+ # Instead, emit two safe lines: a `# TODO:` comment naming the
442
+ # expression, then a string-literal placeholder so the template still
443
+ # renders something visible at runtime.
444
+ def render_untranslated_interpolation(expression, indent)
445
+ compact = expression.tr("\n", " ").squeeze(" ")
446
+ "#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
447
+ "#{spaces(indent)}plain #{"[untranslated: #{compact}]".inspect}"
448
+ end
449
+
450
+ def render_comment(comment, indent:)
451
+ # Multi-line JSX comments need every line prefixed with `# ` — a
452
+ # bare first-line `#` would leave subsequent lines as Ruby code.
453
+ comment.text.split("\n").map { |line| "#{spaces(indent)}# #{line}" }.join("\n")
454
+ end
455
+
456
+ # Build the Ruby attribute list — `(id: @id, class: @class, ...)` —
457
+ # to splice immediately after the tag method name. Returns "" when
458
+ # there are no attributes (so the caller emits a bare `h1` instead
459
+ # of `h1()`). The `context:` param selects naming convention:
460
+ # - :html (HTML element attrs — preserve camelCase for SVG)
461
+ # - :component (Ruby method args — snake_case via Inflector.underscore)
462
+ def format_attributes(attributes, translator, context: :html, todos: [])
463
+ events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
464
+ spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
465
+
466
+ sym_parts = []
467
+ str_parts = []
468
+ plain_attrs.each do |a|
469
+ append_attribute_part(a, translator, sym_parts, str_parts, context: context, todos: todos)
470
+ end
471
+ sym_parts << data_action_entry(events, translator) if events.any?
472
+
473
+ joined = build_attribute_list(sym_parts, str_parts, spreads, translator)
474
+ joined.empty? ? "" : "(#{joined})"
475
+ end
476
+
477
+ def append_attribute_part(attribute, translator, sym_parts, str_parts, context:, todos:)
478
+ part = phlex_attribute_part(attribute, translator, context: context, todos: todos)
479
+ return unless part
480
+
481
+ (part[:string_key] ? str_parts : sym_parts) << part[:source]
482
+ end
483
+
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?
487
+ pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
488
+ pieces.join(", ")
489
+ end
490
+
491
+ # Emit one attribute as either a {string_key: false, source: "id: @x"}
492
+ # (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
493
+ # (rare; non-identifier name — goes into a **{ ... } splat).
494
+ def phlex_attribute_part(attribute, translator, context:, todos:)
495
+ case attribute
496
+ when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
497
+ 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)
500
+ end
501
+ end
502
+
503
+ def class_attribute_part(expression, translator)
504
+ translated = translator.translate(expression)
505
+ ruby = translated ? translated.ruby : expression.inspect
506
+ { string_key: false, source: "class: #{ruby}" }
507
+ end
508
+
509
+ # Map a JSX attribute name to its Ruby kwarg form. For HTML element
510
+ # attrs (`context: :html`), only hyphens convert to underscores —
511
+ # camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
512
+ # so SVG attributes render correctly through Phlex. For component
513
+ # invocations (`context: :component`), full Inflector.underscore
514
+ # converts both hyphens AND camelCase, since Ruby method args
515
+ # follow snake_case convention (`defaultValue` → `default_value`).
516
+ # Names that aren't valid Ruby identifiers after conversion (rare:
517
+ # `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)
520
+ ruby_name = case context
521
+ when :component then AST::Inflector.underscore(attribute.name)
522
+ else attribute.name.tr("-", "_")
523
+ end
524
+ if ruby_name.match?(VALID_IDENTIFIER)
525
+ { string_key: false, source: "#{ruby_name}: #{value_ruby}" }
526
+ else
527
+ { string_key: true, source: "#{attribute.name.inspect} => #{value_ruby}" }
528
+ end
529
+ end
530
+
531
+ def attribute_value_to_ruby(name, value, translator, todos:)
532
+ case value
533
+ when true then "true"
534
+ when String then value.inspect
535
+ 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)
538
+ when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
539
+ end
540
+ end
541
+
542
+ # Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
543
+ # entries become Ruby kwargs (snake_cased to match Ruby convention);
544
+ # non-identifier keys (numeric, hyphenated) fall back to string keys.
545
+ def render_object_literal_value(object_literal, translator, todos:)
546
+ parts = object_literal.properties.map do |(key, value)|
547
+ render_object_property(key, value, translator, todos: todos)
548
+ end
549
+ "{ #{parts.join(", ")} }"
550
+ end
551
+
552
+ def render_object_property(key, value, translator, todos:)
553
+ value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key)
554
+ snake = AST::Inflector.underscore(key)
555
+ if snake.match?(VALID_IDENTIFIER)
556
+ "#{snake}: #{value_ruby}"
557
+ else
558
+ "#{key.inspect} => #{value_ruby}"
559
+ end
560
+ end
561
+
562
+ def render_array_literal_value(array_literal, translator, todos:)
563
+ parts = array_literal.elements.map do |el|
564
+ el.nil? ? "nil" : render_inline_value(el, translator, todos: todos, attr_name: nil)
565
+ end
566
+ "[#{parts.join(", ")}]"
567
+ end
568
+
569
+ # An inline value can appear as a kwarg value, an array element, or a
570
+ # hash property value. Recursive shapes route back through the new IR
571
+ # types; primitives fall through the same paths as attribute_value_to_ruby.
572
+ def render_inline_value(value, translator, todos:, attr_name:)
573
+ 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)
576
+ when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
577
+ when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
578
+ todos: todos)
579
+ when String then value.inspect
580
+ when true then "true"
581
+ else
582
+ "nil"
583
+ end
584
+ end
585
+
586
+ # An IR::Lambda lives inside an object/array literal as a value. We
587
+ # extract it to a method on the class (so it has access to the
588
+ # Phlex tag.* helpers) and reference it via `method(:name)` in the
589
+ # value position. Method names are deterministic so re-runs produce
590
+ # stable output: `<attr-name>_renderer<N>` where N is a per-attr index.
591
+ def render_lambda_method_reference(lambda, translator, attr_name:)
592
+ @lambda_methods ||= []
593
+ base = lambda_method_base(attr_name)
594
+ @lambda_methods << { base: base, lambda: lambda, translator: translator }
595
+ # Index is the position within `@lambda_methods` so re-renders are
596
+ # deterministic in the order encountered.
597
+ method_name = unique_lambda_method_name(base)
598
+ @lambda_methods.last[:method_name] = method_name
599
+ "method(:#{method_name})"
600
+ end
601
+
602
+ def lambda_method_base(attr_name)
603
+ return "render_lambda" if attr_name.nil? || attr_name.empty?
604
+
605
+ "render_#{AST::Inflector.underscore(attr_name)}"
606
+ end
607
+
608
+ def unique_lambda_method_name(base)
609
+ @lambda_method_counts ||= {}
610
+ @lambda_method_counts[base] ||= 0
611
+ @lambda_method_counts[base] += 1
612
+ @lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
613
+ end
614
+
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
622
+ # 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.
626
+ def interpolated_attribute_value(name, value, translator, todos:)
627
+ translated = translator.translate(value.expression)
628
+ return translated.ruby if translated
629
+
630
+ compact = value.expression.tr("\n", " ").squeeze(" ")
631
+ todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
632
+ "nil"
633
+ end
634
+
635
+ def component_invocation_kwargs(props, translator, todos: [])
636
+ events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
637
+ spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
638
+
639
+ sym_parts = []
640
+ str_parts = []
641
+ plain_attrs.each do |a|
642
+ append_attribute_part(a, translator, sym_parts, str_parts, context: :component, todos: todos)
643
+ end
644
+ sym_parts << data_action_entry(events, translator) if events.any?
645
+
646
+ build_attribute_list(sym_parts, str_parts, spreads, translator)
647
+ end
648
+
649
+ def class_list_to_ruby_string(class_list, translator)
650
+ parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
651
+ %("#{parts.join(" ")}")
652
+ end
653
+
654
+ def class_segment_to_ruby(segment, translator)
655
+ case segment
656
+ when String then segment
657
+ when IR::Interpolation
658
+ translated = translator.translate(segment.expression)
659
+ "\#{#{translated&.ruby || segment.expression}}"
660
+ when IR::ConditionalSegment
661
+ cond_translated = translator.translate(segment.condition.expression)
662
+ cond_ruby = cond_translated&.ruby || segment.condition.expression
663
+ %(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
664
+ end
665
+ end
666
+
667
+ def style_to_ruby_string(style, translator)
668
+ parts = style.declarations.map { |decl| style_declaration_to_ruby(decl, translator) }
669
+ %("#{parts.join(" ")}")
670
+ end
671
+
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};"
680
+ end
681
+
682
+ # Wrap the spread expression in `(… || {})` so a nil-valued prop
683
+ # default doesn't raise at render time. `<div {...maybeNil}>` →
684
+ # `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
685
+ # `|| {}` shortcuts on non-nil values.
686
+ def render_spread(expression, translator)
687
+ translated = translator.translate(expression)
688
+ ruby = translated ? translated.ruby : expression
689
+ "(#{ruby} || {})"
690
+ end
691
+
692
+ # Build the `data_action: "..."` kwarg. Phlex auto-hyphenates the
693
+ # `data_action` symbol key to `data-action` in the rendered HTML.
694
+ def data_action_entry(events, translator)
695
+ descriptors = events.map { |event| event_descriptor(event, translator) }
696
+ joined = if descriptors.size == 1
697
+ render_single_event_descriptor(descriptors.first)
698
+ else
699
+ %("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
700
+ end
701
+ "data_action: #{joined}"
702
+ end
703
+
704
+ def event_descriptor(event, translator)
705
+ case event
706
+ when IR::EventBinding
707
+ translated = translator.translate(event.handler.expression)
708
+ EventDescriptor.new(:ruby, translated ? translated.ruby : event.handler.expression.inspect)
709
+ when IR::StimulusBinding
710
+ EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
711
+ end
712
+ end
713
+
714
+ def render_single_event_descriptor(descriptor)
715
+ descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
716
+ end
717
+
718
+ def descriptor_in_string(descriptor)
719
+ descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
720
+ end
721
+
722
+ def render_stimulus_controller_js(component)
723
+ lines = [
724
+ 'import { Controller } from "@hotwired/stimulus";',
725
+ "",
726
+ "export default class extends Controller {"
727
+ ]
728
+ component.stimulus_methods.each_with_index do |method, idx|
729
+ lines << "" if idx.positive?
730
+ lines.concat(stimulus_method_lines(method))
731
+ end
732
+ lines << "}"
733
+ "#{lines.join("\n")}\n"
734
+ end
735
+
736
+ def stimulus_method_lines(method)
737
+ body_lines = method.body_source.strip.split("\n")
738
+ commented = body_lines.map { |line| " // #{line}" }
739
+ header = [" // TODO: translate from the original JSX handler:"]
740
+ if method.name != method.original_name
741
+ header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
742
+ "to avoid collision with an earlier handler")
743
+ end
744
+ header + commented + [
745
+ " #{method.name}(event) {",
746
+ " // ...",
747
+ " }"
748
+ ]
749
+ end
750
+
751
+ def spaces(count)
752
+ " " * count
753
+ end
754
+ end
755
+ end
756
+ end