Class: JsxRosetta::Backend::ViewComponent
- 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
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
- #class_name_for(component) ⇒ Object
- #data_factory_signature(method_name, param_names) ⇒ Object
-
#emit(component, source_filename: nil) ⇒ Object
rubocop:disable Lint/UnusedMethodArgument.
-
#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, source_filename: nil) ⇒ Object
rubocop:disable Lint/UnusedMethodArgument
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, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument 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 |