jsx_rosetta 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,9 @@ module JsxRosetta
18
18
  def emit(component)
19
19
  prop_names = component.props.map(&:name)
20
20
  prop_names << component.rest_prop_name if component.rest_prop_name
21
- translator = ExpressionTranslator.new(prop_names: prop_names)
21
+ translator = ExpressionTranslator.new(
22
+ prop_names: prop_names, local_binding_names: component.local_binding_names
23
+ )
22
24
 
23
25
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
24
26
 
@@ -18,6 +18,10 @@ module JsxRosetta
18
18
  # to the bare snake_case identifier.
19
19
  # * Names in `prop_names` translate to a `@snake_case` instance
20
20
  # variable.
21
+ # * Names in `local_binding_names` (consts/destructures captured at
22
+ # lowering time but not modeled in IR) translate to a `nil`
23
+ # placeholder with an inline `# TODO: local 'name'` marker — the
24
+ # file still loads, but the reviewer sees what to fill in.
21
25
  # * Anything else translates to the bare snake_case identifier and
22
26
  # is recorded as unresolved.
23
27
  #
@@ -35,8 +39,9 @@ module JsxRosetta
35
39
 
36
40
  Result = Data.define(:ruby, :unresolved_identifiers)
37
41
 
38
- def initialize(prop_names:)
42
+ def initialize(prop_names:, local_binding_names: [])
39
43
  @prop_names = prop_names.to_set
44
+ @local_binding_names = local_binding_names.to_set
40
45
  @local_stack = []
41
46
  end
42
47
 
@@ -77,12 +82,27 @@ module JsxRosetta
77
82
  @local_stack.any? { |scope| scope.include?(name) }
78
83
  end
79
84
 
80
- def translate_identifier(name, unresolved)
85
+ def translate_identifier(name, unresolved, member_chain_root: false)
81
86
  snake = AST::Inflector.underscore(name)
82
87
  if in_local_scope?(name)
83
88
  snake
84
89
  elsif @prop_names.include?(name)
85
90
  "@#{snake}"
91
+ elsif @local_binding_names.include?(name)
92
+ # We know this binding exists locally (destructure, hook tuple)
93
+ # but can't model its value. As a leaf identifier, return `nil`
94
+ # so the file loads (a bare snake_case ref would NameError).
95
+ # As a member-chain root, `nil.member` would NoMethodError at
96
+ # render time — worse. Fall back to the snake_case bare ref
97
+ # and let it surface as a NameError (caller adds an unresolved
98
+ # marker), which is at least debuggable. The TODO marker for
99
+ # the binding source already lives in the comment block.
100
+ if member_chain_root
101
+ unresolved << name
102
+ snake
103
+ else
104
+ "nil"
105
+ end
86
106
  else
87
107
  unresolved << name
88
108
  snake
@@ -90,7 +110,7 @@ module JsxRosetta
90
110
  end
91
111
 
92
112
  def translate_member_chain(root, rest, unresolved)
93
- translated_root = translate_identifier(root, unresolved)
113
+ translated_root = translate_identifier(root, unresolved, member_chain_root: true)
94
114
  # Underscore each chain segment so JS camelCase identifiers map to
95
115
  # Ruby snake_case (`post.coverImage` → `post.cover_image`).
96
116
  ruby_rest = rest.gsub(/\.([a-zA-Z_$][a-zA-Z_$0-9]*)/) do
@@ -103,16 +123,38 @@ module JsxRosetta
103
123
  return nil if content.include?("\\`")
104
124
  return nil if content.scan("${").size != content.scan(TEMPLATE_INTERPOLATION).size
105
125
 
106
- ruby_content = content.gsub(TEMPLATE_INTERPOLATION) do |_match|
107
- captured = ::Regexp.last_match(1)
108
- translated = if (m = MEMBER_CHAIN.match(captured))
109
- translate_member_chain(m[:root], m[:rest], unresolved)
110
- else
111
- translate_identifier(captured, unresolved)
112
- end
113
- "\#{#{translated}}"
126
+ parts = []
127
+ last_pos = 0
128
+ content.to_enum(:scan, TEMPLATE_INTERPOLATION).each do
129
+ match = ::Regexp.last_match
130
+ literal = content[last_pos...match.begin(0)]
131
+ parts << escape_ruby_string_literal(literal) unless literal.empty?
132
+ parts << "\#{#{translate_template_interpolation(match[1], unresolved)}}"
133
+ last_pos = match.end(0)
114
134
  end
