jsx_rosetta 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +205 -0
- data/README.md +69 -7
- data/lib/jsx_rosetta/ast/inflector.rb +1 -0
- data/lib/jsx_rosetta/ast/node.rb +28 -1
- data/lib/jsx_rosetta/backend/phlex.rb +756 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +3 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +54 -12
- data/lib/jsx_rosetta/backend/view_component.rb +169 -69
- data/lib/jsx_rosetta/backend.rb +1 -0
- data/lib/jsx_rosetta/cli.rb +33 -5
- data/lib/jsx_rosetta/ir/lowering.rb +631 -104
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +148 -0
- data/lib/jsx_rosetta/ir/types.rb +77 -6
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +7 -6
- metadata +3 -1
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast/inflector"
|
|
4
|
+
require_relative "../ir/types"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "view_component/expression_translator"
|
|
7
|
+
|
|
8
|
+
module JsxRosetta
|
|
9
|
+
module Backend
|
|
10
|
+
# Emits a Phlex 2.x view class (one Ruby file per component) from
|
|
11
|
+
# an IR::Component. Single-file output by design — the JSX `<h1>...`
|
|
12
|
+
# template lives as Ruby inside `view_template`, not in a sibling
|
|
13
|
+
# .erb. When the source uses `onClick`/`onChange` etc., a sibling
|
|
14
|
+
# Stimulus controller `_controller.js` is emitted alongside (same
|
|
15
|
+
# convention as the ViewComponent backend).
|
|
16
|
+
#
|
|
17
|
+
# Naming strategies (mutually exclusive):
|
|
18
|
+
# default class FlashyHeader < Phlex::HTML
|
|
19
|
+
# suffix: "Component" class FlashyHeaderComponent < Phlex::HTML
|
|
20
|
+
# namespace: "Components" module Components
|
|
21
|
+
# class FlashyHeader < Phlex::HTML
|
|
22
|
+
#
|
|
23
|
+
# Hyphenated attributes (`data-testid`, `aria-label`, etc.) emit as
|
|
24
|
+
# string-keyed hash entries inside a splat — `**{ "data-testid" => @x }`
|
|
25
|
+
# — since Ruby kwargs can't carry hyphens. Snake_case-friendly attrs
|
|
26
|
+
# emit as regular keyword arguments.
|
|
27
|
+
class Phlex < Base
|
|
28
|
+
DEFAULT_SLOT_NAME = "children"
|
|
29
|
+
DEFAULT_SUFFIX = "Component"
|
|
30
|
+
PHLEX_BASE_CLASS = "Phlex::HTML"
|
|
31
|
+
VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
|
|
32
|
+
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
33
|
+
|
|
34
|
+
# Structured intermediate for the data-action attribute — mirrors the
|
|
35
|
+
# ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
|
|
36
|
+
EventDescriptor = Data.define(:kind, :body)
|
|
37
|
+
|
|
38
|
+
def initialize(suffix: nil, namespace: nil)
|
|
39
|
+
super()
|
|
40
|
+
raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
|
|
41
|
+
|
|
42
|
+
@suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
|
|
43
|
+
@namespace = namespace
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def emit(component)
|
|
47
|
+
prop_names = component.props.map(&:name)
|
|
48
|
+
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
49
|
+
translator = ViewComponent::ExpressionTranslator.new(
|
|
50
|
+
prop_names: prop_names, local_binding_names: component.local_binding_names
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
54
|
+
@lambda_methods = []
|
|
55
|
+
@lambda_method_counts = {}
|
|
56
|
+
|
|
57
|
+
files = [File.new(path: ruby_path(component), contents: render_ruby_class(component, translator))]
|
|
58
|
+
if component.stimulus_methods.any?
|
|
59
|
+
files << File.new(
|
|
60
|
+
path: stimulus_path(component),
|
|
61
|
+
contents: render_stimulus_controller_js(component)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
files
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# JSX-returning lowercase helpers (e.g. `textRender`, `getNodeIcon`)
|
|
70
|
+
# have lowercase-starting names. Ruby class names must be constants
|
|
71
|
+
# (begin with an uppercase letter), so we capitalize the first
|
|
72
|
+
# letter when forming the class name. Pure-PascalCase names pass
|
|
73
|
+
# through unchanged.
|
|
74
|
+
def class_name(component)
|
|
75
|
+
base = "#{component.name[0].upcase}#{component.name[1..]}"
|
|
76
|
+
@suffix ? "#{base}#{@suffix}" : base
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ruby_path(component)
|
|
80
|
+
"#{AST::Inflector.underscore(class_name(component))}.rb"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stimulus_path(component)
|
|
84
|
+
"#{AST::Inflector.underscore(class_name(component))}_controller.js"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stimulus_identifier(component)
|
|
88
|
+
AST::Inflector.underscore(component.name).tr("_", "-")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_ruby_class(component, translator)
|
|
92
|
+
class_body = render_class_body(component, translator)
|
|
93
|
+
prefix = render_module_bindings_prefix(component)
|
|
94
|
+
wrap_in_namespace("#{prefix}#{class_body}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Top-level `const`/`let` declarations outside the component
|
|
98
|
+
# function — captured at lowering time and surfaced here as a TODO
|
|
99
|
+
# comment block above the class definition. We don't try to
|
|
100
|
+
# translate the JS; the human reviewer either copies the value as
|
|
101
|
+
# a Ruby constant or moves it to a Rails initializer.
|
|
102
|
+
def render_module_bindings_prefix(component)
|
|
103
|
+
return "" if component.module_bindings.empty?
|
|
104
|
+
|
|
105
|
+
lines = ["# TODO: module-level constants — translate to Ruby constants " \
|
|
106
|
+
"or move to a Rails initializer:"]
|
|
107
|
+
component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
|
|
108
|
+
"#{lines.join("\n")}\n"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def wrap_in_namespace(body)
|
|
112
|
+
return "# frozen_string_literal: true\n\n#{body}" unless @namespace
|
|
113
|
+
|
|
114
|
+
indented = body.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
|
|
115
|
+
"# frozen_string_literal: true\n\nmodule #{@namespace}\n#{indented}end\n"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def render_class_body(component, translator)
|
|
119
|
+
initializer = render_initializer(component, translator)
|
|
120
|
+
template = render_view_template(component, translator)
|
|
121
|
+
# Render lambdas only AFTER the template — `@lambda_methods` is
|
|
122
|
+
# populated as attribute values are rendered.
|
|
123
|
+
lambda_methods = render_lambda_method_definitions(translator)
|
|
124
|
+
|
|
125
|
+
sections = [initializer, template, lambda_methods].compact.join("\n\n")
|
|
126
|
+
"class #{class_name(component)} < #{PHLEX_BASE_CLASS}\n#{sections}\nend\n"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render_lambda_method_definitions(translator)
|
|
130
|
+
return nil if @lambda_methods.nil? || @lambda_methods.empty?
|
|
131
|
+
|
|
132
|
+
defs = @lambda_methods.map do |entry|
|
|
133
|
+
render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
|
|
134
|
+
end
|
|
135
|
+
" private\n\n#{defs.join("\n\n")}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render_lambda_method_definition(method_name, lambda, translator)
|
|
139
|
+
snake_params = lambda.params.map { |p| AST::Inflector.underscore(p) }
|
|
140
|
+
signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
|
|
141
|
+
body = translator.with_locals(lambda.params) do
|
|
142
|
+
render_ir_node(lambda.body, translator, indent: 4)
|
|
143
|
+
end
|
|
144
|
+
" def #{signature}\n#{body}\n end"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_initializer(component, translator)
|
|
148
|
+
props = initializable_props(component)
|
|
149
|
+
rest_name = component.rest_prop_name
|
|
150
|
+
return nil if props.empty? && rest_name.nil?
|
|
151
|
+
|
|
152
|
+
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
153
|
+
kwargs << "**#{rest_name}" if rest_name
|
|
154
|
+
|
|
155
|
+
assignments = props.map do |prop|
|
|
156
|
+
snake = AST::Inflector.underscore(prop.name)
|
|
157
|
+
" @#{snake} = #{snake}"
|
|
158
|
+
end
|
|
159
|
+
assignments << " @#{rest_name} = #{rest_name}" if rest_name
|
|
160
|
+
|
|
161
|
+
" def initialize(#{kwargs.join(", ")})\n#{assignments.join("\n")}\n end"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def initializable_props(component)
|
|
165
|
+
component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ruby_kwarg(prop, translator)
|
|
169
|
+
snake_name = AST::Inflector.underscore(prop.name)
|
|
170
|
+
default = ruby_default_for(prop, translator)
|
|
171
|
+
"#{snake_name}: #{default}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ruby_default_for(prop, translator)
|
|
175
|
+
return "nil" if prop.default.nil?
|
|
176
|
+
|
|
177
|
+
case prop.default
|
|
178
|
+
when IR::Interpolation
|
|
179
|
+
translated = translator.translate(prop.default.expression)
|
|
180
|
+
translated ? translated.ruby : "nil"
|
|
181
|
+
when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
|
|
182
|
+
# Inline values: route through the recursive renderer. Pass an
|
|
183
|
+
# empty todos array — TODO markers wouldn't be safe inside a
|
|
184
|
+
# parameter-list anyway.
|
|
185
|
+
render_inline_value(prop.default, translator, todos: [], attr_name: prop.name)
|
|
186
|
+
else
|
|
187
|
+
"nil"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def render_view_template(component, translator)
|
|
192
|
+
body = render_template_body(component, translator)
|
|
193
|
+
prefix = render_template_prefix(component)
|
|
194
|
+
body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
|
|
195
|
+
" def view_template\n#{body_with_prefix}\n end"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def render_template_prefix(component)
|
|
199
|
+
lines = []
|
|
200
|
+
lines.concat(render_react_hooks_todo(component.react_hooks))
|
|
201
|
+
lines.concat(render_local_bindings_todo(component.local_bindings))
|
|
202
|
+
return "" if lines.empty?
|
|
203
|
+
|
|
204
|
+
"#{lines.map { |l| " #{l}" }.join("\n")}\n"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def render_react_hooks_todo(hooks)
|
|
208
|
+
return [] if hooks.empty?
|
|
209
|
+
|
|
210
|
+
lines = [
|
|
211
|
+
"# TODO: React hooks detected. None translate automatically.",
|
|
212
|
+
"# Hotwire/Stimulus handles behavior; controllers/views handle state;",
|
|
213
|
+
"# turbo-frames handle async loading. Original source:"
|
|
214
|
+
]
|
|
215
|
+
hooks.each { |hook| lines.concat(comment_lines(hook.source)) }
|
|
216
|
+
lines
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def render_local_bindings_todo(bindings)
|
|
220
|
+
return [] if bindings.empty?
|
|
221
|
+
|
|
222
|
+
unique_sources = bindings.map(&:source).uniq
|
|
223
|
+
["# TODO: translate JS to Ruby — original:"] + unique_sources.flat_map { |src| comment_lines(src) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Prefix every line of `source` with `# ` so multi-line JS bodies
|
|
227
|
+
# remain inside a Ruby comment block (single `#` on the first line
|
|
228
|
+
# would leave subsequent lines as bare Ruby and break parsing).
|
|
229
|
+
def comment_lines(source)
|
|
230
|
+
source.split("\n").map { |line| "# #{line}" }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def render_template_body(component, translator)
|
|
234
|
+
root = component.body
|
|
235
|
+
root = decorate_with_stimulus_controller(root) if component.stimulus_methods.any? && root.is_a?(IR::Element)
|
|
236
|
+
render_ir_node(root, translator, indent: 4)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def decorate_with_stimulus_controller(element)
|
|
240
|
+
attr = IR::Attribute.new(name: "data-controller", value: @stimulus_identifier)
|
|
241
|
+
IR::Element.new(tag: element.tag, attributes: [attr] + element.attributes, children: element.children)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def render_ir_node(node, translator, indent:)
|
|
245
|
+
case node
|
|
246
|
+
when IR::Element then render_element(node, translator, indent: indent)
|
|
247
|
+
when IR::ComponentInvocation then render_component_invocation(node, translator, indent: indent)
|
|
248
|
+
when IR::Fragment then render_fragment(node, translator, indent: indent)
|
|
249
|
+
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
250
|
+
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
251
|
+
when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
|
|
252
|
+
when IR::Slot then render_slot(node, indent: indent)
|
|
253
|
+
when IR::Text then render_text(node, indent: indent)
|
|
254
|
+
when IR::Interpolation then render_interpolation(node, translator, indent: indent)
|
|
255
|
+
when IR::Comment then render_comment(node, indent: indent)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# An orphan RenderProp (i.e. one that didn't get consumed as a block
|
|
260
|
+
# by a parent ComponentInvocation). Emit the body inline within the
|
|
261
|
+
# appropriate translator scope; the param names are pushed but no
|
|
262
|
+
# block syntax is generated.
|
|
263
|
+
def render_orphan_render_prop(render_prop, translator, indent:)
|
|
264
|
+
translator.with_locals(render_prop.params) do
|
|
265
|
+
render_ir_node(render_prop.body, translator, indent: indent)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def render_element(element, translator, indent:)
|
|
270
|
+
todos = []
|
|
271
|
+
attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos)
|
|
272
|
+
method_call = "#{element.tag}#{attrs_source}"
|
|
273
|
+
|
|
274
|
+
body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
|
|
275
|
+
"#{spaces(indent)}#{method_call}"
|
|
276
|
+
else
|
|
277
|
+
inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
278
|
+
"#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
prepend_attribute_todos(todos, indent, body)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def render_component_invocation(invocation, translator, indent:)
|
|
285
|
+
todos = []
|
|
286
|
+
kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos)
|
|
287
|
+
class_ref = component_class_reference(invocation.name)
|
|
288
|
+
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
289
|
+
|
|
290
|
+
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
|
|
291
|
+
body = if render_prop
|
|
292
|
+
render_with_render_prop(new_call, render_prop, translator, indent)
|
|
293
|
+
elsif invocation.children.empty?
|
|
294
|
+
"#{spaces(indent)}render #{new_call}"
|
|
295
|
+
else
|
|
296
|
+
inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
297
|
+
"#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
prepend_attribute_todos(todos, indent, body)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Emit a render-prop child as a Ruby block on the parent `render` call.
|
|
304
|
+
# `<Form.List>{(fields) => <p/>}</Form.List>` →
|
|
305
|
+
# `render Form::List.new do |fields|\n p\nend`. Param names are
|
|
306
|
+
# snake_cased and pushed into the translator scope so identifier
|
|
307
|
+
# references inside the body resolve to the block locals.
|
|
308
|
+
def render_with_render_prop(new_call, render_prop, translator, indent)
|
|
309
|
+
snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
|
|
310
|
+
param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
|
|
311
|
+
inner = translator.with_locals(render_prop.params) do
|
|
312
|
+
render_ir_node(render_prop.body, translator, indent: indent + 2)
|
|
313
|
+
end
|
|
314
|
+
"#{spaces(indent)}render #{new_call} do#{param_str}\n#{inner}\n#{spaces(indent)}end"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def prepend_attribute_todos(todos, indent, body)
|
|
318
|
+
return body if todos.empty?
|
|
319
|
+
|
|
320
|
+
prefix = todos.map { |t| "#{spaces(indent)}# TODO: #{t}" }.join("\n")
|
|
321
|
+
"#{prefix}\n#{body}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
|
|
325
|
+
# `Foo` again under namespace (Ruby's constant lookup finds the
|
|
326
|
+
# peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
|
|
327
|
+
def component_class_reference(jsx_tag)
|
|
328
|
+
segments = jsx_tag.split(".")
|
|
329
|
+
segments[-1] = "#{segments.last}#{@suffix}" if @suffix
|
|
330
|
+
segments.join("::")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def render_fragment(fragment, translator, indent:)
|
|
334
|
+
fragment.children.map { |child| render_ir_node(child, translator, indent: indent) }.join("\n")
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def render_conditional(conditional, translator, indent:)
|
|
338
|
+
test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
|
|
339
|
+
lines = []
|
|
340
|
+
lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
|
|
341
|
+
lines << "#{spaces(indent)}if #{test_ruby}"
|
|
342
|
+
lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
|
|
343
|
+
if conditional.alternate
|
|
344
|
+
lines << "#{spaces(indent)}else"
|
|
345
|
+
lines << render_ir_node(conditional.alternate, translator, indent: indent + 2)
|
|
346
|
+
end
|
|
347
|
+
lines << "#{spaces(indent)}end"
|
|
348
|
+
lines.join("\n")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def render_loop(loop_node, translator, indent:)
|
|
352
|
+
iterable_ruby, todo = render_loop_iterable(loop_node.iterable, translator)
|
|
353
|
+
js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
|
|
354
|
+
ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
|
|
355
|
+
binding_str = ruby_bindings.size == 1 ? "|#{ruby_bindings.first}|" : "|#{ruby_bindings.join(", ")}|"
|
|
356
|
+
|
|
357
|
+
body = translator.with_locals(js_bindings) do
|
|
358
|
+
render_ir_node(loop_node.body, translator, indent: indent + 2)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
lines = []
|
|
362
|
+
lines << "#{spaces(indent)}# TODO: translate iterable: #{todo}" if todo
|
|
363
|
+
lines << "#{spaces(indent)}#{iterable_ruby}.each do #{binding_str}"
|
|
364
|
+
lines << body
|
|
365
|
+
lines << "#{spaces(indent)}end"
|
|
366
|
+
lines.join("\n")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Translate the iterable side of a `.each` call. Handles both the
|
|
370
|
+
# traditional Interpolation form and the new ArrayLiteral form
|
|
371
|
+
# (literal array `.map(...)`) introduced by Gap H. Returns
|
|
372
|
+
# [ruby_source, todo_text]; todo_text is nil when translation succeeded.
|
|
373
|
+
def render_loop_iterable(iterable, translator)
|
|
374
|
+
case iterable
|
|
375
|
+
when IR::ArrayLiteral
|
|
376
|
+
[render_array_literal_value(iterable, translator, todos: []), nil]
|
|
377
|
+
when IR::Interpolation
|
|
378
|
+
safe_test_expression(iterable.expression, translator, fallback: "[]")
|
|
379
|
+
else
|
|
380
|
+
["[]", iterable.inspect]
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Translate an expression intended to drive an `if` or `.each` call.
|
|
385
|
+
# Returns `[ruby_source, todo_text]`. When the translator can parse
|
|
386
|
+
# the expression, `todo_text` is nil. When it can't, the caller's
|
|
387
|
+
# `fallback` (e.g. `"false"` for conditions, `"[]"` for iterables)
|
|
388
|
+
# is returned along with the original expression so a TODO comment
|
|
389
|
+
# can be emitted above the call. Without this, JS operators like
|
|
390
|
+
# `!==`, `===`, optional chaining, and `in` would leak into the
|
|
391
|
+
# emitted Ruby and produce SyntaxError on load.
|
|
392
|
+
def safe_test_expression(expression, translator, fallback:)
|
|
393
|
+
translated = translator.translate(expression)
|
|
394
|
+
return [translated.ruby, nil] if translated
|
|
395
|
+
|
|
396
|
+
compact = expression.tr("\n", " ").squeeze(" ")
|
|
397
|
+
[fallback, compact]
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def render_slot(slot, indent:)
|
|
401
|
+
if slot.name == DEFAULT_SLOT_NAME
|
|
402
|
+
"#{spaces(indent)}yield"
|
|
403
|
+
else
|
|
404
|
+
"#{spaces(indent)}# TODO: named slot #{slot.name.inspect}"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def render_text(text, indent:)
|
|
409
|
+
"#{spaces(indent)}plain #{text.value.inspect}"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def render_interpolation(interpolation, translator, indent:)
|
|
413
|
+
translated = translator.translate(interpolation.expression)
|
|
414
|
+
return render_untranslated_interpolation(interpolation.expression, indent) unless translated
|
|
415
|
+
|
|
416
|
+
unresolved = translated.unresolved_identifiers
|
|
417
|
+
if unresolved.empty?
|
|
418
|
+
"#{spaces(indent)}plain #{translated.ruby}#{react_node_hint(translated.ruby)}"
|
|
419
|
+
else
|
|
420
|
+
names = unresolved.map(&:inspect).join(", ")
|
|
421
|
+
"#{spaces(indent)}# TODO: unresolved identifier #{names}\n" \
|
|
422
|
+
"#{spaces(indent)}plain #{translated.ruby}"
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Gap G: when the translated value is a bare `@ivar` reference, the
|
|
427
|
+
# prop may be a ReactNode (children-typed prop) rather than a plain
|
|
428
|
+
# string. `plain` HTML-escapes its argument; `raw` doesn't. We can't
|
|
429
|
+
# tell at translation time which is intended, so default to `plain`
|
|
430
|
+
# (safe for string props) and emit a comment hint pointing at `raw`.
|
|
431
|
+
def react_node_hint(ruby)
|
|
432
|
+
return "" unless ruby.is_a?(String)
|
|
433
|
+
return "" unless ruby.match?(/\A@[a-z_][a-z0-9_]*\z/)
|
|
434
|
+
|
|
435
|
+
" # NOTE: use `raw` instead of `plain` if this is a ReactNode-typed prop"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The original JS expression couldn't be translated to Ruby. We can't
|
|
439
|
+
# emit `plain <verbatim-JS>` because raw JS (TypeScript casts, JSX
|
|
440
|
+
# method chains, ternary spreads, etc.) usually isn't valid Ruby.
|
|
441
|
+
# Instead, emit two safe lines: a `# TODO:` comment naming the
|
|
442
|
+
# expression, then a string-literal placeholder so the template still
|
|
443
|
+
# renders something visible at runtime.
|
|
444
|
+
def render_untranslated_interpolation(expression, indent)
|
|
445
|
+
compact = expression.tr("\n", " ").squeeze(" ")
|
|
446
|
+
"#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
|
|
447
|
+
"#{spaces(indent)}plain #{"[untranslated: #{compact}]".inspect}"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def render_comment(comment, indent:)
|
|
451
|
+
# Multi-line JSX comments need every line prefixed with `# ` — a
|
|
452
|
+
# bare first-line `#` would leave subsequent lines as Ruby code.
|
|
453
|
+
comment.text.split("\n").map { |line| "#{spaces(indent)}# #{line}" }.join("\n")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Build the Ruby attribute list — `(id: @id, class: @class, ...)` —
|
|
457
|
+
# to splice immediately after the tag method name. Returns "" when
|
|
458
|
+
# there are no attributes (so the caller emits a bare `h1` instead
|
|
459
|
+
# of `h1()`). The `context:` param selects naming convention:
|
|
460
|
+
# - :html (HTML element attrs — preserve camelCase for SVG)
|
|
461
|
+
# - :component (Ruby method args — snake_case via Inflector.underscore)
|
|
462
|
+
def format_attributes(attributes, translator, context: :html, todos: [])
|
|
463
|
+
events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
464
|
+
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
465
|
+
|
|
466
|
+
sym_parts = []
|
|
467
|
+
str_parts = []
|
|
468
|
+
plain_attrs.each do |a|
|
|
469
|
+
append_attribute_part(a, translator, sym_parts, str_parts, context: context, todos: todos)
|
|
470
|
+
end
|
|
471
|
+
sym_parts << data_action_entry(events, translator) if events.any?
|
|
472
|
+
|
|
473
|
+
joined = build_attribute_list(sym_parts, str_parts, spreads, translator)
|
|
474
|
+
joined.empty? ? "" : "(#{joined})"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def append_attribute_part(attribute, translator, sym_parts, str_parts, context:, todos:)
|
|
478
|
+
part = phlex_attribute_part(attribute, translator, context: context, todos: todos)
|
|
479
|
+
return unless part
|
|
480
|
+
|
|
481
|
+
(part[:string_key] ? str_parts : sym_parts) << part[:source]
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def build_attribute_list(sym_parts, str_parts, spreads, translator)
|
|
485
|
+
pieces = sym_parts.dup
|
|
486
|
+
pieces << "**{ #{str_parts.join(", ")} }" if str_parts.any?
|
|
487
|
+
pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
|
|
488
|
+
pieces.join(", ")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Emit one attribute as either a {string_key: false, source: "id: @x"}
|
|
492
|
+
# (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
|
|
493
|
+
# (rare; non-identifier name — goes into a **{ ... } splat).
|
|
494
|
+
def phlex_attribute_part(attribute, translator, context:, todos:)
|
|
495
|
+
case attribute
|
|
496
|
+
when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
|
|
497
|
+
when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
|
|
498
|
+
when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator)}" }
|
|
499
|
+
when IR::Attribute then plain_attribute_part(attribute, translator, context: context, todos: todos)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def class_attribute_part(expression, translator)
|
|
504
|
+
translated = translator.translate(expression)
|
|
505
|
+
ruby = translated ? translated.ruby : expression.inspect
|
|
506
|
+
{ string_key: false, source: "class: #{ruby}" }
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Map a JSX attribute name to its Ruby kwarg form. For HTML element
|
|
510
|
+
# attrs (`context: :html`), only hyphens convert to underscores —
|
|
511
|
+
# camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
|
|
512
|
+
# so SVG attributes render correctly through Phlex. For component
|
|
513
|
+
# invocations (`context: :component`), full Inflector.underscore
|
|
514
|
+
# converts both hyphens AND camelCase, since Ruby method args
|
|
515
|
+
# follow snake_case convention (`defaultValue` → `default_value`).
|
|
516
|
+
# Names that aren't valid Ruby identifiers after conversion (rare:
|
|
517
|
+
# `xml:lang` and friends) fall back to a quoted string key.
|
|
518
|
+
def plain_attribute_part(attribute, translator, context:, todos:)
|
|
519
|
+
value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos)
|
|
520
|
+
ruby_name = case context
|
|
521
|
+
when :component then AST::Inflector.underscore(attribute.name)
|
|
522
|
+
else attribute.name.tr("-", "_")
|
|
523
|
+
end
|
|
524
|
+
if ruby_name.match?(VALID_IDENTIFIER)
|
|
525
|
+
{ string_key: false, source: "#{ruby_name}: #{value_ruby}" }
|
|
526
|
+
else
|
|
527
|
+
{ string_key: true, source: "#{attribute.name.inspect} => #{value_ruby}" }
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def attribute_value_to_ruby(name, value, translator, todos:)
|
|
532
|
+
case value
|
|
533
|
+
when true then "true"
|
|
534
|
+
when String then value.inspect
|
|
535
|
+
when IR::Interpolation then interpolated_attribute_value(name, value, translator, todos: todos)
|
|
536
|
+
when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
|
|
537
|
+
when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
|
|
538
|
+
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
|
|
543
|
+
# entries become Ruby kwargs (snake_cased to match Ruby convention);
|
|
544
|
+
# non-identifier keys (numeric, hyphenated) fall back to string keys.
|
|
545
|
+
def render_object_literal_value(object_literal, translator, todos:)
|
|
546
|
+
parts = object_literal.properties.map do |(key, value)|
|
|
547
|
+
render_object_property(key, value, translator, todos: todos)
|
|
548
|
+
end
|
|
549
|
+
"{ #{parts.join(", ")} }"
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def render_object_property(key, value, translator, todos:)
|
|
553
|
+
value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key)
|
|
554
|
+
snake = AST::Inflector.underscore(key)
|
|
555
|
+
if snake.match?(VALID_IDENTIFIER)
|
|
556
|
+
"#{snake}: #{value_ruby}"
|
|
557
|
+
else
|
|
558
|
+
"#{key.inspect} => #{value_ruby}"
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def render_array_literal_value(array_literal, translator, todos:)
|
|
563
|
+
parts = array_literal.elements.map do |el|
|
|
564
|
+
el.nil? ? "nil" : render_inline_value(el, translator, todos: todos, attr_name: nil)
|
|
565
|
+
end
|
|
566
|
+
"[#{parts.join(", ")}]"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# An inline value can appear as a kwarg value, an array element, or a
|
|
570
|
+
# hash property value. Recursive shapes route back through the new IR
|
|
571
|
+
# types; primitives fall through the same paths as attribute_value_to_ruby.
|
|
572
|
+
def render_inline_value(value, translator, todos:, attr_name:)
|
|
573
|
+
case value
|
|
574
|
+
when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
|
|
575
|
+
when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
|
|
576
|
+
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
|
|
577
|
+
when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
|
|
578
|
+
todos: todos)
|
|
579
|
+
when String then value.inspect
|
|
580
|
+
when true then "true"
|
|
581
|
+
else
|
|
582
|
+
"nil"
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# An IR::Lambda lives inside an object/array literal as a value. We
|
|
587
|
+
# extract it to a method on the class (so it has access to the
|
|
588
|
+
# Phlex tag.* helpers) and reference it via `method(:name)` in the
|
|
589
|
+
# value position. Method names are deterministic so re-runs produce
|
|
590
|
+
# stable output: `<attr-name>_renderer<N>` where N is a per-attr index.
|
|
591
|
+
def render_lambda_method_reference(lambda, translator, attr_name:)
|
|
592
|
+
@lambda_methods ||= []
|
|
593
|
+
base = lambda_method_base(attr_name)
|
|
594
|
+
@lambda_methods << { base: base, lambda: lambda, translator: translator }
|
|
595
|
+
# Index is the position within `@lambda_methods` so re-renders are
|
|
596
|
+
# deterministic in the order encountered.
|
|
597
|
+
method_name = unique_lambda_method_name(base)
|
|
598
|
+
@lambda_methods.last[:method_name] = method_name
|
|
599
|
+
"method(:#{method_name})"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def lambda_method_base(attr_name)
|
|
603
|
+
return "render_lambda" if attr_name.nil? || attr_name.empty?
|
|
604
|
+
|
|
605
|
+
"render_#{AST::Inflector.underscore(attr_name)}"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def unique_lambda_method_name(base)
|
|
609
|
+
@lambda_method_counts ||= {}
|
|
610
|
+
@lambda_method_counts[base] ||= 0
|
|
611
|
+
@lambda_method_counts[base] += 1
|
|
612
|
+
@lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Attribute-position interpolation. Two failure modes:
|
|
616
|
+
# 1. Translator returns non-nil but with unresolved identifiers —
|
|
617
|
+
# the Ruby reference is fine to emit (it'll surface as a
|
|
618
|
+
# NameError at render time if it's wrong). Inline TODO comments
|
|
619
|
+
# aren't safe in attribute position (would break hash splat or
|
|
620
|
+
# method-call syntax), so the marker is suppressed.
|
|
621
|
+
# 2. Translator returns nil — the original JS expression couldn't
|
|
622
|
+
# be parsed at all (e.g. `<LeftOutlined .../>`, array literals,
|
|
623
|
+
# template literals with method calls). We emit `nil` for the
|
|
624
|
+
# kwarg AND record the original expression in `todos` so the
|
|
625
|
+
# caller can prepend a `# TODO:` comment line above the element.
|
|
626
|
+
def interpolated_attribute_value(name, value, translator, todos:)
|
|
627
|
+
translated = translator.translate(value.expression)
|
|
628
|
+
return translated.ruby if translated
|
|
629
|
+
|
|
630
|
+
compact = value.expression.tr("\n", " ").squeeze(" ")
|
|
631
|
+
todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
|
|
632
|
+
"nil"
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def component_invocation_kwargs(props, translator, todos: [])
|
|
636
|
+
events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
637
|
+
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
638
|
+
|
|
639
|
+
sym_parts = []
|
|
640
|
+
str_parts = []
|
|
641
|
+
plain_attrs.each do |a|
|
|
642
|
+
append_attribute_part(a, translator, sym_parts, str_parts, context: :component, todos: todos)
|
|
643
|
+
end
|
|
644
|
+
sym_parts << data_action_entry(events, translator) if events.any?
|
|
645
|
+
|
|
646
|
+
build_attribute_list(sym_parts, str_parts, spreads, translator)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def class_list_to_ruby_string(class_list, translator)
|
|
650
|
+
parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
|
|
651
|
+
%("#{parts.join(" ")}")
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def class_segment_to_ruby(segment, translator)
|
|
655
|
+
case segment
|
|
656
|
+
when String then segment
|
|
657
|
+
when IR::Interpolation
|
|
658
|
+
translated = translator.translate(segment.expression)
|
|
659
|
+
"\#{#{translated&.ruby || segment.expression}}"
|
|
660
|
+
when IR::ConditionalSegment
|
|
661
|
+
cond_translated = translator.translate(segment.condition.expression)
|
|
662
|
+
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
663
|
+
%(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def style_to_ruby_string(style, translator)
|
|
668
|
+
parts = style.declarations.map { |decl| style_declaration_to_ruby(decl, translator) }
|
|
669
|
+
%("#{parts.join(" ")}")
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def style_declaration_to_ruby(decl, translator)
|
|
673
|
+
value = case decl.value
|
|
674
|
+
when String then decl.value
|
|
675
|
+
when IR::Interpolation
|
|
676
|
+
translated = translator.translate(decl.value.expression)
|
|
677
|
+
"\#{#{translated&.ruby || decl.value.expression}}"
|
|
678
|
+
end
|
|
679
|
+
"#{decl.property}: #{value};"
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Wrap the spread expression in `(… || {})` so a nil-valued prop
|
|
683
|
+
# default doesn't raise at render time. `<div {...maybeNil}>` →
|
|
684
|
+
# `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
|
|
685
|
+
# `|| {}` shortcuts on non-nil values.
|
|
686
|
+
def render_spread(expression, translator)
|
|
687
|
+
translated = translator.translate(expression)
|
|
688
|
+
ruby = translated ? translated.ruby : expression
|
|
689
|
+
"(#{ruby} || {})"
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Build the `data_action: "..."` kwarg. Phlex auto-hyphenates the
|
|
693
|
+
# `data_action` symbol key to `data-action` in the rendered HTML.
|
|
694
|
+
def data_action_entry(events, translator)
|
|
695
|
+
descriptors = events.map { |event| event_descriptor(event, translator) }
|
|
696
|
+
joined = if descriptors.size == 1
|
|
697
|
+
render_single_event_descriptor(descriptors.first)
|
|
698
|
+
else
|
|
699
|
+
%("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
|
|
700
|
+
end
|
|
701
|
+
"data_action: #{joined}"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def event_descriptor(event, translator)
|
|
705
|
+
case event
|
|
706
|
+
when IR::EventBinding
|
|
707
|
+
translated = translator.translate(event.handler.expression)
|
|
708
|
+
EventDescriptor.new(:ruby, translated ? translated.ruby : event.handler.expression.inspect)
|
|
709
|
+
when IR::StimulusBinding
|
|
710
|
+
EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def render_single_event_descriptor(descriptor)
|
|
715
|
+
descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def descriptor_in_string(descriptor)
|
|
719
|
+
descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def render_stimulus_controller_js(component)
|
|
723
|
+
lines = [
|
|
724
|
+
'import { Controller } from "@hotwired/stimulus";',
|
|
725
|
+
"",
|
|
726
|
+
"export default class extends Controller {"
|
|
727
|
+
]
|
|
728
|
+
component.stimulus_methods.each_with_index do |method, idx|
|
|
729
|
+
lines << "" if idx.positive?
|
|
730
|
+
lines.concat(stimulus_method_lines(method))
|
|
731
|
+
end
|
|
732
|
+
lines << "}"
|
|
733
|
+
"#{lines.join("\n")}\n"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def stimulus_method_lines(method)
|
|
737
|
+
body_lines = method.body_source.strip.split("\n")
|
|
738
|
+
commented = body_lines.map { |line| " // #{line}" }
|
|
739
|
+
header = [" // TODO: translate from the original JSX handler:"]
|
|
740
|
+
if method.name != method.original_name
|
|
741
|
+
header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
|
|
742
|
+
"to avoid collision with an earlier handler")
|
|
743
|
+
end
|
|
744
|
+
header + commented + [
|
|
745
|
+
" #{method.name}(event) {",
|
|
746
|
+
" // ...",
|
|
747
|
+
" }"
|
|
748
|
+
]
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def spaces(count)
|
|
752
|
+
" " * count
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
end
|