Class: JsxRosetta::Backend::ViewComponent
- Inherits:
-
Base
- Object
- Base
- JsxRosetta::Backend::ViewComponent
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 %>`.
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
{
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
-
#class_name_for(component) ⇒ Object
-
#data_factory_signature(method_name, param_names) ⇒ Object
-
#emit(component) ⇒ Object
-
#emit_data_factory(component) ⇒ Object
For data-factory components emit a plain Ruby class (no ApplicationViewComponent base, no ERB template).
-
#erb_path(base_name) ⇒ Object
-
#initialize(helpers: nil, layout: :sidecar) ⇒ ViewComponent
constructor
A new instance of ViewComponent.
-
#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.
-
#render_factory_array_literal(arr, translator, indent:) ⇒ Object
-
#render_factory_object_literal(obj, translator, indent:) ⇒ Object
-
#render_stimulus_controller_js(component) ⇒ Object
-
#stimulus_identifier(component) ⇒ Object
-
#stimulus_method_lines(method) ⇒ Object
-
#stimulus_path(component, base_name) ⇒ Object
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
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")
= body_lines.map { |line| " // #{line}" }
= [" // TODO: translate from the original JSX handler:"]
if method.name != method.original_name
.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
"to avoid collision with an earlier handler")
end
+ + [
" #{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
|