115
- %("#{ruby_content}")
135
+ trailing = content[last_pos..]
136
+ parts << escape_ruby_string_literal(trailing) unless trailing.empty?
137
+ %("#{parts.join}")
138
+ end
139
+
140
+ # Split into literal vs. interpolation segments so `"` and `\` in
141
+ # the literal parts can be escaped without touching the
142
+ # interpolation expressions (which are already valid Ruby).
143
+ def translate_template_interpolation(captured, unresolved)
144
+ if (m = MEMBER_CHAIN.match(captured))
145
+ translate_member_chain(m[:root], m[:rest], unresolved)
146
+ else
147
+ translate_identifier(captured, unresolved)
148
+ end
149
+ end
150
+
151
+ # Escape backslashes and double quotes so the literal portions of a
152
+ # translated template literal don't accidentally terminate the
153
+ # surrounding Ruby string. Newlines stay literal — Ruby double-quoted
154
+ # strings allow them, and template literals are typically used for
155
+ # short interpolated phrases anyway.
156
+ def escape_ruby_string_literal(text)
157
+ text.gsub("\\", "\\\\").gsub('"', '\\"')
116
158
  end
117
159
  end
118
160
  end
@@ -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`.
@@ -53,7 +59,9 @@ module JsxRosetta
53
59
  def emit(component)
54
60
  prop_names = component.props.map(&:name)
55
61
  prop_names << component.rest_prop_name if component.rest_prop_name
56
- translator = ExpressionTranslator.new(prop_names: prop_names)
62
+ translator = ExpressionTranslator.new(
63
+ prop_names: prop_names, local_binding_names: component.local_binding_names
64
+ )
57
65
 
58
66
  base_name = "#{AST::Inflector.underscore(component.name)}_component"
59
67
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
@@ -101,7 +109,12 @@ module JsxRosetta
101
109
  def stimulus_method_lines(method)
102
110
  body_lines = method.body_source.strip.split("\n")
103
111
  commented = body_lines.map { |line| " // #{line}" }
104
- [" // TODO: translate from the original JSX handler:"] + commented + [
112
+ header = [" // TODO: translate from the original JSX handler:"]
113
+ if method.name != method.original_name
114
+ header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
115
+ "to avoid collision with an earlier handler")
116
+ end
117
+ header + commented + [
105
118
  " #{method.name}(event) {",
106
119
  " // ...",
107
120
  " }"
@@ -118,16 +131,41 @@ module JsxRosetta
118
131
  props = initializable_props(component)
119
132
  rest_name = component.rest_prop_name
120
133
 
121
- if props.empty? && rest_name.nil?
122
- <<~RUBY
123
- # frozen_string_literal: true
134
+ body = if props.empty? && rest_name.nil?
135
+ <<~RUBY
136
+ # frozen_string_literal: true
124
137
 
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
138
+ class #{component.name}Component < ::ViewComponent::Base
139
+ end
140
+ RUBY
141
+ else
142
+ render_ruby_class_with_props(component, props, rest_name, translator)
143
+ end
144
+
145
+ prefix = render_module_bindings_prefix(component)
146
+ prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
147
+ end
148
+
149
+ def render_module_bindings_prefix(component)
150
+ return "" if component.module_bindings.empty?
151
+
152
+ lines = ["# TODO: module-level constants — translate to Ruby constants " \
153
+ "or move to a Rails initializer:"]
154
+ component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
155
+ "#{lines.join("\n")}\n"
156
+ end
157
+
158
+ # The class body already starts with the magic comment — splice the
159
+ # module-bindings prefix in between so it lands above the class.
160
+ def insert_module_bindings_prefix(body, prefix)
161
+ magic = "# frozen_string_literal: true\n\n"
162
+ return "#{prefix}#{body}" unless body.start_with?(magic)
163
+
164
+ "#{magic}#{prefix}#{body[magic.length..]}"
165
+ end
166
+
167
+ def comment_lines(source)
168
+ source.split("\n").map { |line| "# #{line}" }
131
169
  end
132
170
 
133
171
  def render_ruby_class_with_props(component, props, rest_name, translator)
@@ -160,8 +198,41 @@ module JsxRosetta
160
198
  def ruby_default_for(prop, translator)
161
199
  return "nil" if prop.default.nil?
162
200
 
163
- translated = translator.translate(prop.default.expression)
164
- translated ? translated.ruby : "nil # TODO: translate #{prop.default.expression.inspect}"
201
+ case prop.default
202
+ when IR::Interpolation
203
+ translated = translator.translate(prop.default.expression)
204
+ translated ? translated.ruby : "nil"
205
+ when IR::ObjectLiteral then render_object_literal_default(prop.default, translator)
206
+ when IR::ArrayLiteral then render_array_literal_default(prop.default, translator)
207
+ else "nil"
208
+ end
209
+ end
210
+
211
+ def render_object_literal_default(object_literal, translator)
212
+ pairs = object_literal.properties.map do |(key, value)|
213
+ snake = AST::Inflector.underscore(key)
214
+ key_str = snake.match?(/\A[a-z_][a-z0-9_]*\z/i) ? "#{snake}:" : "#{key.inspect} =>"
215
+ "#{key_str} #{render_default_inline_value(value, translator)}"
216
+ end
217
+ "{ #{pairs.join(", ")} }"
218
+ end
219
+
220
+ def render_array_literal_default(array_literal, translator)
221
+ parts = array_literal.elements.map { |el| el.nil? ? "nil" : render_default_inline_value(el, translator) }
222
+ "[#{parts.join(", ")}]"
223
+ end
224
+
225
+ def render_default_inline_value(value, translator)
226
+ case value
227
+ when IR::ObjectLiteral then render_object_literal_default(value, translator)
228
+ when IR::ArrayLiteral then render_array_literal_default(value, translator)
229
+ when IR::Interpolation
230
+ translated = translator.translate(value.expression)
231
+ translated ? translated.ruby : "nil"
232
+ when String then value.inspect
233
+ when true then "true"
234
+ else "nil"
235
+ end
165
236
  end
166
237
 
167
238
  def render_erb_template(component, translator)
@@ -211,6 +282,7 @@ module JsxRosetta
211
282
  when IR::Fragment then render_fragment(node, translator, indent: indent)
212
283
  when IR::Conditional then render_conditional(node, translator, indent: indent)
213
284
  when IR::Loop then render_loop(node, translator, indent: indent)
285
+ when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
214
286
  when IR::Slot then render_slot(node, indent: indent)
215
287
  when IR::Text then "#{spaces(indent)}#{node.value}"
216
288
  when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
@@ -218,8 +290,14 @@ module JsxRosetta
218
290
  end
219
291
  end
220
292
 
293
+ def render_orphan_render_prop(render_prop, translator, indent:)
294
+ translator.with_locals(render_prop.params) do
295
+ render_ir_node(render_prop.body, translator, indent: indent)
296
+ end
297
+ end
298
+
221
299
  def render_loop(loop_node, translator, indent:)
222
- iterable_ruby = render_test_expression(loop_node.iterable, translator)
300
+ iterable_ruby = render_loop_iterable(loop_node.iterable, translator)
223
301
  js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
224
302
  ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
225
303
  binding_str = "|#{ruby_bindings.join(", ")}|"
@@ -235,6 +313,14 @@ module JsxRosetta
235
313
  ].join("\n")
236
314
  end
237
315
 
316
+ def render_loop_iterable(iterable, translator)
317
+ case iterable
318
+ when IR::ArrayLiteral then render_array_literal_default(iterable, translator)
319
+ when IR::Interpolation then render_test_expression(iterable, translator)
320
+ else "[]"
321
+ end
322
+ end
323
+
238
324
  def render_element(element, translator, indent:)
239
325
  return render_element_with_tag_builder(element, translator, indent: indent) if needs_tag_builder?(element)
240
326
 
@@ -312,13 +398,11 @@ module JsxRosetta
312
398
 
313
399
  def tag_builder_data_action(events, translator)
314
400
  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(" ")}")
401
+ joined = case descriptors
402
+ in [single]
403
+ render_single_event_descriptor(single)
320
404
  else
321
- %("#{descriptors.map { |d| literal_to_interpolated(d) }.join(" ")}")
405
+ %("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
322
406
  end
323
407
  %("data-action" => #{joined})
324
408
  end
@@ -327,23 +411,34 @@ module JsxRosetta
327
411
  case event
328
412
  when IR::EventBinding
329
413
  translated = translator.translate(event.handler.expression)
330
- translated ? translated.ruby : event.handler.expression.inspect
414
+ if translated
415
+ EventDescriptor.new(:ruby, translated.ruby)
416
+ else
417
+ EventDescriptor.new(:ruby, event.handler.expression.inspect)
418
+ end
331
419
  when IR::StimulusBinding
332
- %("#{event.event}->#{@stimulus_identifier}##{event.method_name}")
420
+ EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
333
421
  end
334
422
  end
335
423
 
336
- def literal_to_interpolated(descriptor)
337
- if descriptor.start_with?('"') && descriptor.end_with?('"')
338
- descriptor[1..-2]
339
- else
340
- "\#{#{descriptor}}"
341
- end
424
+ def render_single_event_descriptor(descriptor)
425
+ descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
342
426
  end
343
427
 
428
+ # Render a descriptor inline inside a Ruby string literal: literals
429
+ # are spliced verbatim, ruby expressions become `#{...}`.
430
+ def descriptor_in_string(descriptor)
431
+ descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
432
+ end
433
+
434
+ # Wrap the spread expression in `(… || {})` so a nil-valued prop
435
+ # default doesn't raise at render time. `<div {...maybeNil}>` →
436
+ # `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
437
+ # `|| {}` shortcuts on non-nil values.
344
438
  def tag_builder_spread(expression, translator)
