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.
@@ -28,6 +28,12 @@ module JsxRosetta
28
28
  DEFAULT_SLOT_NAME = "children"
29
29
  VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
30
30
 
31
+ # Structured intermediate for tag_builder_data_action — avoids the
32
+ # fragile "parse what you just rendered" pattern. :literal is a raw
33
+ # action token like `"click->foo#bar"`; :ruby is a Ruby expression
34
+ # whose value is the action string (e.g. `event.handler.expression`).
35
+ EventDescriptor = Data.define(:kind, :body)
36
+
31
37
  # JSX component names that have a direct Rails view-helper analog.
32
38
  # Override per-instance via `ViewComponent.new(helpers: {...})`, or
33
39
  # disable by passing `helpers: false`.
@@ -36,6 +42,32 @@ module JsxRosetta
36
42
  "Image" => { method: :image_tag, positional: :src }.freeze
37
43
  }.freeze
38
44
 
45
+ # Per-library TODO header lines surfaced above the verbatim hook
46
+ # source. Each library has a different Rails analog, so we don't
47
+ # collapse them into a single generic block. Keys must mirror the
48
+ # `:library` values produced by IR::Lowering. First line is rendered
49
+ # with the `<%#` opener; subsequent lines are indented continuation.
50
+ HOOK_TODO_HEADERS = {
51
+ react: [
52
+ "TODO: React hooks detected. None translate automatically. Hotwire/Stimulus",
53
+ "handles behavior; controllers/views handle state; turbo-frames handle async",
54
+ "loading. Original source:"
55
+ ].freeze,
56
+ apollo: [
57
+ "TODO: Apollo data-fetching hooks detected. None translate automatically.",
58
+ "Move the fetch to the Rails controller (or a model/service); pass the",
59
+ "result in as a prop. For useMutation, use a form POST + redirect or a",
60
+ "Turbo Stream response. Original source:"
61
+ ].freeze,
62
+ next_js: [
63
+ "TODO: Next.js navigation hooks detected. None translate automatically.",
64
+ "Rails analogs: useRouter -> redirect_to / form actions;",
65
+ "usePathname -> request.path; useSearchParams / useParams -> params;",
66
+ "useSelectedLayoutSegment(s) -> match against request.path in the view.",
67
+ "Original source:"
68
+ ].freeze
69
+ }.freeze
70
+
39
71
  def initialize(helpers: nil, layout: :sidecar)
40
72
  super()
41
73
  @helpers = case helpers
@@ -51,13 +83,17 @@ module JsxRosetta
51
83
  end
52
84
 
53
85
  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
-
86
+ translator = build_translator(component)
58
87
  base_name = "#{AST::Inflector.underscore(component.name)}_component"
59
88
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
60
89
 
