jsx_rosetta 0.1.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,638 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/inflector"
4
+ require_relative "../ir/types"
5
+ require_relative "base"
6
+
7
+ module JsxRosetta
8
+ module Backend
9
+ # Emits a Rails ViewComponent (one Ruby class + one ERB template)
10
+ # from an IR::Component.
11
+ #
12
+ # Phase 3 scope:
13
+ # - Single component per emit.
14
+ # - JSX prop names lowered to snake_case Ruby kwargs and matching
15
+ # `@instance_variable` assignments.
16
+ # - JS expressions translated via ExpressionTranslator where the
17
+ # shape is recognized; otherwise emitted as a TODO marker plus
18
+ # verbatim source.
19
+ # - HTML attributes emitted directly; className / template-literal
20
+ # class expressions inlined into the `class="..."` attribute.
21
+ #
22
+ # Phase 4a additions:
23
+ # - `children` prop is treated as ViewComponent's default content
24
+ # slot: it's filtered out of the initializer and rendered as
25
+ # `<%= content %>` wherever the IR has IR::Slot(name: "children").
26
+ # - IR::Conditional renders as `<% if %>...<% else %>...<% end %>`.
27
+ class ViewComponent < Base
28
+ DEFAULT_SLOT_NAME = "children"
29
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
30
+
31
+ # JSX component names that have a direct Rails view-helper analog.
32
+ # Override per-instance via `ViewComponent.new(helpers: {...})`, or
33
+ # disable by passing `helpers: false`.
34
+ DEFAULT_HELPERS = {
35
+ "Link" => { method: :link_to, positional: :href }.freeze,
36
+ "Image" => { method: :image_tag, positional: :src }.freeze
37
+ }.freeze
38
+
39
+ def initialize(helpers: nil, layout: :sidecar)
40
+ super()
41
+ @helpers = case helpers
42
+ when nil then DEFAULT_HELPERS
43
+ when false then {}
44
+ else helpers
45
+ end
46
+ unless %i[sidecar flat].include?(layout)
47
+ raise ArgumentError, "unknown layout: #{layout.inspect} (expected :sidecar or :flat)"
48
+ end
49
+
50
+ @layout = layout
51
+ end
52
+
53
+ def emit(component)
54
+ prop_names = component.props.map(&:name)
55
+ prop_names << component.rest_prop_name if component.rest_prop_name
56
+ translator = ExpressionTranslator.new(prop_names: prop_names)
57
+
58
+ base_name = "#{AST::Inflector.underscore(component.name)}_component"
59
+ @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
60
+
61
+ files = [
62
+ File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
63
+ File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
64
+ ]
65
+ if component.stimulus_methods.any?
66
+ files << File.new(
67
+ path: stimulus_path(component, base_name),
68
+ contents: render_stimulus_controller_js(component)
69
+ )
70
+ end
71
+ files
72
+ end
73
+
74
+ def erb_path(base_name)
75
+ @layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
76
+ end
77
+
78
+ def stimulus_path(component, base_name)
79
+ controller_filename = "#{AST::Inflector.underscore(component.name)}_controller.js"
80
+ @layout == :sidecar ? "#{base_name}/#{controller_filename}" : controller_filename
81
+ end
82
+
83
+ def stimulus_identifier(component)
84
+ AST::Inflector.underscore(component.name).tr("_", "-")
85
+ end
86
+
87
+ def render_stimulus_controller_js(component)
88
+ lines = [
89
+ 'import { Controller } from "@hotwired/stimulus";',
90
+ "",
91
+ "export default class extends Controller {"
92
+ ]
93
+ component.stimulus_methods.each_with_index do |method, idx|
94
+ lines << "" if idx.positive?
95
+ lines.concat(stimulus_method_lines(method))
96
+ end
97
+ lines << "}"
98
+ "#{lines.join("\n")}\n"
99
+ end
100
+
101
+ def stimulus_method_lines(method)
102
+ body_lines = method.body_source.strip.split("\n")
103
+ commented = body_lines.map { |line| " // #{line}" }
104
+ [" // TODO: translate from the original JSX handler:"] + commented + [
105
+ " #{method.name}(event) {",
106
+ " // ...",
107
+ " }"
108
+ ]
109
+ end
110
+
111
+ private
112
+
113
+ def initializable_props(component)
114
+ component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
115
+ end
116
+
117
+ def render_ruby_class(component, translator)
118
+ props = initializable_props(component)
119
+ rest_name = component.rest_prop_name
120
+
121
+ if props.empty? && rest_name.nil?
122
+ <<~RUBY
123
+ # frozen_string_literal: true
124
+
125
+ class #{component.name}Component < ::ViewComponent::Base
126
+ end
127
+ RUBY
128
+ else
129
+ render_ruby_class_with_props(component, props, rest_name, translator)
130
+ end
131
+ end
132
+
133
+ def render_ruby_class_with_props(component, props, rest_name, translator)
134
+ kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
135
+ kwargs << "**#{rest_name}" if rest_name
136
+
137
+ assignments = props.map do |prop|
138
+ snake = AST::Inflector.underscore(prop.name)
139
+ " @#{snake} = #{snake}"
140
+ end
141
+ assignments << " @#{rest_name} = #{rest_name}" if rest_name
142
+
143
+ <<~RUBY
144
+ # frozen_string_literal: true
145
+
146
+ class #{component.name}Component < ::ViewComponent::Base
147
+ def initialize(#{kwargs.join(", ")})
148
+ #{assignments.join("\n")}
149
+ end
150
+ end
151
+ RUBY
152
+ end
153
+
154
+ def ruby_kwarg(prop, translator)
155
+ snake_name = AST::Inflector.underscore(prop.name)
156
+ default = ruby_default_for(prop, translator)
157
+ "#{snake_name}: #{default}"
158
+ end
159
+
160
+ def ruby_default_for(prop, translator)
161
+ return "nil" if prop.default.nil?
162
+
163
+ translated = translator.translate(prop.default.expression)
164
+ translated ? translated.ruby : "nil # TODO: translate #{prop.default.expression.inspect}"
165
+ end
166
+
167
+ def render_erb_template(component, translator)
168
+ root = component.body
169
+ root = decorate_with_stimulus_controller(root) if component.stimulus_methods.any? && root.is_a?(IR::Element)
170
+ body = render_ir_node(root, translator, indent: 0)
171
+ body = "#{body}\n" unless body.end_with?("\n")
172
+
173
+ prefix = String.new
174
+ prefix << render_react_hooks_todo(component.react_hooks)
175
+ prefix << render_local_bindings_todo(component.local_bindings)
176
+ "#{prefix}#{body}"
177
+ end
178
+
179
+ def decorate_with_stimulus_controller(element)
180
+ attr = IR::Attribute.new(name: "data-controller", value: @stimulus_identifier)
181
+ IR::Element.new(tag: element.tag, attributes: [attr] + element.attributes, children: element.children)
182
+ end
183
+
184
+ def render_local_bindings_todo(bindings)
185
+ return "" if bindings.empty?
186
+
187
+ unique_sources = bindings.map(&:source).uniq
188
+ lines = ["<%# TODO: translate JS to Ruby — original:"]
189
+ unique_sources.each { |src| lines << " #{src}" }
190
+ lines << "%>"
191
+ "#{lines.join("\n")}\n"
192
+ end
193
+
194
+ def render_react_hooks_todo(hooks)
195
+ return "" if hooks.empty?
196
+
197
+ lines = [
198
+ "<%# TODO: React hooks detected. None translate automatically. Hotwire/Stimulus",
199
+ " handles behavior; controllers/views handle state; turbo-frames handle async",
200
+ " loading. Original source:"
201
+ ]
202
+ hooks.each { |hook| lines << " #{hook.source}" }
203
+ lines << "%>"
204
+ "#{lines.join("\n")}\n"
205
+ end
206
+
207
+ def render_ir_node(node, translator, indent:)
208
+ case node
209
+ when IR::Element then render_element(node, translator, indent: indent)
210
+ when IR::ComponentInvocation then render_component_invocation(node, translator, indent: indent)
211
+ when IR::Fragment then render_fragment(node, translator, indent: indent)
212
+ when IR::Conditional then render_conditional(node, translator, indent: indent)
213
+ when IR::Loop then render_loop(node, translator, indent: indent)
214
+ when IR::Slot then render_slot(node, indent: indent)
215
+ when IR::Text then "#{spaces(indent)}#{node.value}"
216
+ when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
217
+ when IR::Comment then "#{spaces(indent)}<%# #{node.text} %>"
218
+ end
219
+ end
220
+
221
+ def render_loop(loop_node, translator, indent:)
222
+ iterable_ruby = render_test_expression(loop_node.iterable, translator)
223
+ js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
224
+ ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
225
+ binding_str = "|#{ruby_bindings.join(", ")}|"
226
+
227
+ body = translator.with_locals(js_bindings) do
228
+ render_ir_node(loop_node.body, translator, indent: indent + 2)
229
+ end
230
+
231
+ [
232
+ "#{spaces(indent)}<% #{iterable_ruby}.each do #{binding_str} %>",
233
+ body,
234
+ "#{spaces(indent)}<% end %>"
235
+ ].join("\n")
236
+ end
237
+
238
+ def render_element(element, translator, indent:)
239
+ return render_element_with_tag_builder(element, translator, indent: indent) if needs_tag_builder?(element)
240
+
241
+ attrs = render_attributes(element.attributes, translator)
242
+ attrs_segment = attrs.empty? ? "" : " #{attrs}"
243
+
244
+ return "#{spaces(indent)}<#{element.tag}#{attrs_segment} />" if VOID_ELEMENTS.include?(element.tag)
245
+
246
+ opening = "<#{element.tag}#{attrs_segment}>"
247
+ closing = "</#{element.tag}>"
248
+
249
+ if element.children.empty?
250
+ "#{spaces(indent)}#{opening}#{closing}"
251
+ else
252
+ inner = element.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
253
+ "#{spaces(indent)}#{opening}\n#{inner}\n#{spaces(indent)}#{closing}"
254
+ end
255
+ end
256
+
257
+ def needs_tag_builder?(element)
258
+ element.attributes.any?(IR::SpreadAttribute)
259
+ end
260
+
261
+ def render_element_with_tag_builder(element, translator, indent:)
262
+ builder_args = render_tag_builder_args(element.attributes, translator)
263
+ prefix = "<%= tag.#{element.tag}(#{builder_args})"
264
+
265
+ if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
266
+ "#{spaces(indent)}#{prefix} %>"
267
+ else
268
+ inner = element.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
269
+ "#{spaces(indent)}#{prefix} do %>\n#{inner}\n#{spaces(indent)}<% end %>"
270
+ end
271
+ end
272
+
273
+ def render_tag_builder_args(attributes, translator)
274
+ events, others = attributes.partition { |attr| attr.is_a?(IR::EventBinding) || attr.is_a?(IR::StimulusBinding) }
275
+ spreads, plain = others.partition { |attr| attr.is_a?(IR::SpreadAttribute) }
276
+
277
+ pieces = plain.filter_map { |attr| tag_builder_kwarg(attr, translator) }
278
+ pieces << tag_builder_data_action(events, translator) if events.any?
279
+ pieces.concat(spreads.map { |s| "**#{tag_builder_spread(s.expression, translator)}" })
280
+ pieces.join(", ")
281
+ end
282
+
283
+ def tag_builder_kwarg(attribute, translator)
284
+ case attribute
285
+ when IR::StyleBinding
286
+ translated = translator.translate(attribute.expression)
287
+ ruby = translated ? translated.ruby : attribute.expression.inspect
288
+ "class: #{ruby}"
289
+ when IR::ClassList
290
+ "class: #{class_list_to_ruby_string(attribute, translator)}"
291
+ when IR::Style
292
+ "style: #{style_to_ruby_string(attribute, translator)}"
293
+ when IR::Attribute
294
+ tag_builder_plain_kwarg(attribute, translator)
295
+ end
296
+ end
297
+
298
+ def tag_builder_plain_kwarg(attribute, translator)
299
+ key = attribute.name.match?(/\A[a-z_][a-z0-9_]*\z/i) ? "#{attribute.name}:" : "#{attribute.name.inspect} =>"
300
+ "#{key} #{tag_builder_value(attribute.value, translator)}"
301
+ end
302
+
303
+ def tag_builder_value(value, translator)
304
+ case value
305
+ when true then "true"
306
+ when String then value.inspect
307
+ when IR::Interpolation
308
+ translated = translator.translate(value.expression)
309
+ translated ? translated.ruby : value.expression.inspect
310
+ end
311
+ end
312
+
313
+ def tag_builder_data_action(events, translator)
314
+ descriptors = events.map { |event| tag_builder_event_descriptor(event, translator) }
315
+ all_literal = descriptors.all? { |d| d.start_with?('"') && d.end_with?('"') }
316
+ joined = if descriptors.size == 1
317
+ descriptors.first
318
+ elsif all_literal
319
+ %("#{descriptors.map { |d| d[1..-2] }.join(" ")}")
320
+ else
321
+ %("#{descriptors.map { |d| literal_to_interpolated(d) }.join(" ")}")
322
+ end
323
+ %("data-action" => #{joined})
324
+ end
325
+
326
+ def tag_builder_event_descriptor(event, translator)
327
+ case event
328
+ when IR::EventBinding
329
+ translated = translator.translate(event.handler.expression)
330
+ translated ? translated.ruby : event.handler.expression.inspect
331
+ when IR::StimulusBinding
332
+ %("#{event.event}->#{@stimulus_identifier}##{event.method_name}")
333
+ end
334
+ end
335
+
336
+ def literal_to_interpolated(descriptor)
337
+ if descriptor.start_with?('"') && descriptor.end_with?('"')
338
+ descriptor[1..-2]
339
+ else
340
+ "\#{#{descriptor}}"
341
+ end
342
+ end
343
+
344
+ def tag_builder_spread(expression, translator)
345
+ translated = translator.translate(expression)
346
+ translated ? translated.ruby : expression
347
+ end
348
+
349
+ def render_component_invocation(invocation, translator, indent:)
350
+ helper = @helpers[invocation.name]
351
+ return render_helper_call(invocation, translator, helper, indent: indent) if helper
352
+
353
+ kwargs = component_invocation_kwargs(invocation.props, translator)
354
+ class_name = component_class_name(invocation.name)
355
+ new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
356
+
357
+ if invocation.children.empty?
358
+ "#{spaces(indent)}<%= render #{new_call} %>"
359
+ else
360
+ inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
361
+ "#{spaces(indent)}<%= render #{new_call} do %>\n#{inner}\n#{spaces(indent)}<% end %>"
362
+ end
363
+ end
364
+
365
+ # JSX `<Foo.Bar>` → Ruby `Foo::BarComponent`. Plain `<Card>` stays as
366
+ # `CardComponent`. Each member-expression segment joins with `::`,
367
+ # and `Component` suffixes the leaf so the result is a constant path
368
+ # the host app can autoload.
369
+ def component_class_name(jsx_tag)
370
+ return "#{jsx_tag}Component" unless jsx_tag.include?(".")
371
+
372
+ "#{jsx_tag.split(".").join("::")}Component"
373
+ end
374
+
375
+ def render_helper_call(invocation, translator, helper, indent:)
376
+ call = build_helper_call(invocation, translator, helper)
377
+ if invocation.children.empty?
378
+ "#{spaces(indent)}<%= #{call} %>"
379
+ else
380
+ inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
381
+ "#{spaces(indent)}<%= #{call} do %>\n#{inner}\n#{spaces(indent)}<% end %>"
382
+ end
383
+ end
384
+
385
+ def build_helper_call(invocation, translator, helper)
386
+ positional_attr = find_positional_attr(invocation.props, helper[:positional])
387
+ remaining = positional_attr ? invocation.props.reject { |p| p.equal?(positional_attr) } : invocation.props
388
+
389
+ parts = []
390
+ parts << component_kwarg_value(positional_attr.value, translator) if positional_attr
391
+ kwargs = component_invocation_kwargs(remaining, translator)
392
+ parts << kwargs unless kwargs.empty?
393
+
394
+ parts.empty? ? helper[:method].to_s : "#{helper[:method]}(#{parts.join(", ")})"
395
+ end
396
+
397
+ def find_positional_attr(props, positional)
398
+ return nil unless positional
399
+
400
+ name = positional.to_s
401
+ props.find { |p| p.is_a?(IR::Attribute) && p.name == name }
402
+ end
403
+
404
+ def component_invocation_kwargs(props, translator)
405
+ spreads, others = props.partition { |attr| attr.is_a?(IR::SpreadAttribute) }
406
+ parts = others.filter_map { |attr| component_kwarg(attr, translator) }
407
+ parts.concat(spreads.map { |s| "**#{tag_builder_spread(s.expression, translator)}" })
408
+ parts.join(", ")
409
+ end
410
+
411
+ def render_fragment(fragment, translator, indent:)
412
+ fragment.children.map { |child| render_ir_node(child, translator, indent: indent) }.join("\n")
413
+ end
414
+
415
+ def render_conditional(conditional, translator, indent:)
416
+ test_ruby = render_test_expression(conditional.test, translator)
417
+ lines = ["#{spaces(indent)}<% if #{test_ruby} %>"]
418
+ lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
419
+ if conditional.alternate
420
+ lines << "#{spaces(indent)}<% else %>"
421
+ lines << render_ir_node(conditional.alternate, translator, indent: indent + 2)
422
+ end
423
+ lines << "#{spaces(indent)}<% end %>"
424
+ lines.join("\n")
425
+ end
426
+
427
+ def render_test_expression(test, translator)
428
+ translated = translator.translate(test.expression)
429
+ translated ? translated.ruby : test.expression
430
+ end
431
+
432
+ def render_slot(slot, indent:)
433
+ if slot.name == DEFAULT_SLOT_NAME
434
+ "#{spaces(indent)}<%= content %>"
435
+ else
436
+ # Named slots become Phase 4d work; for now flag them.
437
+ "#{spaces(indent)}<%# TODO: named slot #{slot.name.inspect} %>"
438
+ end
439
+ end
440
+
441
+ def render_attributes(attributes, translator)
442
+ events, others = attributes.partition { |attr| attr.is_a?(IR::EventBinding) || attr.is_a?(IR::StimulusBinding) }
443
+ rendered = others.filter_map { |attr| render_attribute(attr, translator) }
444
+ rendered << render_data_action(events, translator) if events.any?
445
+ rendered.join(" ")
446
+ end
447
+
448
+ def render_attribute(attribute, translator)
449
+ case attribute
450
+ when IR::StyleBinding then render_style_binding(attribute, translator)
451
+ when IR::ClassList then render_class_list_attribute(attribute, translator)
452
+ when IR::Style then render_style(attribute, translator)
453
+ when IR::Attribute then render_plain_attribute(attribute, translator)
454
+ end
455
+ end
456
+
457
+ def render_style(style, translator)
458
+ rendered = style.declarations.map { |decl| render_style_declaration(decl, translator) }.join(" ")
459
+ %(style="#{rendered}")
460
+ end
461
+
462
+ def render_style_declaration(decl, translator)
463
+ value = case decl.value
464
+ when String then decl.value
465
+ when IR::Interpolation
466
+ translated = translator.translate(decl.value.expression)
467
+ "<%= #{translated&.ruby || decl.value.expression} %>"
468
+ end
469
+ "#{decl.property}: #{value};"
470
+ end
471
+
472
+ def render_class_list_attribute(class_list, translator)
473
+ parts = class_list.segments.map { |seg| class_segment_for_html(seg, translator) }
474
+ %(class="#{parts.join(" ")}")
475
+ end
476
+
477
+ def class_segment_for_html(segment, translator)
478
+ case segment
479
+ when String then segment
480
+ when IR::Interpolation
481
+ translated = translator.translate(segment.expression)
482
+ "<%= #{translated&.ruby || segment.expression} %>"
483
+ when IR::ConditionalSegment
484
+ cond_translated = translator.translate(segment.condition.expression)
485
+ cond_ruby = cond_translated&.ruby || segment.condition.expression
486
+ "<%= #{cond_ruby} ? #{segment.class_name.inspect} : '' %>"
487
+ end
488
+ end
489
+
490
+ def class_list_to_ruby_string(class_list, translator)
491
+ parts = class_list.segments.map { |seg| class_segment_for_ruby(seg, translator) }
492
+ %("#{parts.join(" ")}")
493
+ end
494
+
495
+ def class_segment_for_ruby(segment, translator)
496
+ case segment
497
+ when String then segment
498
+ when IR::Interpolation
499
+ translated = translator.translate(segment.expression)
500
+ "\#{#{translated&.ruby || segment.expression}}"
501
+ when IR::ConditionalSegment
502
+ cond_translated = translator.translate(segment.condition.expression)
503
+ cond_ruby = cond_translated&.ruby || segment.condition.expression
504
+ %(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
505
+ end
506
+ end
507
+
508
+ def render_data_action(events, translator)
509
+ parts = events.map { |event| render_event_handler(event, translator) }
510
+ %(data-action="#{parts.join(" ")}")
511
+ end
512
+
513
+ def render_event_handler(event, translator)
514
+ case event
515
+ when IR::EventBinding
516
+ translated = translator.translate(event.handler.expression)
517
+ ruby = translated ? translated.ruby : event.handler.expression
518
+ "<%= #{ruby} %>"
519
+ when IR::StimulusBinding
520
+ "#{event.event}->#{@stimulus_identifier}##{event.method_name}"
521
+ end
522
+ end
523
+
524
+ def render_plain_attribute(attribute, translator)
525
+ case attribute.value
526
+ when true then attribute.name
527
+ when String then %(#{attribute.name}="#{attribute.value}")
528
+ when IR::Interpolation
529
+ %(#{attribute.name}="#{plain_attribute_value_erb(attribute.value, translator)}")
530
+ end
531
+ end
532
+
533
+ # Try to inline the attribute value rather than wrapping it in `<%= %>`.
534
+ # If the translator produces a Ruby double-quoted string with `#{…}`
535
+ # interpolations (typical for template-literal hrefs etc.), emit the
536
+ # literal portions literally and the interpolations as ERB tags.
537
+ def plain_attribute_value_erb(interpolation, translator)
538
+ translated = translator.translate(interpolation.expression)
539
+ if double_quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
540
+ inlined_ruby_string(translated.ruby)
541
+ else
542
+ interpolation_to_erb(interpolation, translator)
543
+ end
544
+ end
545
+
546
+ def render_style_binding(binding, translator)
547
+ translated = translator.translate(binding.expression)
548
+ if double_quoted_ruby_string?(translated&.ruby)
549
+ %(class="#{inlined_ruby_string(translated.ruby)}")
550
+ elsif translated
551
+ %(class="<%= #{translated.ruby} %>")
552
+ else
553
+ %(class="<%# TODO: translate #{binding.expression.inspect} %><%= #{binding.expression} %>")
554
+ end
555
+ end
556
+
557
+ def double_quoted_ruby_string?(ruby)
558
+ ruby.is_a?(String) && ruby.start_with?('"') && ruby.end_with?('"')
559
+ end
560
+
561
+ # Given a Ruby double-quoted string with #{...} interpolations, emit it
562
+ # in ERB template form: the literal portions stay literal, and each
563
+ # interpolation becomes an ERB tag.
564
+ #
565
+ # `"btn btn-#{@variant}"` → `btn btn-<%= @variant %>`
566
+ def inlined_ruby_string(ruby_string)
567
+ inner = ruby_string[1..-2]
568
+ inner.gsub(/\#\{([^}]+)\}/) { "<%= #{::Regexp.last_match(1)} %>" }
569
+ end
570
+
571
+ def interpolation_to_erb(interpolation, translator)
572
+ translated = translator.translate(interpolation.expression)
573
+ unless translated
574
+ return "<%# TODO: translate #{interpolation.expression.inspect} %>" \
575
+ "<%= #{interpolation.expression} %>"
576
+ end
577
+
578
+ unresolved = translated.unresolved_identifiers
579
+ return "<%= #{translated.ruby} %>" if unresolved.empty?
580
+
581
+ names = unresolved.map(&:inspect).join(", ")
582
+ "<%# TODO: unresolved identifier #{names} %><%= #{translated.ruby} %>"
583
+ end
584
+
585
+ def component_kwarg(attribute, translator)
586
+ case attribute
587
+ when IR::Attribute
588
+ component_attribute_kwarg(attribute, translator)
589
+ when IR::StyleBinding
590
+ translated = translator.translate(attribute.expression)
591
+ ruby = translated ? translated.ruby : attribute.expression.inspect
592
+ "class: #{ruby}"
593
+ when IR::ClassList
594
+ "class: #{class_list_to_ruby_string(attribute, translator)}"
595
+ when IR::Style
596
+ "style: #{style_to_ruby_string(attribute, translator)}"
597
+ end
598
+ end
599
+
600
+ def style_to_ruby_string(style, translator)
601
+ parts = style.declarations.map do |decl|
602
+ value = case decl.value
603
+ when String then decl.value
604
+ when IR::Interpolation
605
+ translated = translator.translate(decl.value.expression)
606
+ "\#{#{translated&.ruby || decl.value.expression}}"
607
+ end
608
+ "#{decl.property}: #{value};"
609
+ end
610
+ %("#{parts.join(" ")}")
611
+ end
612
+
613
+ def component_attribute_kwarg(attribute, translator)
614
+ value = component_kwarg_value(attribute.value, translator)
615
+ if attribute.name.match?(/\A[a-z_][a-z0-9_]*\z/i)
616
+ name = AST::Inflector.underscore(attribute.name)
617
+ "#{name}: #{value}"
618
+ else
619
+ "#{attribute.name.inspect} => #{value}"
620
+ end
621
+ end
622
+
623
+ def component_kwarg_value(value, translator)
624
+ case value
625
+ when true then "true"
626
+ when String then value.inspect
627
+ when IR::Interpolation
628
+ translated = translator.translate(value.expression)
629
+ translated ? translated.ruby : "nil # TODO: translate #{value.expression.inspect}"
630
+ end
631
+ end
632
+
633
+ def spaces(count)
634
+ " " * count
635
+ end
636
+ end
637
+ end
638
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backend/base"
4
+ require_relative "backend/view_component"
5
+ require_relative "backend/view_component/expression_translator"
6
+ require_relative "backend/rails_view"
7
+ require_relative "backend/routes_script"
8
+
9
+ module JsxRosetta
10
+ module Backend
11
+ end
12
+ end