345
439
  translated = translator.translate(expression)
346
- translated ? translated.ruby : expression
440
+ ruby = translated ? translated.ruby : expression
441
+ "(#{ruby} || {})"
347
442
  end
348
443
 
349
444
  def render_component_invocation(invocation, translator, indent:)
@@ -354,7 +449,10 @@ module JsxRosetta
354
449
  class_name = component_class_name(invocation.name)
355
450
  new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
356
451
 
357
- if invocation.children.empty?
452
+ render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
453
+ if render_prop
454
+ render_component_with_render_prop(new_call, render_prop, translator, indent)
455
+ elsif invocation.children.empty?
358
456
  "#{spaces(indent)}<%= render #{new_call} %>"
359
457
  else
360
458
  inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
@@ -362,6 +460,15 @@ module JsxRosetta
362
460
  end
363
461
  end
364
462
 
463
+ def render_component_with_render_prop(new_call, render_prop, translator, indent)
464
+ snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
465
+ param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
466
+ inner = translator.with_locals(render_prop.params) do
467
+ render_ir_node(render_prop.body, translator, indent: indent + 2)
468
+ end
469
+ "#{spaces(indent)}<%= render #{new_call} do#{param_str} %>\n#{inner}\n#{spaces(indent)}<% end %>"
470
+ end
471
+
365
472
  # JSX `<Foo.Bar>` → Ruby `Foo::BarComponent`. Plain `<Card>` stays as