90
+ # Data-factory components (column-descriptor modules) have no
91
+ # template — they're pure-data classes. Skip the .erb pair and
92
+ # emit a single .rb with the factory method. JSX render lambdas
93
+ # inside the data have nowhere to live in the ViewComponent
94
+ # ERB-template world, so we surface a TODO note in the class.
95
+ return emit_data_factory(component) if component.mode == :data_factory
96
+
61
97
  files = [
62
98
  File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
63
99
  File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
@@ -71,6 +107,85 @@ module JsxRosetta
71
107
  files
72
108
  end
73
109
 
110
+ # For data-factory components emit a plain Ruby class (no ApplicationViewComponent
111
+ # base, no ERB template). The user can mix it into a ViewComponent or call
112
+ # the method directly — the goal is to surface the translated data array,
113
+ # not to render it in isolation.
114
+ def emit_data_factory(component)
115
+ method_name = AST::Inflector.underscore(component.name)
116
+ param_names = component.props.map(&:name)
117
+ translator = ExpressionTranslator.new(prop_names: [], local_binding_names: param_names)
118
+ signature = data_factory_signature(method_name, param_names)
119
+ body = translator.with_locals(param_names) do
120
+ inline_render_value(component.body, translator, indent: 4)
121
+ end
122
+ contents = <<~RUBY
123
+ # frozen_string_literal: true
124
+
125
+ # TODO: data-factory module — the translated array contains JSX render
126
+ # lambdas as `nil` placeholders. Wire each up to a Phlex helper or a
127
+ # method on the consuming ViewComponent.
128
+ class #{class_name_for(component)}
129
+ def #{signature}
130
+ #{body}
131
+ end
132
+ end
133
+ RUBY
134
+ [File.new(path: "#{AST::Inflector.underscore(component.name)}.rb", contents: contents)]
135
+ end
136
+
137
+ def class_name_for(component)
138
+ component.name[0].upcase + component.name[1..]
139
+ end
140
+
141
+ def data_factory_signature(method_name, param_names)
142
+ return method_name if param_names.empty?
143
+
144
+ kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
145
+ "#{method_name}(#{kwargs.join(", ")})"
146
+ end
147
+
148
+ # Recursively render a non-JSX value (ObjectLiteral / ArrayLiteral /
149
+ # Lambda / Interpolation / primitives) without the kwarg-list
150
+ # context the Phlex backend uses. IR::Lambda and unmatched cases
151
+ # both fall through to `nil` — there's no Phlex class to host a
152
+ # rendered method body, and the class-level TODO comment above the
153
+ # emitted file already flags both for the reviewer.
154
+ def inline_render_value(value, translator, indent: 0)
155
+ case value
156
+ when IR::ObjectLiteral then render_factory_object_literal(value, translator, indent: indent)
157
+ when IR::ArrayLiteral then render_factory_array_literal(value, translator, indent: indent)
158
+ when IR::Interpolation then translator.translate(value.expression)&.ruby || "nil"
159
+ when String then value.inspect
160
+ when true then "true"
161
+ else "nil"
162
+ end
163
+ end
164
+
165
+ def render_factory_object_literal(obj, translator, indent:)
166
+ parts = obj.properties.map do |(key, value)|
167
+ rendered = inline_render_value(value, translator, indent: indent + 2)
168
+ snake = AST::Inflector.underscore(key)
169
+ if snake.match?(/\A[a-z_][a-z0-9_]*\z/)
170
+ "#{snake}: #{rendered}"
171
+ else
172
+ "#{key.inspect} => #{rendered}"
173
+ end
174
+ end
175
+ "{ #{parts.join(", ")} }"
176
+ end
177
+
178
+ def render_factory_array_literal(arr, translator, indent:)
179
+ parts = arr.elements.map do |el|
180
+ el.nil? ? "nil" : inline_render_value(el, translator, indent: indent + 2)
181
+ end
182
+ return "[]" if parts.empty?
183
+
184
+ pad = " " * (indent + 2)
185
+ close_pad = " " * indent
186
+ "[\n#{pad}#{parts.join(",\n#{pad}")}\n#{close_pad}]"
187
+ end
188
+
74
189
  def erb_path(base_name)
75
190
  @layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
76
191
  end
@@ -101,7 +216,12 @@ module JsxRosetta
101
216
  def stimulus_method_lines(method)
102
217
  body_lines = method.body_source.strip.split("\n")
103
218
  commented = body_lines.map { |line| " // #{line}" }
104
- [" // TODO: translate from the original JSX handler:"] + commented + [
219
+ header = [" // TODO: translate from the original JSX handler:"]
220
+ if method.name != method.original_name
221
+ header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
222
+ "to avoid collision with an earlier handler")
223
+ end
224
+ header + commented + [
105
225
  " #{method.name}(event) {",
106
226
  " // ...",
107
227
  " }"
@@ -110,6 +230,19 @@ module JsxRosetta
110
230
 
111
231
  private
112
232
 
233
+ def build_translator(component)
234
+ prop_names = component.props.map(&:name)
235
+ prop_names << component.rest_prop_name if component.rest_prop_name
236
+ prop_aliases = component.props.each_with_object({}) do |prop, hash|
237
+ hash[prop.alias_name] = prop.name if prop.alias_name
238
+ end
239
+ ExpressionTranslator.new(
240
+ prop_names: prop_names,
241
+ local_binding_names: component.local_binding_names,
242
+ prop_aliases: prop_aliases
243
+ )
244
+ end
245
+
113
246
  def initializable_props(component)
114
247
  component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
115
248
  end
@@ -118,6 +251,14 @@ module JsxRosetta
118
251
  props = initializable_props(component)
119
252
  rest_name = component.rest_prop_name
120
253
 
254
+ body = render_class_with_optional_props(component, props, rest_name, translator)
255
+ body = inject_render_method_skeletons(body, component)
256
+
257
+ prefix = render_module_bindings_prefix(component)
258
+ prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
259
+ end
260
+
261
+ def render_class_with_optional_props(component, props, rest_name, translator)
121
262
  if props.empty? && rest_name.nil?
122
263
  <<~RUBY
123
264
  # frozen_string_literal: true
@@ -130,15 +271,62 @@ module JsxRosetta
130
271
  end
131
272
  end
132
273
 
274
+ # For each RenderMethod, emit a method skeleton on the class just
275
+ # before the closing `end`. ERB-rendered VC bodies don't translate
276
+ # cleanly to Ruby methods (Phlex does — see its renderer), so the
277
+ # skeleton stays empty and the JSX source is preserved as a comment
278
+ # for the reviewer to translate by hand.
279
+ def inject_render_method_skeletons(body, component)
280
+ return body if component.render_methods.empty?
281
+
282
+ skeletons = component.render_methods.map { |rm| render_method_skeleton(rm) }
283
+ body.sub(/(\n)end\n\z/, "\n\n#{skeletons.join("\n\n")}\\1end\n")
284
+ end
285
+
286
+ def render_method_skeleton(render_method)
287
+ snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
288
+ signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
289
+ [
290
+ " # TODO: translate the JSX body for #{render_method.name} — was a",
291
+ " # local arrow returning JSX in the source component.",
292
+ " def #{signature}",
293
+ " \"\"",
294
+ " end"
295
+ ].join("\n")
296
+ end
297
+
298
+ def render_module_bindings_prefix(component)
299
+ return "" if component.module_bindings.empty?
300
+
301
+ lines = ["# TODO: module-level constants — translate to Ruby constants " \
302
+ "or move to a Rails initializer:"]
303
+ component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
304
+ "#{lines.join("\n")}\n"
305
+ end
306
+
307
+ # The class body already starts with the magic comment — splice the
308
+ # module-bindings prefix in between so it lands above the class.
309
+ def insert_module_bindings_prefix(body, prefix)
310
+ magic = "# frozen_string_literal: true\n\n"
311
+ return "#{prefix}#{body}" unless body.start_with?(magic)
312
+
313
+ "#{magic}#{prefix}#{body[magic.length..]}"
314
+ end
315
+
316
+ def comment_lines(source)
317
+ source.split("\n").map { |line| "# #{line}" }
318
+ end
319
+
133
320
  def render_ruby_class_with_props(component, props, rest_name, translator)
321
+ rest_snake = rest_name && AST::Inflector.underscore(rest_name)
134
322
  kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
135
- kwargs << "**#{rest_name}" if rest_name
323
+ kwargs << "**#{rest_snake}" if rest_snake
136
324
 
137
325
  assignments = props.map do |prop|
138
326
  snake = AST::Inflector.underscore(prop.name)
139
327
  " @#{snake} = #{snake}"
140
328
  end
141
- assignments << " @#{rest_name} = #{rest_name}" if rest_name
329
+ assignments << " @#{rest_snake} = #{rest_snake}" if rest_snake
142
330
 
143
331
  <<~RUBY
144
332
  # frozen_string_literal: true
@@ -160,8 +348,41 @@ module JsxRosetta
160
348
  def ruby_default_for(prop, translator)
161
349
  return "nil" if prop.default.nil?
162
350
 
163
- translated = translator.translate(prop.default.expression)
164
- translated ? translated.ruby : "nil # TODO: translate #{prop.default.expression.inspect}"
351
+ case prop.default
352
+ when IR::Interpolation
353
+ translated = translator.translate(prop.default.expression)
354
+ translated ? translated.ruby : "nil"
355
+ when IR::ObjectLiteral then render_object_literal_default(prop.default, translator)
356
+ when IR::ArrayLiteral then render_array_literal_default(prop.default, translator)
357
+ else "nil"
358
+ end
359
+ end
360
+
361
+ def render_object_literal_default(object_literal, translator)
362
+ pairs = object_literal.properties.map do |(key, value)|
363
+ snake = AST::Inflector.underscore(key)
364
+ key_str = snake.match?(/\A[a-z_][a-z0-9_]*\z/i) ? "#{snake}:" : "#{key.inspect} =>"
365
+ "#{key_str} #{render_default_inline_value(value, translator)}"
366
+ end
367
+ "{ #{pairs.join(", ")} }"
368
+ end
369
+
370
+ def render_array_literal_default(array_literal, translator)
371
+ parts = array_literal.elements.map { |el| el.nil? ? "nil" : render_default_inline_value(el, translator) }
372
+ "[#{parts.join(", ")}]"
373
+ end
374
+
375
+ def render_default_inline_value(value, translator)
376
+ case value
377
+ when IR::ObjectLiteral then render_object_literal_default(value, translator)
378
+ when IR::ArrayLiteral then render_array_literal_default(value, translator)
379
+ when IR::Interpolation
380
+ translated = translator.translate(value.expression)
381
+ translated ? translated.ruby : "nil"
382
+ when String then value.inspect
383
+ when true then "true"
384
+ else "nil"
385
+ end
165
386
  end
166
387
 
167
388
  def render_erb_template(component, translator)
@@ -194,14 +415,20 @@ module JsxRosetta
194
415
  def render_react_hooks_todo(hooks)
195
416
  return "" if hooks.empty?
196
417
 
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}" }
418
+ blocks = hooks.group_by(&:library).map { |library, calls| hook_todo_block(library, calls) }
419
+ "#{blocks.join("\n")}\n"
420
+ end
421
+
422
+ def hook_todo_block(library, calls)
423
+ header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
424
+ lines = ["<%# #{header_lines.first}"]
425
+ header_lines.drop(1).each { |line| lines << " #{line}" }
426
+ calls.each do |call|
427
+ lines << " operation: #{call.operation}" if call.operation
428
+ lines << " #{call.source}"
429
+ end
203
430
  lines << "%>"
204
- "#{lines.join("\n")}\n"
431
+ lines.join("\n")
205
432
  end
206
433
 
207
434
  def render_ir_node(node, translator, indent:)
@@ -211,6 +438,8 @@ module JsxRosetta
211
438
  when IR::Fragment then render_fragment(node, translator, indent: indent)
212
439
  when IR::Conditional then render_conditional(node, translator, indent: indent)
213
440
  when IR::Loop then render_loop(node, translator, indent: indent)
441
+ when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
442
+ when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
214
443
  when IR::Slot then render_slot(node, indent: indent)
215
444
  when IR::Text then "#{spaces(indent)}#{node.value}"
216
445
  when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
@@ -218,8 +447,31 @@ module JsxRosetta
218
447
  end
219
448
  end
220
449
 
450
+ # `{renderHeader(arg)}` → `<%= render_header(arg) %>`. The matching
451
+ # method definition is emitted on the component class via
452
+ # `render_render_methods_section`. The class method returns an
453
+ # HTML-safe string (Rails' `content_tag` / `safe_join` is the
454
+ # canonical approach), and `<%= %>` interpolates it into the template.
455
+ def render_local_render_call(call, translator, indent:)
456
+ if call.args.empty?
457
+ "#{spaces(indent)}<%= #{call.method_name} %>"
458
+ else
459
+ arg_sources = call.args.map do |arg|
460
+ translated = translator.translate(arg.expression)
461
+ translated ? translated.ruby : arg.expression
462
+ end
463
+ "#{spaces(indent)}<%= #{call.method_name}(#{arg_sources.join(", ")}) %>"
464
+ end
465
+ end
466
+
467
+ def render_orphan_render_prop(render_prop, translator, indent:)
468
+ translator.with_locals(render_prop.params) do
469
+ render_ir_node(render_prop.body, translator, indent: indent)
470
+ end
471
+ end
472
+
221
473
  def render_loop(loop_node, translator, indent:)
222
- iterable_ruby = render_test_expression(loop_node.iterable, translator)
474
+ iterable_ruby = render_loop_iterable(loop_node.iterable, translator)
223
475
  js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
224
476
  ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
225
477
  binding_str = "|#{ruby_bindings.join(", ")}|"
@@ -235,6 +487,14 @@ module JsxRosetta
235
487
  ].join("\n")
