jsx_rosetta 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +149 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +236 -0
- data/README.md +328 -0
- data/Rakefile +12 -0
- data/exe/jsx_rosetta +6 -0
- data/lib/jsx_rosetta/ast/inflector.rb +23 -0
- data/lib/jsx_rosetta/ast/node.rb +151 -0
- data/lib/jsx_rosetta/ast/types.rb +224 -0
- data/lib/jsx_rosetta/ast/visitor.rb +47 -0
- data/lib/jsx_rosetta/ast.rb +15 -0
- data/lib/jsx_rosetta/backend/base.rb +21 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +41 -0
- data/lib/jsx_rosetta/backend/routes_script.rb +191 -0
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +120 -0
- data/lib/jsx_rosetta/backend/view_component.rb +638 -0
- data/lib/jsx_rosetta/backend.rb +12 -0
- data/lib/jsx_rosetta/cli.rb +182 -0
- data/lib/jsx_rosetta/ir/lowering.rb +727 -0
- data/lib/jsx_rosetta/ir/types.rb +276 -0
- data/lib/jsx_rosetta/ir.rb +16 -0
- data/lib/jsx_rosetta/node_bridge.rb +56 -0
- data/lib/jsx_rosetta/parse_error.rb +19 -0
- data/lib/jsx_rosetta/parser.rb +30 -0
- data/lib/jsx_rosetta/routes.rb +72 -0
- data/lib/jsx_rosetta/version.rb +5 -0
- data/lib/jsx_rosetta.rb +41 -0
- data/node/.gitignore +1 -0
- data/node/package-lock.json +64 -0
- data/node/package.json +16 -0
- data/node/parse.js +77 -0
- metadata +84 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast/inflector"
|
|
4
|
+
require_relative "../ir/types"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module JsxRosetta
|
|
8
|
+
module Backend
|
|
9
|
+
# Emits a Rails ViewComponent (one Ruby class + one ERB template)
|
|
10
|
+
# from an IR::Component.
|
|
11
|
+
#
|
|
12
|
+
# Phase 3 scope:
|
|
13
|
+
# - Single component per emit.
|
|
14
|
+
# - JSX prop names lowered to snake_case Ruby kwargs and matching
|
|
15
|
+
# `@instance_variable` assignments.
|
|
16
|
+
# - JS expressions translated via ExpressionTranslator where the
|
|
17
|
+
# shape is recognized; otherwise emitted as a TODO marker plus
|
|
18
|
+
# verbatim source.
|
|
19
|
+
# - HTML attributes emitted directly; className / template-literal
|
|
20
|
+
# class expressions inlined into the `class="..."` attribute.
|
|
21
|
+
#
|
|
22
|
+
# Phase 4a additions:
|
|
23
|
+
# - `children` prop is treated as ViewComponent's default content
|
|
24
|
+
# slot: it's filtered out of the initializer and rendered as
|
|
25
|
+
# `<%= content %>` wherever the IR has IR::Slot(name: "children").
|
|
26
|
+
# - IR::Conditional renders as `<% if %>...<% else %>...<% end %>`.
|
|
27
|
+
class ViewComponent < Base
|
|
28
|
+
DEFAULT_SLOT_NAME = "children"
|
|
29
|
+
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
30
|
+
|
|
31
|
+
# JSX component names that have a direct Rails view-helper analog.
|
|
32
|
+
# Override per-instance via `ViewComponent.new(helpers: {...})`, or
|
|
33
|
+
# disable by passing `helpers: false`.
|
|
34
|
+
DEFAULT_HELPERS = {
|
|
35
|
+
"Link" => { method: :link_to, positional: :href }.freeze,
|
|
36
|
+
"Image" => { method: :image_tag, positional: :src }.freeze
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def initialize(helpers: nil, layout: :sidecar)
|
|
40
|
+
super()
|
|
41
|
+
@helpers = case helpers
|
|
42
|
+
when nil then DEFAULT_HELPERS
|
|
43
|
+
when false then {}
|
|
44
|
+
else helpers
|
|
45
|
+
end
|
|
46
|
+
unless %i[sidecar flat].include?(layout)
|
|
47
|
+
raise ArgumentError, "unknown layout: #{layout.inspect} (expected :sidecar or :flat)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@layout = layout
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
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
|
+
|
|
58
|
+
base_name = "#{AST::Inflector.underscore(component.name)}_component"
|
|
59
|
+
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
60
|
+
|
|
61
|
+
files = [
|
|
62
|
+
File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
|
|
63
|
+
File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
|
|
64
|
+
]
|
|
65
|
+
if component.stimulus_methods.any?
|
|
66
|
+
files << File.new(
|
|
67
|
+
path: stimulus_path(component, base_name),
|
|
68
|
+
contents: render_stimulus_controller_js(component)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
files
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def erb_path(base_name)
|
|
75
|
+
@layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def stimulus_path(component, base_name)
|
|
79
|
+
controller_filename = "#{AST::Inflector.underscore(component.name)}_controller.js"
|
|
80
|
+
@layout == :sidecar ? "#{base_name}/#{controller_filename}" : controller_filename
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stimulus_identifier(component)
|
|
84
|
+
AST::Inflector.underscore(component.name).tr("_", "-")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_stimulus_controller_js(component)
|
|
88
|
+
lines = [
|
|
89
|
+
'import { Controller } from "@hotwired/stimulus";',
|
|
90
|
+
"",
|
|
91
|
+
"export default class extends Controller {"
|
|
92
|
+
]
|
|
93
|
+
component.stimulus_methods.each_with_index do |method, idx|
|
|
94
|
+
lines << "" if idx.positive?
|
|
95
|
+
lines.concat(stimulus_method_lines(method))
|
|
96
|
+
end
|
|
97
|
+
lines << "}"
|
|
98
|
+
"#{lines.join("\n")}\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stimulus_method_lines(method)
|
|
102
|
+
body_lines = method.body_source.strip.split("\n")
|
|
103
|
+
commented = body_lines.map { |line| " // #{line}" }
|
|
104
|
+
[" // TODO: translate from the original JSX handler:"] + commented + [
|
|
105
|
+
" #{method.name}(event) {",
|
|
106
|
+
" // ...",
|
|
107
|
+
" }"
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def initializable_props(component)
|
|
114
|
+
component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render_ruby_class(component, translator)
|
|
118
|
+
props = initializable_props(component)
|
|
119
|
+
rest_name = component.rest_prop_name
|
|
120
|
+
|
|
121
|
+
if props.empty? && rest_name.nil?
|
|
122
|
+
<<~RUBY
|
|
123
|
+
# frozen_string_literal: true
|
|
124
|
+
|
|
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
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def render_ruby_class_with_props(component, props, rest_name, translator)
|
|
134
|
+
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
135
|
+
kwargs << "**#{rest_name}" if rest_name
|
|
136
|
+
|
|
137
|
+
assignments = props.map do |prop|
|
|
138
|
+
snake = AST::Inflector.underscore(prop.name)
|
|
139
|
+
" @#{snake} = #{snake}"
|
|
140
|
+
end
|
|
141
|
+
assignments << " @#{rest_name} = #{rest_name}" if rest_name
|
|
142
|
+
|
|
143
|
+
<<~RUBY
|
|
144
|
+
# frozen_string_literal: true
|
|
145
|
+
|
|
146
|
+
class #{component.name}Component < ::ViewComponent::Base
|
|
147
|
+
def initialize(#{kwargs.join(", ")})
|
|
148
|
+
#{assignments.join("\n")}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
RUBY
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ruby_kwarg(prop, translator)
|
|
155
|
+
snake_name = AST::Inflector.underscore(prop.name)
|
|
156
|
+
default = ruby_default_for(prop, translator)
|
|
157
|
+
"#{snake_name}: #{default}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def ruby_default_for(prop, translator)
|
|
161
|
+
return "nil" if prop.default.nil?
|
|
162
|
+
|
|
163
|
+
translated = translator.translate(prop.default.expression)
|
|
164
|
+
translated ? translated.ruby : "nil # TODO: translate #{prop.default.expression.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_erb_template(component, translator)
|
|
168
|
+
root = component.body
|
|
169
|
+
root = decorate_with_stimulus_controller(root) if component.stimulus_methods.any? && root.is_a?(IR::Element)
|
|
170
|
+
body = render_ir_node(root, translator, indent: 0)
|
|
171
|
+
body = "#{body}\n" unless body.end_with?("\n")
|
|
172
|
+
|
|
173
|
+
prefix = String.new
|
|
174
|
+
prefix << render_react_hooks_todo(component.react_hooks)
|
|
175
|
+
prefix << render_local_bindings_todo(component.local_bindings)
|
|
176
|
+
"#{prefix}#{body}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def decorate_with_stimulus_controller(element)
|
|
180
|
+
attr = IR::Attribute.new(name: "data-controller", value: @stimulus_identifier)
|
|
181
|
+
IR::Element.new(tag: element.tag, attributes: [attr] + element.attributes, children: element.children)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render_local_bindings_todo(bindings)
|
|
185
|
+
return "" if bindings.empty?
|
|
186
|
+
|
|
187
|
+
unique_sources = bindings.map(&:source).uniq
|
|
188
|
+
lines = ["<%# TODO: translate JS to Ruby — original:"]
|
|
189
|
+
unique_sources.each { |src| lines << " #{src}" }
|
|
190
|
+
lines << "%>"
|
|
191
|
+
"#{lines.join("\n")}\n"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def render_react_hooks_todo(hooks)
|
|
195
|
+
return "" if hooks.empty?
|
|
196
|
+
|
|
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}" }
|
|
203
|
+
lines << "%>"
|
|
204
|
+
"#{lines.join("\n")}\n"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def render_ir_node(node, translator, indent:)
|
|
208
|
+
case node
|
|
209
|
+
when IR::Element then render_element(node, translator, indent: indent)
|
|
210
|
+
when IR::ComponentInvocation then render_component_invocation(node, translator, indent: indent)
|
|
211
|
+
when IR::Fragment then render_fragment(node, translator, indent: indent)
|
|
212
|
+
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
213
|
+
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
214
|
+
when IR::Slot then render_slot(node, indent: indent)
|
|
215
|
+
when IR::Text then "#{spaces(indent)}#{node.value}"
|
|
216
|
+
when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
|
|
217
|
+
when IR::Comment then "#{spaces(indent)}<%# #{node.text} %>"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def render_loop(loop_node, translator, indent:)
|
|
222
|
+
iterable_ruby = render_test_expression(loop_node.iterable, translator)
|
|
223
|
+
js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
|
|
224
|
+
ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
|
|
225
|
+
binding_str = "|#{ruby_bindings.join(", ")}|"
|
|
226
|
+
|
|
227
|
+
body = translator.with_locals(js_bindings) do
|
|
228
|
+
render_ir_node(loop_node.body, translator, indent: indent + 2)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
[
|
|
232
|
+
"#{spaces(indent)}<% #{iterable_ruby}.each do #{binding_str} %>",
|
|
233
|
+
body,
|
|
234
|
+
"#{spaces(indent)}<% end %>"
|
|
235
|
+
].join("\n")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def render_element(element, translator, indent:)
|
|
239
|
+
return render_element_with_tag_builder(element, translator, indent: indent) if needs_tag_builder?(element)
|
|
240
|
+
|
|
241
|
+
attrs = render_attributes(element.attributes, translator)
|
|
242
|
+
attrs_segment = attrs.empty? ? "" : " #{attrs}"
|
|
243
|
+
|
|
244
|
+
return "#{spaces(indent)}<#{element.tag}#{attrs_segment} />" if VOID_ELEMENTS.include?(element.tag)
|
|
245
|
+
|
|
246
|
+
opening = "<#{element.tag}#{attrs_segment}>"
|
|
247
|
+
closing = "</#{element.tag}>"
|
|
248
|
+
|
|
249
|
+
if element.children.empty?
|
|
250
|
+
"#{spaces(indent)}#{opening}#{closing}"
|
|
251
|
+
else
|
|
252
|
+
inner = element.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
|
|
253
|
+
"#{spaces(indent)}#{opening}\n#{inner}\n#{spaces(indent)}#{closing}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def needs_tag_builder?(element)
|
|
258
|
+
element.attributes.any?(IR::SpreadAttribute)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def render_element_with_tag_builder(element, translator, indent:)
|
|
262
|
+
builder_args = render_tag_builder_args(element.attributes, translator)
|
|
263
|
+
prefix = "<%= tag.#{element.tag}(#{builder_args})"
|
|
264
|
+
|
|
265
|
+
if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
|
|
266
|
+
"#{spaces(indent)}#{prefix} %>"
|
|
267
|
+
else
|
|
268
|
+
inner = element.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
|
|
269
|
+
"#{spaces(indent)}#{prefix} do %>\n#{inner}\n#{spaces(indent)}<% end %>"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def render_tag_builder_args(attributes, translator)
|
|
274
|
+
events, others = attributes.partition { |attr| attr.is_a?(IR::EventBinding) || attr.is_a?(IR::StimulusBinding) }
|
|
275
|
+
spreads, plain = others.partition { |attr| attr.is_a?(IR::SpreadAttribute) }
|
|
276
|
+
|
|
277
|
+
pieces = plain.filter_map { |attr| tag_builder_kwarg(attr, translator) }
|
|
278
|
+
pieces << tag_builder_data_action(events, translator) if events.any?
|
|
279
|
+
pieces.concat(spreads.map { |s| "**#{tag_builder_spread(s.expression, translator)}" })
|
|
280
|
+
pieces.join(", ")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def tag_builder_kwarg(attribute, translator)
|
|
284
|
+
case attribute
|
|
285
|
+
when IR::StyleBinding
|
|
286
|
+
translated = translator.translate(attribute.expression)
|
|
287
|
+
ruby = translated ? translated.ruby : attribute.expression.inspect
|
|
288
|
+
"class: #{ruby}"
|
|
289
|
+
when IR::ClassList
|
|
290
|
+
"class: #{class_list_to_ruby_string(attribute, translator)}"
|
|
291
|
+
when IR::Style
|
|
292
|
+
"style: #{style_to_ruby_string(attribute, translator)}"
|
|
293
|
+
when IR::Attribute
|
|
294
|
+
tag_builder_plain_kwarg(attribute, translator)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def tag_builder_plain_kwarg(attribute, translator)
|
|
299
|
+
key = attribute.name.match?(/\A[a-z_][a-z0-9_]*\z/i) ? "#{attribute.name}:" : "#{attribute.name.inspect} =>"
|
|
300
|
+
"#{key} #{tag_builder_value(attribute.value, translator)}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def tag_builder_value(value, translator)
|
|
304
|
+
case value
|
|
305
|
+
when true then "true"
|
|
306
|
+
when String then value.inspect
|
|
307
|
+
when IR::Interpolation
|
|
308
|
+
translated = translator.translate(value.expression)
|
|
309
|
+
translated ? translated.ruby : value.expression.inspect
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def tag_builder_data_action(events, translator)
|
|
314
|
+
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(" ")}")
|
|
320
|
+
else
|
|
321
|
+
%("#{descriptors.map { |d| literal_to_interpolated(d) }.join(" ")}")
|
|
322
|
+
end
|
|
323
|
+
%("data-action" => #{joined})
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def tag_builder_event_descriptor(event, translator)
|
|
327
|
+
case event
|
|
328
|
+
when IR::EventBinding
|
|
329
|
+
translated = translator.translate(event.handler.expression)
|
|
330
|
+
translated ? translated.ruby : event.handler.expression.inspect
|
|
331
|
+
when IR::StimulusBinding
|
|
332
|
+
%("#{event.event}->#{@stimulus_identifier}##{event.method_name}")
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def literal_to_interpolated(descriptor)
|
|
337
|
+
if descriptor.start_with?('"') && descriptor.end_with?('"')
|
|
338
|
+
descriptor[1..-2]
|
|
339
|
+
else
|
|
340
|
+
"\#{#{descriptor}}"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def tag_builder_spread(expression, translator)
|
|
345
|
+
translated = translator.translate(expression)
|
|
346
|
+
translated ? translated.ruby : expression
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def render_component_invocation(invocation, translator, indent:)
|
|
350
|
+
helper = @helpers[invocation.name]
|
|
351
|
+
return render_helper_call(invocation, translator, helper, indent: indent) if helper
|
|
352
|
+
|
|
353
|
+
kwargs = component_invocation_kwargs(invocation.props, translator)
|
|
354
|
+
class_name = component_class_name(invocation.name)
|
|
355
|
+
new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
|
|
356
|
+
|
|
357
|
+
if invocation.children.empty?
|
|
358
|
+
"#{spaces(indent)}<%= render #{new_call} %>"
|
|
359
|
+
else
|
|
360
|
+
inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
|
|
361
|
+
"#{spaces(indent)}<%= render #{new_call} do %>\n#{inner}\n#{spaces(indent)}<% end %>"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# JSX `<Foo.Bar>` → Ruby `Foo::BarComponent`. Plain `<Card>` stays as
|
|
366
|
+
# `CardComponent`. Each member-expression segment joins with `::`,
|
|
367
|
+
# and `Component` suffixes the leaf so the result is a constant path
|
|
368
|
+
# the host app can autoload.
|
|
369
|
+
def component_class_name(jsx_tag)
|
|
370
|
+
return "#{jsx_tag}Component" unless jsx_tag.include?(".")
|
|
371
|
+
|
|
372
|
+
"#{jsx_tag.split(".").join("::")}Component"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def render_helper_call(invocation, translator, helper, indent:)
|
|
376
|
+
call = build_helper_call(invocation, translator, helper)
|
|
377
|
+
if invocation.children.empty?
|
|
378
|
+
"#{spaces(indent)}<%= #{call} %>"
|
|
379
|
+
else
|
|
380
|
+
inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
|
|
381
|
+
"#{spaces(indent)}<%= #{call} do %>\n#{inner}\n#{spaces(indent)}<% end %>"
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def build_helper_call(invocation, translator, helper)
|
|
386
|
+
positional_attr = find_positional_attr(invocation.props, helper[:positional])
|
|
387
|
+
remaining = positional_attr ? invocation.props.reject { |p| p.equal?(positional_attr) } : invocation.props
|
|
388
|
+
|
|
389
|
+
parts = []
|
|
390
|
+
parts << component_kwarg_value(positional_attr.value, translator) if positional_attr
|
|
391
|
+
kwargs = component_invocation_kwargs(remaining, translator)
|
|
392
|
+
parts << kwargs unless kwargs.empty?
|
|
393
|
+
|
|
394
|
+
parts.empty? ? helper[:method].to_s : "#{helper[:method]}(#{parts.join(", ")})"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def find_positional_attr(props, positional)
|
|
398
|
+
return nil unless positional
|
|
399
|
+
|
|
400
|
+
name = positional.to_s
|
|
401
|
+
props.find { |p| p.is_a?(IR::Attribute) && p.name == name }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def component_invocation_kwargs(props, translator)
|
|
405
|
+
spreads, others = props.partition { |attr| attr.is_a?(IR::SpreadAttribute) }
|
|
406
|
+
parts = others.filter_map { |attr| component_kwarg(attr, translator) }
|
|
407
|
+
parts.concat(spreads.map { |s| "**#{tag_builder_spread(s.expression, translator)}" })
|
|
408
|
+
parts.join(", ")
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def render_fragment(fragment, translator, indent:)
|
|
412
|
+
fragment.children.map { |child| render_ir_node(child, translator, indent: indent) }.join("\n")
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def render_conditional(conditional, translator, indent:)
|
|
416
|
+
test_ruby = render_test_expression(conditional.test, translator)
|
|
417
|
+
lines = ["#{spaces(indent)}<% if #{test_ruby} %>"]
|
|
418
|
+
lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
|
|
419
|
+
if conditional.alternate
|
|
420
|
+
lines << "#{spaces(indent)}<% else %>"
|
|
421
|
+
lines << render_ir_node(conditional.alternate, translator, indent: indent + 2)
|
|
422
|
+
end
|
|
423
|
+
lines << "#{spaces(indent)}<% end %>"
|
|
424
|
+
lines.join("\n")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def render_test_expression(test, translator)
|
|
428
|
+
translated = translator.translate(test.expression)
|
|
429
|
+
translated ? translated.ruby : test.expression
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def render_slot(slot, indent:)
|
|
433
|
+
if slot.name == DEFAULT_SLOT_NAME
|
|
434
|
+
"#{spaces(indent)}<%= content %>"
|
|
435
|
+
else
|
|
436
|
+
# Named slots become Phase 4d work; for now flag them.
|
|
437
|
+
"#{spaces(indent)}<%# TODO: named slot #{slot.name.inspect} %>"
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def render_attributes(attributes, translator)
|
|
442
|
+
events, others = attributes.partition { |attr| attr.is_a?(IR::EventBinding) || attr.is_a?(IR::StimulusBinding) }
|
|
443
|
+
rendered = others.filter_map { |attr| render_attribute(attr, translator) }
|
|
444
|
+
rendered << render_data_action(events, translator) if events.any?
|
|
445
|
+
rendered.join(" ")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def render_attribute(attribute, translator)
|
|
449
|
+
case attribute
|
|
450
|
+
when IR::StyleBinding then render_style_binding(attribute, translator)
|
|
451
|
+
when IR::ClassList then render_class_list_attribute(attribute, translator)
|
|
452
|
+
when IR::Style then render_style(attribute, translator)
|
|
453
|
+
when IR::Attribute then render_plain_attribute(attribute, translator)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def render_style(style, translator)
|
|
458
|
+
rendered = style.declarations.map { |decl| render_style_declaration(decl, translator) }.join(" ")
|
|
459
|
+
%(style="#{rendered}")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def render_style_declaration(decl, translator)
|
|
463
|
+
value = case decl.value
|
|
464
|
+
when String then decl.value
|
|
465
|
+
when IR::Interpolation
|
|
466
|
+
translated = translator.translate(decl.value.expression)
|
|
467
|
+
"<%= #{translated&.ruby || decl.value.expression} %>"
|
|
468
|
+
end
|
|
469
|
+
"#{decl.property}: #{value};"
|
|
470
|
+
end
|
|
471
|
+
|
|
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)
|
|
478
|
+
case segment
|
|
479
|
+
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} : '' %>"
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
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(" ")}")
|
|
493
|
+
end
|
|
494
|
+
|
|
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
|
|
504
|
+
%(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def render_data_action(events, translator)
|
|
509
|
+
parts = events.map { |event| render_event_handler(event, translator) }
|
|
510
|
+
%(data-action="#{parts.join(" ")}")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def render_event_handler(event, translator)
|
|
514
|
+
case event
|
|
515
|
+
when IR::EventBinding
|
|
516
|
+
translated = translator.translate(event.handler.expression)
|
|
517
|
+
ruby = translated ? translated.ruby : event.handler.expression
|
|
518
|
+
"<%= #{ruby} %>"
|
|
519
|
+
when IR::StimulusBinding
|
|
520
|
+
"#{event.event}->#{@stimulus_identifier}##{event.method_name}"
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def render_plain_attribute(attribute, translator)
|
|
525
|
+
case attribute.value
|
|
526
|
+
when true then attribute.name
|
|
527
|
+
when String then %(#{attribute.name}="#{attribute.value}")
|
|
528
|
+
when IR::Interpolation
|
|
529
|
+
%(#{attribute.name}="#{plain_attribute_value_erb(attribute.value, translator)}")
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Try to inline the attribute value rather than wrapping it in `<%= %>`.
|
|
534
|
+
# If the translator produces a Ruby double-quoted string with `#{…}`
|
|
535
|
+
# interpolations (typical for template-literal hrefs etc.), emit the
|
|
536
|
+
# literal portions literally and the interpolations as ERB tags.
|
|
537
|
+
def plain_attribute_value_erb(interpolation, translator)
|
|
538
|
+
translated = translator.translate(interpolation.expression)
|
|
539
|
+
if double_quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
|
|
540
|
+
inlined_ruby_string(translated.ruby)
|
|
541
|
+
else
|
|
542
|
+
interpolation_to_erb(interpolation, translator)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def render_style_binding(binding, translator)
|
|
547
|
+
translated = translator.translate(binding.expression)
|
|
548
|
+
if double_quoted_ruby_string?(translated&.ruby)
|
|
549
|
+
%(class="#{inlined_ruby_string(translated.ruby)}")
|
|
550
|
+
elsif translated
|
|
551
|
+
%(class="<%= #{translated.ruby} %>")
|
|
552
|
+
else
|
|
553
|
+
%(class="<%# TODO: translate #{binding.expression.inspect} %><%= #{binding.expression} %>")
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def double_quoted_ruby_string?(ruby)
|
|
558
|
+
ruby.is_a?(String) && ruby.start_with?('"') && ruby.end_with?('"')
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Given a Ruby double-quoted string with #{...} interpolations, emit it
|
|
562
|
+
# in ERB template form: the literal portions stay literal, and each
|
|
563
|
+
# interpolation becomes an ERB tag.
|
|
564
|
+
#
|
|
565
|
+
# `"btn btn-#{@variant}"` → `btn btn-<%= @variant %>`
|
|
566
|
+
def inlined_ruby_string(ruby_string)
|
|
567
|
+
inner = ruby_string[1..-2]
|
|
568
|
+
inner.gsub(/\#\{([^}]+)\}/) { "<%= #{::Regexp.last_match(1)} %>" }
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def interpolation_to_erb(interpolation, translator)
|
|
572
|
+
translated = translator.translate(interpolation.expression)
|
|
573
|
+
unless translated
|
|
574
|
+
return "<%# TODO: translate #{interpolation.expression.inspect} %>" \
|
|
575
|
+
"<%= #{interpolation.expression} %>"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
unresolved = translated.unresolved_identifiers
|
|
579
|
+
return "<%= #{translated.ruby} %>" if unresolved.empty?
|
|
580
|
+
|
|
581
|
+
names = unresolved.map(&:inspect).join(", ")
|
|
582
|
+
"<%# TODO: unresolved identifier #{names} %><%= #{translated.ruby} %>"
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def component_kwarg(attribute, translator)
|
|
586
|
+
case attribute
|
|
587
|
+
when IR::Attribute
|
|
588
|
+
component_attribute_kwarg(attribute, translator)
|
|
589
|
+
when IR::StyleBinding
|
|
590
|
+
translated = translator.translate(attribute.expression)
|
|
591
|
+
ruby = translated ? translated.ruby : attribute.expression.inspect
|
|
592
|
+
"class: #{ruby}"
|
|
593
|
+
when IR::ClassList
|
|
594
|
+
"class: #{class_list_to_ruby_string(attribute, translator)}"
|
|
595
|
+
when IR::Style
|
|
596
|
+
"style: #{style_to_ruby_string(attribute, translator)}"
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
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
|
|
610
|
+
%("#{parts.join(" ")}")
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def component_attribute_kwarg(attribute, translator)
|
|
614
|
+
value = component_kwarg_value(attribute.value, translator)
|
|
615
|
+
if attribute.name.match?(/\A[a-z_][a-z0-9_]*\z/i)
|
|
616
|
+
name = AST::Inflector.underscore(attribute.name)
|
|
617
|
+
"#{name}: #{value}"
|
|
618
|
+
else
|
|
619
|
+
"#{attribute.name.inspect} => #{value}"
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def component_kwarg_value(value, translator)
|
|
624
|
+
case value
|
|
625
|
+
when true then "true"
|
|
626
|
+
when String then value.inspect
|
|
627
|
+
when IR::Interpolation
|
|
628
|
+
translated = translator.translate(value.expression)
|
|
629
|
+
translated ? translated.ruby : "nil # TODO: translate #{value.expression.inspect}"
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def spaces(count)
|
|
634
|
+
" " * count
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "backend/base"
|
|
4
|
+
require_relative "backend/view_component"
|
|
5
|
+
require_relative "backend/view_component/expression_translator"
|
|
6
|
+
require_relative "backend/rails_view"
|
|
7
|
+
require_relative "backend/routes_script"
|
|
8
|
+
|
|
9
|
+
module JsxRosetta
|
|
10
|
+
module Backend
|
|
11
|
+
end
|
|
12
|
+
end
|