jsx_rosetta 0.3.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.
@@ -0,0 +1,1018 @@
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
+ # 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
+
68
+ # Structured intermediate for the data-action attribute — mirrors the
69
+ # ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
70
+ EventDescriptor = Data.define(:kind, :body)
71
+
72
+ def initialize(suffix: nil, namespace: nil)
73
+ super()
74
+ raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
75
+
76
+ @suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
77
+ @namespace = namespace
78
+ end
79
+
80
+ def emit(component)
81
+ translator = build_translator(component)
82
+ @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
83
+ @lambda_methods = []
84
+ @lambda_method_counts = {}
85
+
86
+ files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
87
+ if component.stimulus_methods.any?
88
+ files << File.new(
89
+ path: stimulus_path(component),
90
+ contents: render_stimulus_controller_js(component)
91
+ )
92
+ end
93
+ files
94
+ end
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
+
146
+ private
147
+
148
+ # JSX-returning lowercase helpers (e.g. `textRender`, `getNodeIcon`)
149
+ # have lowercase-starting names. Ruby class names must be constants
150
+ # (begin with an uppercase letter), so we capitalize the first
151
+ # letter when forming the class name. Pure-PascalCase names pass
152
+ # through unchanged.
153
+ def class_name(component)
154
+ base = "#{component.name[0].upcase}#{component.name[1..]}"
155
+ @suffix ? "#{base}#{@suffix}" : base
156
+ end
157
+
158
+ def ruby_path(component)
159
+ "#{AST::Inflector.underscore(class_name(component))}.rb"
160
+ end
161
+
162
+ def stimulus_path(component)
163
+ "#{AST::Inflector.underscore(class_name(component))}_controller.js"
164
+ end
165
+
166
+ def stimulus_identifier(component)
167
+ AST::Inflector.underscore(component.name).tr("_", "-")
168
+ end
169
+
170
+ def render_ruby_class(component, translator)
171
+ class_body = render_class_body(component, translator)
172
+ prefix = render_module_bindings_prefix(component)
173
+ wrap_in_namespace("#{prefix}#{class_body}")
174
+ end
175
+
176
+ # Top-level `const`/`let` declarations outside the component
177
+ # function — captured at lowering time and surfaced here as a TODO
178
+ # comment block above the class definition. We don't try to
179
+ # translate the JS; the human reviewer either copies the value as
180
+ # a Ruby constant or moves it to a Rails initializer.
181
+ def render_module_bindings_prefix(component)
182
+ return "" if component.module_bindings.empty?
183
+
184
+ lines = ["# TODO: module-level constants — translate to Ruby constants " \
185
+ "or move to a Rails initializer:"]
186
+ component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
187
+ "#{lines.join("\n")}\n"
188
+ end
189
+
190
+ def wrap_in_namespace(body)
191
+ return "# frozen_string_literal: true\n\n#{body}" unless @namespace
192
+
193
+ indented = body.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
194
+ "# frozen_string_literal: true\n\nmodule #{@namespace}\n#{indented}end\n"
195
+ end
196
+
197
+ def render_class_body(component, translator)
198
+ initializer = render_initializer(component, translator)
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"
237
+ end
238
+
239
+ def data_factory_signature(method_name, param_names)
240
+ return method_name if param_names.empty?
241
+
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|
253
+ render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
254
+ end
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"
271
+ end
272
+
273
+ def render_lambda_method_definition(method_name, lambda, translator)
274
+ snake_params = lambda.params.map { |p| AST::Inflector.underscore(p) }
275
+ signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
276
+ body = translator.with_locals(lambda.params) do
277
+ render_ir_node(lambda.body, translator, indent: 4)
278
+ end
279
+ " def #{signature}\n#{body}\n end"
280
+ end
281
+
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
+
287
+ props = initializable_props(component)
288
+ rest_name = component.rest_prop_name
289
+ return nil if props.empty? && rest_name.nil?
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)
296
+ kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
297
+ kwargs << "**#{rest_snake}" if rest_snake
298
+
299
+ body = [" super()"]
300
+ body.concat(props.map do |prop|
301
+ snake = AST::Inflector.underscore(prop.name)
302
+ " @#{snake} = #{snake}"
303
+ end)
304
+ body << " @#{rest_snake} = #{rest_snake}" if rest_snake
305
+
306
+ " def initialize(#{kwargs.join(", ")})\n#{body.join("\n")}\n end"
307
+ end
308
+
309
+ def initializable_props(component)
310
+ component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
311
+ end
312
+
313
+ def ruby_kwarg(prop, translator)
314
+ snake_name = AST::Inflector.underscore(prop.name)
315
+ default = ruby_default_for(prop, translator)
316
+ "#{snake_name}: #{default}"
317
+ end
318
+
319
+ def ruby_default_for(prop, translator)
320
+ return "nil" if prop.default.nil?
321
+
322
+ case prop.default
323
+ when IR::Interpolation
324
+ translated = translator.translate(prop.default.expression)
325
+ translated ? translated.ruby : "nil"
326
+ when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
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)
334
+ else
335
+ "nil"
336
+ end
337
+ end
338
+
339
+ def render_view_template(component, translator)
340
+ body = render_template_body(component, translator)
341
+ prefix = render_template_prefix(component)
342
+ body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
343
+ " def view_template\n#{body_with_prefix}\n end"
344
+ end
345
+
346
+ def render_template_prefix(component)
347
+ lines = []
348
+ lines.concat(render_react_hooks_todo(component.react_hooks))
349
+ lines.concat(render_local_bindings_todo(component.local_bindings))
350
+ return "" if lines.empty?
351
+
352
+ "#{lines.map { |l| " #{l}" }.join("\n")}\n"
353
+ end
354
+
355
+ def render_react_hooks_todo(hooks)
356
+ return [] if hooks.empty?
357
+
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
371
+ lines
372
+ end
373
+
374
+ def render_local_bindings_todo(bindings)
375
+ return [] if bindings.empty?
376
+
377
+ unique_sources = bindings.map(&:source).uniq
378
+ ["# TODO: translate JS to Ruby — original:"] + unique_sources.flat_map { |src| comment_lines(src) }
379
+ end
380
+
381
+ # Prefix every line of `source` with `# ` so multi-line JS bodies
382
+ # remain inside a Ruby comment block (single `#` on the first line
383
+ # would leave subsequent lines as bare Ruby and break parsing).
384
+ def comment_lines(source)
385
+ source.split("\n").map { |line| "# #{line}" }
386
+ end
387
+
388
+ def render_template_body(component, translator)
389
+ root = component.body
390
+ root = decorate_with_stimulus_controller(root) if component.stimulus_methods.any? && root.is_a?(IR::Element)
391
+ render_ir_node(root, translator, indent: 4)
392
+ end
393
+
394
+ def decorate_with_stimulus_controller(element)
395
+ attr = IR::Attribute.new(name: "data-controller", value: @stimulus_identifier)
396
+ IR::Element.new(tag: element.tag, attributes: [attr] + element.attributes, children: element.children)
397
+ end
398
+
399
+ def render_ir_node(node, translator, indent:)
400
+ case node
401
+ when IR::Element then render_element(node, translator, indent: indent)
402
+ when IR::ComponentInvocation then render_component_invocation(node, translator, indent: indent)
403
+ when IR::Fragment then render_fragment(node, translator, indent: indent)
404
+ when IR::Conditional then render_conditional(node, translator, indent: indent)
405
+ when IR::Loop then render_loop(node, translator, indent: indent)
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)
408
+ when IR::Slot then render_slot(node, indent: indent)
409
+ when IR::Text then render_text(node, indent: indent)
410
+ when IR::Interpolation then render_interpolation(node, translator, indent: indent)
411
+ when IR::Comment then render_comment(node, indent: indent)
412
+ end
413
+ end
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
+
432
+ # An orphan RenderProp (i.e. one that didn't get consumed as a block
433
+ # by a parent ComponentInvocation). Emit the body inline within the
434
+ # appropriate translator scope; the param names are pushed but no
435
+ # block syntax is generated.
436
+ def render_orphan_render_prop(render_prop, translator, indent:)
437
+ translator.with_locals(render_prop.params) do
438
+ render_ir_node(render_prop.body, translator, indent: indent)
439
+ end
440
+ end
441
+
442
+ def render_element(element, translator, indent:)
443
+ todos = []
444
+ attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos, indent: indent)
445
+ method_call = "#{element.tag}#{attrs_source}"
446
+
447
+ body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
448
+ "#{spaces(indent)}#{method_call}"
449
+ else
450
+ inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
451
+ "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
452
+ end
453
+
454
+ prepend_attribute_todos(todos, indent, body)
455
+ end
456
+
457
+ def render_component_invocation(invocation, translator, indent:)
458
+ todos = []
459
+ kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: indent)
460
+ class_ref = component_class_reference(invocation.name)
461
+ new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
462
+
463
+ render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
464
+ body = if render_prop
465
+ render_with_render_prop(new_call, render_prop, translator, indent)
466
+ elsif invocation.children.empty?
467
+ "#{spaces(indent)}render #{new_call}"
468
+ else
469
+ inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
470
+ "#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
471
+ end
472
+
473
+ prepend_attribute_todos(todos, indent, body)
474
+ end
475
+
476
+ # Emit a render-prop child as a Ruby block on the parent `render` call.
477
+ # `<Form.List>{(fields) => <p/>}</Form.List>` →
478
+ # `render Form::List.new do |fields|\n p\nend`. Param names are
479
+ # snake_cased and pushed into the translator scope so identifier
480
+ # references inside the body resolve to the block locals.
481
+ def render_with_render_prop(new_call, render_prop, translator, indent)
482
+ snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
483
+ param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
484
+ inner = translator.with_locals(render_prop.params) do
485
+ render_ir_node(render_prop.body, translator, indent: indent + 2)
486
+ end
487
+ "#{spaces(indent)}render #{new_call} do#{param_str}\n#{inner}\n#{spaces(indent)}end"
488
+ end
489
+
490
+ def prepend_attribute_todos(todos, indent, body)
491
+ return body if todos.empty?
492
+
493
+ prefix = todos.map { |t| "#{spaces(indent)}# TODO: #{t}" }.join("\n")
494
+ "#{prefix}\n#{body}"
495
+ end
496
+
497
+ # JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
498
+ # `Foo` again under namespace (Ruby's constant lookup finds the
499
+ # peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
500
+ def component_class_reference(jsx_tag)
501
+ segments = jsx_tag.split(".")
502
+ segments[-1] = "#{segments.last}#{@suffix}" if @suffix
503
+ segments.join("::")
504
+ end
505
+
506
+ def render_fragment(fragment, translator, indent:)
507
+ fragment.children.map { |child| render_ir_node(child, translator, indent: indent) }.join("\n")
508
+ end
509
+
510
+ def render_conditional(conditional, translator, indent:)
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")
526
+ lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
527
+ lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
528
+ lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
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
536
+ lines << "#{spaces(indent)}else"
537
+ lines << render_ir_node(alt, translator, indent: indent + 2)
538
+ end
539
+ end
540
+
541
+ def render_loop(loop_node, translator, indent:)
542
+ iterable_ruby, todo = render_loop_iterable(loop_node.iterable, translator)
543
+ js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
544
+ ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
545
+ binding_str = ruby_bindings.size == 1 ? "|#{ruby_bindings.first}|" : "|#{ruby_bindings.join(", ")}|"
546
+
547
+ body = translator.with_locals(js_bindings) do
548
+ render_ir_node(loop_node.body, translator, indent: indent + 2)
549
+ end
550
+
551
+ lines = []
552
+ lines << "#{spaces(indent)}# TODO: translate iterable: #{todo}" if todo
553
+ lines << "#{spaces(indent)}#{iterable_ruby}.each do #{binding_str}"
554
+ lines << body
555
+ lines << "#{spaces(indent)}end"
556
+ lines.join("\n")
557
+ end
558
+
559
+ # Translate the iterable side of a `.each` call. Handles both the
560
+ # traditional Interpolation form and the new ArrayLiteral form
561
+ # (literal array `.map(...)`) introduced by Gap H. Returns
562
+ # [ruby_source, todo_text]; todo_text is nil when translation succeeded.
563
+ def render_loop_iterable(iterable, translator)
564
+ case iterable
565
+ when IR::ArrayLiteral
566
+ [render_array_literal_value(iterable, translator, todos: []), nil]
567
+ when IR::Interpolation
568
+ safe_test_expression(iterable.expression, translator, fallback: "[]")
569
+ else
570
+ ["[]", iterable.inspect]
571
+ end
572
+ end
573
+
574
+ # Translate an expression intended to drive an `if` or `.each` call.
575
+ # Returns `[ruby_source, todo_text]`. When the translator can parse
576
+ # the expression, `todo_text` is nil. When it can't, the caller's
577
+ # `fallback` (e.g. `"false"` for conditions, `"[]"` for iterables)
578
+ # is returned along with the original expression so a TODO comment
579
+ # can be emitted above the call. Without this, JS operators like
580
+ # `!==`, `===`, optional chaining, and `in` would leak into the
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.
588
+ def safe_test_expression(expression, translator, fallback:)
589
+ translated = translator.translate(expression)
590
+ return [translated.ruby, nil] if translated && translated.ruby != "nil"
591
+
592
+ compact = expression.tr("\n", " ").squeeze(" ")
593
+ [fallback, compact]
594
+ end
595
+
596
+ def render_slot(slot, indent:)
597
+ if slot.name == DEFAULT_SLOT_NAME
598
+ "#{spaces(indent)}yield"
599
+ else
600
+ "#{spaces(indent)}# TODO: named slot #{slot.name.inspect}"
601
+ end
602
+ end
603
+
604
+ def render_text(text, indent:)
605
+ "#{spaces(indent)}plain #{AST::Inflector.ruby_string_literal(text.value)}"
606
+ end
607
+
608
+ def render_interpolation(interpolation, translator, indent:)
609
+ translated = translator.translate(interpolation.expression)
610
+ return render_untranslated_interpolation(interpolation.expression, indent) unless translated
611
+
612
+ unresolved = translated.unresolved_identifiers
613
+ if unresolved.empty?
614
+ "#{spaces(indent)}plain #{translated.ruby}#{react_node_hint(translated.ruby)}"
615
+ else
616
+ names = unresolved.map(&:inspect).join(", ")
617
+ "#{spaces(indent)}# TODO: unresolved identifier #{names}\n" \
618
+ "#{spaces(indent)}plain #{translated.ruby}"
619
+ end
620
+ end
621
+
622
+ # Gap G: when the translated value is a bare `@ivar` reference, the
623
+ # prop may be a ReactNode (children-typed prop) rather than a plain
624
+ # string. `plain` HTML-escapes its argument; `raw` doesn't. We can't
625
+ # tell at translation time which is intended, so default to `plain`
626
+ # (safe for string props) and emit a comment hint pointing at `raw`.
627
+ def react_node_hint(ruby)
628
+ return "" unless ruby.is_a?(String)
629
+ return "" unless ruby.match?(/\A@[a-z_][a-z0-9_]*\z/)
630
+
631
+ " # NOTE: use `raw` instead of `plain` if this is a ReactNode-typed prop"
632
+ end
633
+
634
+ # The original JS expression couldn't be translated to Ruby. We can't
635
+ # emit `plain <verbatim-JS>` because raw JS (TypeScript casts, JSX
636
+ # method chains, ternary spreads, etc.) usually isn't valid Ruby.
637
+ # Instead, emit two safe lines: a `# TODO:` comment naming the
638
+ # expression, then a string-literal placeholder so the template still
639
+ # renders something visible at runtime.
640
+ def render_untranslated_interpolation(expression, indent)
641
+ compact = expression.tr("\n", " ").squeeze(" ")
642
+ placeholder = AST::Inflector.ruby_string_literal("[untranslated: #{compact}]")
643
+ "#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
644
+ "#{spaces(indent)}plain #{placeholder}"
645
+ end
646
+
647
+ def render_comment(comment, indent:)
648
+ # Multi-line JSX comments need every line prefixed with `# ` — a
649
+ # bare first-line `#` would leave subsequent lines as Ruby code.
650
+ comment.text.split("\n").map { |line| "#{spaces(indent)}# #{line}" }.join("\n")
651
+ end
652
+
653
+ # Build the Ruby attribute list — `(id: @id, class: @class, ...)` —
654
+ # to splice immediately after the tag method name. Returns "" when
655
+ # there are no attributes (so the caller emits a bare `h1` instead
656
+ # of `h1()`). The `context:` param selects naming convention:
657
+ # - :html (HTML element attrs — preserve camelCase for SVG)
658
+ # - :component (Ruby method args — snake_case via Inflector.underscore)
659
+ def format_attributes(attributes, translator, context: :html, todos: [], indent: 0)
660
+ events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
661
+ spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
662
+
663
+ parts = { sym: [], str: [] }
664
+ plain_attrs.each do |a|
665
+ append_attribute_part(a, translator, parts, context: context, todos: todos, indent: indent)
666
+ end
667
+ parts[:sym] << data_action_entry(events, translator) if events.any?
668
+
669
+ joined = build_attribute_list(parts, spreads, translator)
670
+ joined.empty? ? "" : "(#{joined})"
671
+ end
672
+
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)
675
+ return unless part
676
+
677
+ (part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
678
+ end
679
+
680
+ def build_attribute_list(parts, spreads, translator)
681
+ pieces = parts[:sym].dup
682
+ pieces << "**{ #{parts[:str].join(", ")} }" if parts[:str].any?
683
+ pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
684
+ pieces.join(", ")
685
+ end
686
+
687
+ # Emit one attribute as either a {string_key: false, source: "id: @x"}
688
+ # (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
689
+ # (rare; non-identifier name — goes into a **{ ... } splat).
690
+ def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0)
691
+ case attribute
692
+ when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
693
+ when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
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)
697
+ end
698
+ end
699
+
700
+ def class_attribute_part(expression, translator)
701
+ translated = translator.translate(expression)
702
+ ruby = translated ? translated.ruby : expression.inspect
703
+ { string_key: false, source: "class: #{ruby}" }
704
+ end
705
+
706
+ # Map a JSX attribute name to its Ruby kwarg form. For HTML element
707
+ # attrs (`context: :html`), only hyphens convert to underscores —
708
+ # camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
709
+ # so SVG attributes render correctly through Phlex. For component
710
+ # invocations (`context: :component`), full Inflector.underscore
711
+ # converts both hyphens AND camelCase, since Ruby method args
712
+ # follow snake_case convention (`defaultValue` → `default_value`).
713
+ # Names that aren't valid Ruby identifiers after conversion (rare:
714
+ # `xml:lang` and friends) fall back to a quoted string key.
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)
717
+ ruby_name = case context
718
+ when :component then AST::Inflector.underscore(attribute.name)
719
+ else attribute.name.tr("-", "_")
720
+ end
721
+ if ruby_name.match?(VALID_IDENTIFIER)
722
+ { string_key: false, source: "#{ruby_name}: #{value_ruby}" }
723
+ else
724
+ { string_key: true, source: "#{AST::Inflector.ruby_string_literal(attribute.name)} => #{value_ruby}" }
725
+ end
726
+ end
727
+
728
+ def attribute_value_to_ruby(name, value, translator, todos:, indent: 0)
729
+ case value
730
+ when true then "true"
731
+ when String then AST::Inflector.ruby_string_literal(value)
732
+ when IR::Interpolation then interpolated_attribute_value(name, 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)
735
+ when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
736
+ end
737
+ end
738
+
739
+ # Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
740
+ # entries become Ruby kwargs (snake_cased to match Ruby convention);
741
+ # non-identifier keys (numeric, hyphenated) fall back to string keys.
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
747
+ parts = object_literal.properties.map do |(key, value)|
748
+ render_object_property(key, value, translator, todos: todos, indent: child_indent, force_inline: force_inline)
749
+ end
750
+ wrap_literal_parts(parts, open: "{", close: "}", indent: indent, inline_sep: ", ", inline_pad: " ",
751
+ force_inline: force_inline)
752
+ end
753
+
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)
757
+ snake = AST::Inflector.underscore(key)
758
+ if snake.match?(VALID_IDENTIFIER)
759
+ "#{snake}: #{value_ruby}"
760
+ else
761
+ "#{AST::Inflector.ruby_string_literal(key)} => #{value_ruby}"
762
+ end
763
+ end
764
+
765
+ def render_array_literal_value(array_literal, translator, todos:, indent: 0, force_inline: false)
766
+ child_indent = indent + 2
767
+ parts = array_literal.elements.map do |el|
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
774
+ end
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}"
796
+ end
797
+
798
+ # An inline value can appear as a kwarg value, an array element, or a
799
+ # hash property value. Recursive shapes route back through the new IR
800
+ # types; primitives fall through the same paths as attribute_value_to_ruby.
801
+ def render_inline_value(value, translator, todos:, attr_name:, indent: 0, force_inline: false)
802
+ case value
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)
807
+ when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
808
+ when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
809
+ todos: todos)
810
+ when String then AST::Inflector.ruby_string_literal(value)
811
+ when true then "true"
812
+ else
813
+ "nil"
814
+ end
815
+ end
816
+
817
+ # An IR::Lambda lives inside an object/array literal as a value. We
818
+ # extract it to a method on the class (so it has access to the
819
+ # Phlex tag.* helpers) and reference it via `method(:name)` in the
820
+ # value position. Method names are deterministic so re-runs produce
821
+ # stable output: `<attr-name>_renderer<N>` where N is a per-attr index.
822
+ def render_lambda_method_reference(lambda, translator, attr_name:)
823
+ @lambda_methods ||= []
824
+ base = lambda_method_base(attr_name)
825
+ @lambda_methods << { base: base, lambda: lambda, translator: translator }
826
+ # Index is the position within `@lambda_methods` so re-renders are
827
+ # deterministic in the order encountered.
828
+ method_name = unique_lambda_method_name(base)
829
+ @lambda_methods.last[:method_name] = method_name
830
+ "method(:#{method_name})"
831
+ end
832
+
833
+ def lambda_method_base(attr_name)
834
+ return "render_lambda" if attr_name.nil? || attr_name.empty?
835
+
836
+ "render_#{AST::Inflector.underscore(attr_name)}"
837
+ end
838
+
839
+ def unique_lambda_method_name(base)
840
+ @lambda_method_counts ||= {}
841
+ @lambda_method_counts[base] ||= 0
842
+ @lambda_method_counts[base] += 1
843
+ @lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
844
+ end
845
+
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
858
+ # be parsed at all (e.g. `<LeftOutlined .../>`, array literals,
859
+ # template literals with method calls). Same TODO + nil path.
860
+ def interpolated_attribute_value(name, value, translator, todos:)
861
+ translated = translator.translate(value.expression)
862
+ if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
863
+ return translated.ruby
864
+ end
865
+
866
+ compact = value.expression.tr("\n", " ").squeeze(" ")
867
+ todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
868
+ "nil"
869
+ end
870
+
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)
876
+ events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
877
+ spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
878
+
879
+ parts = { sym: [], str: [] }
880
+ plain_attrs.each do |a|
881
+ append_attribute_part(a, translator, parts, context: :component, todos: todos, indent: indent)
882
+ end
883
+ parts[:sym] << data_action_entry(events, translator) if events.any?
884
+
885
+ build_attribute_list(parts, spreads, translator)
886
+ end
887
+
888
+ def class_list_to_ruby_string(class_list, translator)
889
+ parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
890
+ wrap_concatenated_string(parts.join(" "))
891
+ end
892
+
893
+ def class_segment_to_ruby(segment, translator)
894
+ case segment
895
+ when String then segment
896
+ when IR::Interpolation
897
+ translated = translator.translate(segment.expression)
898
+ "\#{#{translated&.ruby || segment.expression}}"
899
+ when IR::ConditionalSegment
900
+ cond_translated = translator.translate(segment.condition.expression)
901
+ cond_ruby = cond_translated&.ruby || segment.condition.expression
902
+ %(\#{#{cond_ruby} ? #{AST::Inflector.ruby_string_literal(segment.class_name)} : ''})
903
+ end
904
+ end
905
+
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(" "))
909
+ end
910
+
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
942
+ end
943
+
944
+ # Wrap the spread expression in `(… || {})` so a nil-valued prop
945
+ # default doesn't raise at render time. `<div {...maybeNil}>` →
946
+ # `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
947
+ # `|| {}` shortcuts on non-nil values.
948
+ def render_spread(expression, translator)
949
+ translated = translator.translate(expression)
950
+ ruby = translated ? translated.ruby : expression
951
+ "(#{ruby} || {})"
952
+ end
953
+
954
+ # Build the `data_action: "..."` kwarg. Phlex auto-hyphenates the
955
+ # `data_action` symbol key to `data-action` in the rendered HTML.
956
+ def data_action_entry(events, translator)
957
+ descriptors = events.map { |event| event_descriptor(event, translator) }
958
+ joined = if descriptors.size == 1
959
+ render_single_event_descriptor(descriptors.first)
960
+ else
961
+ %("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
962
+ end
963
+ "data_action: #{joined}"
964
+ end
965
+
966
+ def event_descriptor(event, translator)
967
+ case event
968
+ when IR::EventBinding
969
+ translated = translator.translate(event.handler.expression)
970
+ EventDescriptor.new(:ruby, translated ? translated.ruby : event.handler.expression.inspect)
971
+ when IR::StimulusBinding
972
+ EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
973
+ end
974
+ end
975
+
976
+ def render_single_event_descriptor(descriptor)
977
+ descriptor.kind == :literal ? AST::Inflector.ruby_string_literal(descriptor.body) : descriptor.body
978
+ end
979
+
980
+ def descriptor_in_string(descriptor)
981
+ descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
982
+ end
983
+
984
+ def render_stimulus_controller_js(component)
985
+ lines = [
986
+ 'import { Controller } from "@hotwired/stimulus";',
987
+ "",
988
+ "export default class extends Controller {"
989
+ ]
990
+ component.stimulus_methods.each_with_index do |method, idx|
991
+ lines << "" if idx.positive?
992
+ lines.concat(stimulus_method_lines(method))
993
+ end
994
+ lines << "}"
995
+ "#{lines.join("\n")}\n"
996
+ end
997
+
998
+ def stimulus_method_lines(method)
999
+ body_lines = method.body_source.strip.split("\n")
1000
+ commented = body_lines.map { |line| " // #{line}" }
1001
+ header = [" // TODO: translate from the original JSX handler:"]
1002
+ if method.name != method.original_name
1003
+ header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
1004
+ "to avoid collision with an earlier handler")
1005
+ end
1006
+ header + commented + [
1007
+ " #{method.name}(event) {",
1008
+ " // ...",
1009
+ " }"
1010
+ ]
1011
+ end
1012
+
1013
+ def spaces(count)
1014
+ " " * count
1015
+ end
1016
+ end
1017
+ end
1018
+ end