236
488
  end
237
489
 
490
+ def render_loop_iterable(iterable, translator)
491
+ case iterable
492
+ when IR::ArrayLiteral then render_array_literal_default(iterable, translator)
493
+ when IR::Interpolation then render_test_expression(iterable, translator)
494
+ else "[]"
495
+ end
496
+ end
497
+
238
498
  def render_element(element, translator, indent:)
239
499
  return render_element_with_tag_builder(element, translator, indent: indent) if needs_tag_builder?(element)
240
500
 
@@ -312,13 +572,11 @@ module JsxRosetta
312
572
 
313
573
  def tag_builder_data_action(events, translator)
314
574
  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(" ")}")
575
+ joined = case descriptors
576
+ in [single]
577
+ render_single_event_descriptor(single)
320
578
  else
321
- %("#{descriptors.map { |d| literal_to_interpolated(d) }.join(" ")}")
579
+ %("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
322
580
  end
323
581
  %("data-action" => #{joined})
324
582
  end
@@ -327,23 +585,34 @@ module JsxRosetta
327
585
  case event
328
586
  when IR::EventBinding
329
587
  translated = translator.translate(event.handler.expression)
330
- translated ? translated.ruby : event.handler.expression.inspect
588
+ if translated
589
+ EventDescriptor.new(:ruby, translated.ruby)
590
+ else
591
+ EventDescriptor.new(:ruby, event.handler.expression.inspect)
592
+ end
331
593
  when IR::StimulusBinding
332
- %("#{event.event}->#{@stimulus_identifier}##{event.method_name}")
594
+ EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
333
595
  end
334
596
  end
335
597
 
336
- def literal_to_interpolated(descriptor)
337
- if descriptor.start_with?('"') && descriptor.end_with?('"')
338
- descriptor[1..-2]
339
- else
340
- "\#{#{descriptor}}"
341
- end
598
+ def render_single_event_descriptor(descriptor)
599
+ descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
600
+ end
601
+
602
+ # Render a descriptor inline inside a Ruby string literal: literals
603
+ # are spliced verbatim, ruby expressions become `#{...}`.
604
+ def descriptor_in_string(descriptor)
605
+ descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
342
606
  end
343
607
 
608
+ # Wrap the spread expression in `(… || {})` so a nil-valued prop
609
+ # default doesn't raise at render time. `<div {...maybeNil}>` →
610
+ # `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
611
+ # `|| {}` shortcuts on non-nil values.
344
612
  def tag_builder_spread(expression, translator)
345
613
  translated = translator.translate(expression)
346
- translated ? translated.ruby : expression
614
+ ruby = translated ? translated.ruby : expression
615
+ "(#{ruby} || {})"
347
616
  end
348
617
 
349
618
  def render_component_invocation(invocation, translator, indent:)
@@ -354,7 +623,10 @@ module JsxRosetta
354
623
  class_name = component_class_name(invocation.name)
355
624
  new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
356
625
 
357
- if invocation.children.empty?
626
+ render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
627
+ if render_prop
628
+ render_component_with_render_prop(new_call, render_prop, translator, indent)
629
+ elsif invocation.children.empty?
358
630
  "#{spaces(indent)}<%= render #{new_call} %>"
359
631
  else
360
632
  inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
@@ -362,6 +634,15 @@ module JsxRosetta
362
634
  end
363
635
  end
364
636
 
637
+ def render_component_with_render_prop(new_call, render_prop, translator, indent)
638
+ snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
639
+ param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
640
+ inner = translator.with_locals(render_prop.params) do
641
+ render_ir_node(render_prop.body, translator, indent: indent + 2)
642
+ end
643
+ "#{spaces(indent)}<%= render #{new_call} do#{param_str} %>\n#{inner}\n#{spaces(indent)}<% end %>"
644
+ end
645
+
365
646
  # JSX `<Foo.Bar>` → Ruby `Foo::BarComponent`. Plain `<Card>` stays as
366
647
  # `CardComponent`. Each member-expression segment joins with `::`,
367
648
  # and `Component` suffixes the leaf so the result is a constant path
@@ -424,9 +705,16 @@ module JsxRosetta
424
705
  lines.join("\n")
425
706
  end
426
707
 
708
+ # A translated value of `"nil"` is treated as untranslatable: the
709
+ # translator emits `"nil"` for known-local bindings (so the file
710
+ # loads as a leaf reference), but driving an `<% if %>` with `nil`
711
+ # silently disables the whole branch. Fall back to the verbatim
712
+ # expression so the human reviewer sees what needs translating.
427
713
  def render_test_expression(test, translator)
428
714
  translated = translator.translate(test.expression)
429
- translated ? translated.ruby : test.expression
715
+ return translated.ruby if translated && translated.ruby != "nil"
716
+
717
+ test.expression
430
718
  end
431
719
 
432
720
  def render_slot(slot, indent:)
@@ -455,52 +743,53 @@ module JsxRosetta
455
743
  end
456
744
 
457
745
  def render_style(style, translator)
458
- rendered = style.declarations.map { |decl| render_style_declaration(decl, translator) }.join(" ")
746
+ rendered = style.declarations.map { |decl| style_declaration(decl, translator, format: :erb) }.join(" ")
459
747
  %(style="#{rendered}")
460
748
  end
461
749
 
462
- def render_style_declaration(decl, translator)
750
+ def render_class_list_attribute(class_list, translator)
751
+ parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :erb) }
752
+ %(class="#{parts.join(" ")}")
753
+ end
754
+
755
+ def class_list_to_ruby_string(class_list, translator)
756
+ parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :ruby_string) }
757
+ %("#{parts.join(" ")}")
758
+ end
759
+
760
+ # Render one IR::Style declaration in either ERB-template form
761
+ # (`color: <%= @c %>;`) or Ruby-string-interpolation form
762
+ # (`color: #{@c};`).
763
+ def style_declaration(decl, translator, format:)
463
764
  value = case decl.value
