Class: JsxRosetta::Backend::ViewComponent

Inherits:
Base
  • Object
show all
Defined in:
lib/jsx_rosetta/backend/view_component.rb,
lib/jsx_rosetta/backend/view_component/expression_translator.rb

Overview

Emits a Rails ViewComponent (one Ruby class + one ERB template) from an IR::Component.

Phase 3 scope:

- Single component per emit.
- JSX prop names lowered to snake_case Ruby kwargs and matching
  `@instance_variable` assignments.
- JS expressions translated via ExpressionTranslator where the
  shape is recognized; otherwise emitted as a TODO marker plus
  verbatim source.
- HTML attributes emitted directly; className / template-literal
  class expressions inlined into the `class="..."` attribute.

Phase 4a additions:

- `children` prop is treated as ViewComponent's default content
  slot: it's filtered out of the initializer and rendered as
  `<%= content %>` wherever the IR has IR::Slot(name: "children").
- IR::Conditional renders as `<% if %>...<% else %>...<% end %>`.

Direct Known Subclasses

RailsView

Defined Under Namespace

Classes: EventDescriptor, ExpressionTranslator

Constant Summary collapse

DEFAULT_SLOT_NAME =
"children"
VOID_ELEMENTS =
%w[area base br col embed hr img input link meta param source track wbr].freeze
DEFAULT_HELPERS =

JSX component names that have a direct Rails view-helper analog. Override per-instance via ‘ViewComponent.new(helpers: …)`, or disable by passing `helpers: false`.

{
  "Link" => { method: :link_to, positional: :href }.freeze,
  "Image" => { method: :image_tag, positional: :src }.freeze
}.freeze
HOOK_TODO_HEADERS =

Per-library TODO header lines surfaced above the verbatim hook source. Each library has a different Rails analog, so we don’t collapse them into a single generic block. Keys must mirror the ‘:library` values produced by IR::Lowering. First line is rendered with the `<%#` opener; subsequent lines are indented continuation.