366
473
  # `CardComponent`. Each member-expression segment joins with `::`,
367
474
  # and `Component` suffixes the leaf so the result is a constant path
@@ -455,52 +562,53 @@ module JsxRosetta
455
562
  end
456
563
 
457
564
  def render_style(style, translator)
458
- rendered = style.declarations.map { |decl| render_style_declaration(decl, translator) }.join(" ")
565
+ rendered = style.declarations.map { |decl| style_declaration(decl, translator, format: :erb) }.join(" ")
459
566
  %(style="#{rendered}")
460
567
  end
461
568
 
462
- def render_style_declaration(decl, translator)
569
+ def render_class_list_attribute(class_list, translator)
570
+ parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :erb) }
571
+ %(class="#{parts.join(" ")}")
572
+ end
573
+
574
+ def class_list_to_ruby_string(class_list, translator)
575
+ parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :ruby_string) }
576
+ %("#{parts.join(" ")}")
577
+ end
578
+
579
+ # Render one IR::Style declaration in either ERB-template form
580
+ # (`color: <%= @c %>;`) or Ruby-string-interpolation form
581
+ # (`color: #{@c};`).
582
+ def style_declaration(decl, translator, format:)
463
583
  value = case decl.value
464
584
  when String then decl.value
465
- when IR::Interpolation
466
- translated = translator.translate(decl.value.expression)
467
- "<%= #{translated&.ruby || decl.value.expression} %>"
585
+ when IR::Interpolation then interpolation_value(decl.value.expression, translator, format: format)
468
586
  end
469
587
  "#{decl.property}: #{value};"
470
588
  end