464
765
  when String then decl.value
465
- when IR::Interpolation
466
- translated = translator.translate(decl.value.expression)
467
- "<%= #{translated&.ruby || decl.value.expression} %>"
766
+ when IR::Interpolation then interpolation_value(decl.value.expression, translator, format: format)
468
767
  end
469
768
  "#{decl.property}: #{value};"
470
769
  end
471
770
 
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)
771
+ # Render one ClassList segment in either ERB-template form or Ruby
772
+ # string-interpolation form.
773
+ def class_segment(segment, translator, format:)
478
774
  case segment
479
775
  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} : '' %>"
776
+ when IR::Interpolation then interpolation_value(segment.expression, translator, format: format)
777
+ when IR::ConditionalSegment then conditional_class_segment(segment, translator, format: format)
487
778
  end
488
779
  end
489
780
 
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(" ")}")
781
+ def interpolation_value(expression, translator, format:)
782
+ translated = translator.translate(expression)
783
+ ruby = translated&.ruby || expression
784
+ format == :erb ? "<%= #{ruby} %>" : "\#{#{ruby}}"
493
785
  end
494
786
 
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
787
+ def conditional_class_segment(segment, translator, format:)
788
+ cond_translated = translator.translate(segment.condition.expression)
789
+ cond_ruby = cond_translated&.ruby || segment.condition.expression
790
+ if format == :erb
791
+ "<%= #{cond_ruby} ? #{segment.class_name.inspect} : '' %>"
792
+ else
504
793
  %(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
505
794
  end
506
795
  end
@@ -536,7 +825,7 @@ module JsxRosetta
536
825
  # literal portions literally and the interpolations as ERB tags.
537
826
  def plain_attribute_value_erb(interpolation, translator)
538
827
  translated = translator.translate(interpolation.expression)
539
- if double_quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
828
+ if quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
540
829
  inlined_ruby_string(translated.ruby)
541
830
  else
542
831
  interpolation_to_erb(interpolation, translator)
@@ -545,7 +834,7 @@ module JsxRosetta
545
834
 
546
835
  def render_style_binding(binding, translator)
547
836
  translated = translator.translate(binding.expression)
548
- if double_quoted_ruby_string?(translated&.ruby)
837
+ if quoted_ruby_string?(translated&.ruby)
549
838
  %(class="#{inlined_ruby_string(translated.ruby)}")
550
839
  elsif translated
551
840
  %(class="<%= #{translated.ruby} %>")
@@ -554,8 +843,11 @@ module JsxRosetta
554
843
  end
555
844
  end
556
845
 
557
- def double_quoted_ruby_string?(ruby)
558
- ruby.is_a?(String) && ruby.start_with?('"') && ruby.end_with?('"')
846
+ def quoted_ruby_string?(ruby)
847
+ ruby.is_a?(String) && (
848
+ (ruby.start_with?('"') && ruby.end_with?('"')) ||
849
+ (ruby.start_with?("'") && ruby.end_with?("'"))
850
+ )
559
851
  end
560
852
 
561
853
  # Given a Ruby double-quoted string with #{...} interpolations, emit it
@@ -598,15 +890,7 @@ module JsxRosetta
598
890
  end
599
891
 
600
892
  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
893
+ parts = style.declarations.map { |decl| style_declaration(decl, translator, format: :ruby_string) }
610
894
  %("#{parts.join(" ")}")
611
895
  end
612
896
 
@@ -4,6 +4,7 @@ require_relative "backend/base"
4
4
  require_relative "backend/view_component"
5
5
  require_relative "backend/view_component/expression_translator"
6
6
  require_relative "backend/rails_view"
7
+ require_relative "backend/phlex"
7
8
  require_relative "backend/routes_script"
8
9
 
9
10
  module JsxRosetta