{
  react: [
    "TODO: React hooks detected. None translate automatically. Hotwire/Stimulus",
    "handles behavior; controllers/views handle state; turbo-frames handle async",
    "loading. Original source:"
  ].freeze,
  apollo: [
    "TODO: Apollo data-fetching hooks detected. None translate automatically.",
    "Move the fetch to the Rails controller (or a model/service); pass the",
    "result in as a prop. For useMutation, use a form POST + redirect or a",
    "Turbo Stream response. Original source:"
  ].freeze,
  next_js: [
    "TODO: Next.js navigation hooks detected. None translate automatically.",
    "Rails analogs: useRouter -> redirect_to / form actions;",
    "usePathname -> request.path; useSearchParams / useParams -> params;",
    "useSelectedLayoutSegment(s) -> match against request.path in the view.",
    "Original source:"
  ].freeze
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(helpers: nil, layout: :sidecar) ⇒ ViewComponent

Returns a new instance of ViewComponent.



71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/jsx_rosetta/backend/view_component.rb', line 71

def initialize(helpers: nil, layout: :sidecar)
  super()
  @helpers = case helpers
             when nil then DEFAULT_HELPERS
             when false then {}
             else helpers
             end
  unless %i[sidecar flat].include?(layout)
    raise ArgumentError, "unknown layout: #{layout.inspect} (expected :sidecar or :flat)"
  end

  @layout = layout
end

Instance Method Details

#class_name_for(component) ⇒ Object



137
138
139
# File 'lib/jsx_rosetta/backend/view_component.rb', line 137

def class_name_for(component)
  component.name[0].upcase + component.name[1..]
end

#data_factory_signature(method_name, param_names) ⇒ Object



141
142
143
144
145
146
# File 'lib/jsx_rosetta/backend/view_component.rb', line 141

def data_factory_signature(method_name, param_names)
  return method_name if param_names.empty?

  kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
  "#{method_name}(#{kwargs.join(", ")})"
end

#emit(component) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/jsx_rosetta/backend/view_component.rb', line 85

def emit(component)
  translator = build_translator(component)
  base_name = "#{AST::Inflector.underscore(component.name)}_component"
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil

  # Data-factory components (column-descriptor modules) have no
  # template — they're pure-data classes. Skip the .erb pair and
  # emit a single .rb with the factory method. JSX render lambdas
  # inside the data have nowhere to live in the ViewComponent
  # ERB-template world, so we surface a TODO note in the class.
  return emit_data_factory(component) if component.mode == :data_factory

  files = [
    File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
    File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
  ]
  if component.stimulus_methods.any?
    files << File.new(
      path: stimulus_path(component, base_name),
      contents: render_stimulus_controller_js(component)
    )
  end
  files
end

#emit_data_factory(component) ⇒ Object

For data-factory components emit a plain Ruby class (no ApplicationViewComponent base, no ERB template). The user can mix it into a ViewComponent or call the method directly — the goal is to surface the translated data array, not to render it in isolation.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/jsx_rosetta/backend/view_component.rb', line 114

def emit_data_factory(component)
  method_name = AST::Inflector.underscore(component.name)
  param_names = component.props.map(&:name)
  translator = ExpressionTranslator.new(prop_names: [], local_binding_names: param_names)
  signature = data_factory_signature(method_name, param_names)
  body = translator.with_locals(param_names) do
    inline_render_value(component.body, translator, indent: 4)
  end
  contents = <<~RUBY
    # frozen_string_literal: true

    # TODO: data-factory module — the translated array contains JSX render
    # lambdas as `nil` placeholders. Wire each up to a Phlex helper or a
    # method on the consuming ViewComponent.
    class #{class_name_for(component)}
      def #{signature}
        #{body}
      end
    end
  RUBY
  [File.new(path: "#{AST::Inflector.underscore(component.name)}.rb", contents: contents)]
end

#erb_path(base_name) ⇒ Object



189
190
191
# File 'lib/jsx_rosetta/backend/view_component.rb', line 189

def erb_path(base_name)
  @layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
end

#inline_render_value(value, translator, indent: 0) ⇒ Object

Recursively render a non-JSX value (ObjectLiteral / ArrayLiteral / Lambda / Interpolation / primitives) without the kwarg-list context the Phlex backend uses. IR::Lambda and unmatched cases both fall through to ‘nil` — there’s no Phlex class to host a rendered method body, and the class-level TODO comment above the emitted file already flags both for the reviewer.



154
155
156
157
158
159
160
161
162
163
# File 'lib/jsx_rosetta/backend/view_component.rb', line 154

def inline_render_value(value, translator, indent: 0)
  case value
  when IR::ObjectLiteral then render_factory_object_literal(value, translator, indent: indent)
  when IR::ArrayLiteral then render_factory_array_literal(value, translator, indent: indent)
  when IR::Interpolation then translator.translate(value.expression)&.ruby || "nil"
  when String then value.inspect
  when true then "true"
  else "nil"
  end
end

#render_factory_array_literal(arr, translator, indent:) ⇒ Object



178
179
180
181
182
183
184
185
186
187
# File 'lib/jsx_rosetta/backend/view_component.rb', line 178

def render_factory_array_literal(arr, translator, indent:)
  parts = arr.elements.map do |el|
    el.nil? ? "nil" : inline_render_value(el, translator, indent: indent + 2)
  end
  return "[]" if parts.empty?

  pad = " " * (indent + 2)
  close_pad = " " * indent
  "[\n#{pad}#{parts.join(",\n#{pad}")}\n#{close_pad}]"
end

#render_factory_object_literal(obj, translator, indent:) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/jsx_rosetta/backend/view_component.rb', line 165

def render_factory_object_literal(obj, translator, indent:)
  parts = obj.properties.map do |(key, value)|
    rendered = inline_render_value(value, translator, indent: indent + 2)
    snake = AST::Inflector.underscore(key)
    if snake.match?(/\A[a-z_][a-z0-9_]*\z/)
      "#{snake}: #{rendered}"
    else
      "#{key.inspect} => #{rendered}"
    end
  end
  "{ #{parts.join(", ")} }"
end

#render_stimulus_controller_js(component) ⇒ Object



202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/jsx_rosetta/backend/view_component.rb', line 202

def render_stimulus_controller_js(component)
  lines = [
    'import { Controller } from "@hotwired/stimulus";',
    "",
    "export default class extends Controller {"
  ]
  component.stimulus_methods.each_with_index do |method, idx|
    lines << "" if idx.positive?
    lines.concat(stimulus_method_lines(method))
  end
  lines << "}"
  "#{lines.join("\n")}\n"
end

#stimulus_identifier(component) ⇒ Object



198
199
200
# File 'lib/jsx_rosetta/backend/view_component.rb', line 198

def stimulus_identifier(component)
  AST::Inflector.underscore(component.name).tr("_", "-")
end

#stimulus_method_lines(method) ⇒ Object



216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/jsx_rosetta/backend/view_component.rb', line 216

def stimulus_method_lines(method)
  body_lines = method.body_source.strip.split("\n")
  commented = body_lines.map { |line| "  //   #{line}" }
  header = ["  // TODO: translate from the original JSX handler:"]
  if method.name != method.original_name
    header.unshift("  // NOTE: method renamed from #{method.original_name.inspect} " \
                   "to avoid collision with an earlier handler")
  end
  header + commented + [
    "  #{method.name}(event) {",
    "    // ...",
    "  }"
  ]
end

#stimulus_path(component, base_name) ⇒ Object



193
194
195
196
# File 'lib/jsx_rosetta/backend/view_component.rb', line 193

def stimulus_path(component, base_name)
  controller_filename = "#{AST::Inflector.underscore(component.name)}_controller.js"
  @layout == :sidecar ? "#{base_name}/#{controller_filename}" : controller_filename
end