471
589
 
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)
590
+ # Render one ClassList segment in either ERB-template form or Ruby
591
+ # string-interpolation form.
592
+ def class_segment(segment, translator, format:)
478
593
  case segment
479
594
  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} : '' %>"
595
+ when IR::Interpolation then interpolation_value(segment.expression, translator, format: format)
596
+ when IR::ConditionalSegment then conditional_class_segment(segment, translator, format: format)
487
597
  end
488
598
  end
489
599
 
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(" ")}")
600
+ def interpolation_value(expression, translator, format:)
601
+ translated = translator.translate(expression)
602
+ ruby = translated&.ruby || expression
603
+ format == :erb ? "<%= #{ruby} %>" : "\#{#{ruby}}"
493
604
  end
494
605
 
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
606
+ def conditional_class_segment(segment, translator, format:)
607
+ cond_translated = translator.translate(segment.condition.expression)
608
+ cond_ruby = cond_translated&.ruby || segment.condition.expression
609
+ if format == :erb
610
+ "<%= #{cond_ruby} ? #{segment.class_name.inspect} : '' %>"
611
+ else
504
612
  %(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
505
613
  end
506
614
  end
@@ -598,15 +706,7 @@ module JsxRosetta
598
706
  end
599
707
 
600
708
  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
709
+ parts = style.declarations.map { |decl| style_declaration(decl, translator, format: :ruby_string) }
610
710
  %("#{parts.join(" ")}")
611
711
  end
612
712
 
@@ -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
@@ -63,16 +63,40 @@ module JsxRosetta
63
63
 
64
64
  out_dir = options[:out] || "."
65
65
  typescript = options[:tsx] || input_path.end_with?(".tsx")
66
- backend = options[:as] == "view" ? :rails_view : :view_component
66
+ backend = backend_for_as(options[:as])
67
+ backend_options = backend_options_for(backend, options)
67
68
 
68
69
  source = File.read(input_path)
69
70
  files = JsxRosetta.translate(
70
71
  source,
71
72
  backend: backend,
73
+ backend_options: backend_options,
72
74
  typescript: typescript,
73
75
  source_filename: input_path
74
76
  )
75
77
 
78
+ write_emitted_files(files, out_dir)
79
+ EXIT_OK
80
+ rescue ParseError, IR::Lowering::LoweringError, ArgumentError => e
81
+ @stderr.puts "jsx_rosetta translate: #{e.message}"
82
+ EXIT_FAILURE
83
+ end
84
+
85
+ def backend_for_as(value)
86
+ case value
87
+ when "view" then :rails_view
88
+ when "phlex" then :phlex
89
+ else :view_component
90
+ end
91
+ end
92
+
93
+ def backend_options_for(backend, options)
94
+ return {} unless backend == :phlex
95
+
96
+ { suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
97
+ end
98
+
99
+ def write_emitted_files(files, out_dir)
76
100
  FileUtils.mkdir_p(out_dir)
77
101
  files.each do |file|
78
102
  target = File.join(out_dir, file.path)
@@ -80,10 +104,6 @@ module JsxRosetta
80
104
  File.write(target, file.contents)
81
105
  @stdout.puts "wrote #{target}"
82
106
  end
83
- EXIT_OK
84
- rescue ParseError, IR::Lowering::LoweringError => e
85
- @stderr.puts "jsx_rosetta translate: #{e.message}"
86
- EXIT_FAILURE
87
107
  end
88
108
 
89
109
  def run_routes
@@ -142,6 +162,10 @@ module JsxRosetta
142
162
  when "--tsx", "--typescript" then options[:tsx] = true
143
163
  when "--as" then options[:as] = @argv.shift
144
164
  when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
165
+ when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
166
+ when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
167
+ when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
168
+ when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
145
169
  else positional << arg
146
170
  end
147
171
  end
@@ -166,6 +190,10 @@ module JsxRosetta
166
190
  Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
167
191
  instead of a ViewComponent class + sidecar template — appropriate
168
192
  for pages tied to a route.
193
+ Pass --as=phlex to emit a single-file Phlex 2.x view class
194
+ (`<snake>.rb`) instead of a ViewComponent. Configure the class
195
+ name with --phlex-suffix=Component or --phlex-namespace=Components
196
+ (mutually exclusive; default is bare class name).
169
197
  routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
170
198
  and emit a reviewable Ruby script that calls `rails generate
171
199
  controller` and prints suggested config/routes.rb additions.