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
|
@@ -18,7 +18,9 @@ module JsxRosetta
|
|
|
18
18
|
def emit(component)
|
|
19
19
|
prop_names = component.props.map(&:name)
|
|
20
20
|
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
21
|
-
translator = ExpressionTranslator.new(
|
|
21
|
+
translator = ExpressionTranslator.new(
|
|
22
|
+
prop_names: prop_names, local_binding_names: component.local_binding_names
|
|
23
|
+
)
|
|
22
24
|
|
|
23
25
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
24
26
|
|
|
@@ -18,6 +18,10 @@ module JsxRosetta
|
|
|
18
18
|
# to the bare snake_case identifier.
|
|
19
19
|
# * Names in `prop_names` translate to a `@snake_case` instance
|
|
20
20
|
# variable.
|
|
21
|
+
# * Names in `local_binding_names` (consts/destructures captured at
|
|
22
|
+
# lowering time but not modeled in IR) translate to a `nil`
|
|
23
|
+
# placeholder with an inline `# TODO: local 'name'` marker — the
|
|
24
|
+
# file still loads, but the reviewer sees what to fill in.
|
|
21
25
|
# * Anything else translates to the bare snake_case identifier and
|
|
22
26
|
# is recorded as unresolved.
|
|
23
27
|
#
|
|
@@ -35,8 +39,9 @@ module JsxRosetta
|
|
|
35
39
|
|
|
36
40
|
Result = Data.define(:ruby, :unresolved_identifiers)
|
|
37
41
|
|
|
38
|
-
def initialize(prop_names:)
|
|
42
|
+
def initialize(prop_names:, local_binding_names: [])
|
|
39
43
|
@prop_names = prop_names.to_set
|
|
44
|
+
@local_binding_names = local_binding_names.to_set
|
|
40
45
|
@local_stack = []
|
|
41
46
|
end
|
|
42
47
|
|
|
@@ -77,12 +82,27 @@ module JsxRosetta
|
|
|
77
82
|
@local_stack.any? { |scope| scope.include?(name) }
|
|
78
83
|
end
|
|
79
84
|
|
|
80
|
-
def translate_identifier(name, unresolved)
|
|
85
|
+
def translate_identifier(name, unresolved, member_chain_root: false)
|
|
81
86
|
snake = AST::Inflector.underscore(name)
|
|
82
87
|
if in_local_scope?(name)
|
|
83
88
|
snake
|
|
84
89
|
elsif @prop_names.include?(name)
|
|
85
90
|
"@#{snake}"
|
|
91
|
+
elsif @local_binding_names.include?(name)
|
|
92
|
+
# We know this binding exists locally (destructure, hook tuple)
|
|
93
|
+
# but can't model its value. As a leaf identifier, return `nil`
|
|
94
|
+
# so the file loads (a bare snake_case ref would NameError).
|
|
95
|
+
# As a member-chain root, `nil.member` would NoMethodError at
|
|
96
|
+
# render time — worse. Fall back to the snake_case bare ref
|
|
97
|
+
# and let it surface as a NameError (caller adds an unresolved
|
|
98
|
+
# marker), which is at least debuggable. The TODO marker for
|
|
99
|
+
# the binding source already lives in the comment block.
|
|
100
|
+
if member_chain_root
|
|
101
|
+
unresolved << name
|
|
102
|
+
snake
|
|
103
|
+
else
|
|
104
|
+
"nil"
|
|
105
|
+
end
|
|
86
106
|
else
|
|
87
107
|
unresolved << name
|
|
88
108
|
snake
|
|
@@ -90,7 +110,7 @@ module JsxRosetta
|
|
|
90
110
|
end
|
|
91
111
|
|
|
92
112
|
def translate_member_chain(root, rest, unresolved)
|
|
93
|
-
translated_root = translate_identifier(root, unresolved)
|
|
113
|
+
translated_root = translate_identifier(root, unresolved, member_chain_root: true)
|
|
94
114
|
# Underscore each chain segment so JS camelCase identifiers map to
|
|
95
115
|
# Ruby snake_case (`post.coverImage` → `post.cover_image`).
|
|
96
116
|
ruby_rest = rest.gsub(/\.([a-zA-Z_$][a-zA-Z_$0-9]*)/) do
|
|
@@ -103,16 +123,38 @@ module JsxRosetta
|
|
|
103
123
|
return nil if content.include?("\\`")
|
|
104
124
|
return nil if content.scan("${").size != content.scan(TEMPLATE_INTERPOLATION).size
|
|
105
125
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
parts = []
|
|
127
|
+
last_pos = 0
|
|
128
|
+
content.to_enum(:scan, TEMPLATE_INTERPOLATION).each do
|
|
129
|
+
match = ::Regexp.last_match
|
|
130
|
+
literal = content[last_pos...match.begin(0)]
|
|
131
|
+
parts << escape_ruby_string_literal(literal) unless literal.empty?
|
|
132
|
+
parts << "\#{#{translate_template_interpolation(match[1], unresolved)}}"
|
|
133
|
+
last_pos = match.end(0)
|
|
114
134
|
end
|
|
115
|
-
|
|
135
|
+
trailing = content[last_pos..]
|
|
136
|
+
parts << escape_ruby_string_literal(trailing) unless trailing.empty?
|
|
137
|
+
%("#{parts.join}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Split into literal vs. interpolation segments so `"` and `\` in
|
|
141
|
+
# the literal parts can be escaped without touching the
|
|
142
|
+
# interpolation expressions (which are already valid Ruby).
|
|
143
|
+
def translate_template_interpolation(captured, unresolved)
|
|
144
|
+
if (m = MEMBER_CHAIN.match(captured))
|
|
145
|
+
translate_member_chain(m[:root], m[:rest], unresolved)
|
|
146
|
+
else
|
|
147
|
+
translate_identifier(captured, unresolved)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Escape backslashes and double quotes so the literal portions of a
|
|
152
|
+
# translated template literal don't accidentally terminate the
|
|
153
|
+
# surrounding Ruby string. Newlines stay literal — Ruby double-quoted
|
|
154
|
+
# strings allow them, and template literals are typically used for
|
|
155
|
+
# short interpolated phrases anyway.
|
|
156
|
+
def escape_ruby_string_literal(text)
|
|
157
|
+
text.gsub("\\", "\\\\").gsub('"', '\\"')
|
|
116
158
|
end
|
|
117
159
|
end
|
|
118
160
|
end
|
|
@@ -28,6 +28,12 @@ module JsxRosetta
|
|
|
28
28
|
DEFAULT_SLOT_NAME = "children"
|
|
29
29
|
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
30
30
|
|
|
31
|
+
# Structured intermediate for tag_builder_data_action — avoids the
|
|
32
|
+
# fragile "parse what you just rendered" pattern. :literal is a raw
|
|
33
|
+
# action token like `"click->foo#bar"`; :ruby is a Ruby expression
|
|
34
|
+
# whose value is the action string (e.g. `event.handler.expression`).
|
|
35
|
+
EventDescriptor = Data.define(:kind, :body)
|
|
36
|
+
|
|
31
37
|
# JSX component names that have a direct Rails view-helper analog.
|
|
32
38
|
# Override per-instance via `ViewComponent.new(helpers: {...})`, or
|
|
33
39
|
# disable by passing `helpers: false`.
|
|
@@ -53,7 +59,9 @@ module JsxRosetta
|
|
|
53
59
|
def emit(component)
|
|
54
60
|
prop_names = component.props.map(&:name)
|
|
55
61
|
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
56
|
-
translator = ExpressionTranslator.new(
|
|
62
|
+
translator = ExpressionTranslator.new(
|
|
63
|
+
prop_names: prop_names, local_binding_names: component.local_binding_names
|
|
64
|
+
)
|
|
57
65
|
|
|
58
66
|
base_name = "#{AST::Inflector.underscore(component.name)}_component"
|
|
59
67
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
@@ -101,7 +109,12 @@ module JsxRosetta
|
|
|
101
109
|
def stimulus_method_lines(method)
|
|
102
110
|
body_lines = method.body_source.strip.split("\n")
|
|
103
111
|
commented = body_lines.map { |line| " // #{line}" }
|
|
104
|
-
[" // TODO: translate from the original JSX handler:"]
|
|
112
|
+
header = [" // TODO: translate from the original JSX handler:"]
|
|
113
|
+
if method.name != method.original_name
|
|
114
|
+
header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
|
|
115
|
+
"to avoid collision with an earlier handler")
|
|
116
|
+
end
|
|
117
|
+
header + commented + [
|
|
105
118
|
" #{method.name}(event) {",
|
|
106
119
|
" // ...",
|
|
107
120
|
" }"
|
|
@@ -118,16 +131,41 @@ module JsxRosetta
|
|
|
118
131
|
props = initializable_props(component)
|
|
119
132
|
rest_name = component.rest_prop_name
|
|
120
133
|
|
|
121
|
-
if props.empty? && rest_name.nil?
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
body = if props.empty? && rest_name.nil?
|
|
135
|
+
<<~RUBY
|
|
136
|
+
# frozen_string_literal: true
|
|
124
137
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
class #{component.name}Component < ::ViewComponent::Base
|
|
139
|
+
end
|
|
140
|
+
RUBY
|
|
141
|
+
else
|
|
142
|
+
render_ruby_class_with_props(component, props, rest_name, translator)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
prefix = render_module_bindings_prefix(component)
|
|
146
|
+
prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def render_module_bindings_prefix(component)
|
|
150
|
+
return "" if component.module_bindings.empty?
|
|
151
|
+
|
|
152
|
+
lines = ["# TODO: module-level constants — translate to Ruby constants " \
|
|
153
|
+
"or move to a Rails initializer:"]
|
|
154
|
+
component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
|
|
155
|
+
"#{lines.join("\n")}\n"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The class body already starts with the magic comment — splice the
|
|
159
|
+
# module-bindings prefix in between so it lands above the class.
|
|
160
|
+
def insert_module_bindings_prefix(body, prefix)
|
|
161
|
+
magic = "# frozen_string_literal: true\n\n"
|
|
162
|
+
return "#{prefix}#{body}" unless body.start_with?(magic)
|
|
163
|
+
|
|
164
|
+
"#{magic}#{prefix}#{body[magic.length..]}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def comment_lines(source)
|
|
168
|
+
source.split("\n").map { |line| "# #{line}" }
|
|
131
169
|
end
|
|
132
170
|
|
|
133
171
|
def render_ruby_class_with_props(component, props, rest_name, translator)
|
|
@@ -160,8 +198,41 @@ module JsxRosetta
|
|
|
160
198
|
def ruby_default_for(prop, translator)
|
|
161
199
|
return "nil" if prop.default.nil?
|
|
162
200
|
|
|
163
|
-
|
|
164
|
-
|
|
201
|
+
case prop.default
|
|
202
|
+
when IR::Interpolation
|
|
203
|
+
translated = translator.translate(prop.default.expression)
|
|
204
|
+
translated ? translated.ruby : "nil"
|
|
205
|
+
when IR::ObjectLiteral then render_object_literal_default(prop.default, translator)
|
|
206
|
+
when IR::ArrayLiteral then render_array_literal_default(prop.default, translator)
|
|
207
|
+
else "nil"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def render_object_literal_default(object_literal, translator)
|
|
212
|
+
pairs = object_literal.properties.map do |(key, value)|
|
|
213
|
+
snake = AST::Inflector.underscore(key)
|
|
214
|
+
key_str = snake.match?(/\A[a-z_][a-z0-9_]*\z/i) ? "#{snake}:" : "#{key.inspect} =>"
|
|
215
|
+
"#{key_str} #{render_default_inline_value(value, translator)}"
|
|
216
|
+
end
|
|
217
|
+
"{ #{pairs.join(", ")} }"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def render_array_literal_default(array_literal, translator)
|
|
221
|
+
parts = array_literal.elements.map { |el| el.nil? ? "nil" : render_default_inline_value(el, translator) }
|
|
222
|
+
"[#{parts.join(", ")}]"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def render_default_inline_value(value, translator)
|
|
226
|
+
case value
|
|
227
|
+
when IR::ObjectLiteral then render_object_literal_default(value, translator)
|
|
228
|
+
when IR::ArrayLiteral then render_array_literal_default(value, translator)
|
|
229
|
+
when IR::Interpolation
|
|
230
|
+
translated = translator.translate(value.expression)
|
|
231
|
+
translated ? translated.ruby : "nil"
|
|
232
|
+
when String then value.inspect
|
|
233
|
+
when true then "true"
|
|
234
|
+
else "nil"
|
|
235
|
+
end
|
|
165
236
|
end
|
|
166
237
|
|
|
167
238
|
def render_erb_template(component, translator)
|
|
@@ -211,6 +282,7 @@ module JsxRosetta
|
|
|
211
282
|
when IR::Fragment then render_fragment(node, translator, indent: indent)
|
|
212
283
|
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
213
284
|
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
285
|
+
when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
|
|
214
286
|
when IR::Slot then render_slot(node, indent: indent)
|
|
215
287
|
when IR::Text then "#{spaces(indent)}#{node.value}"
|
|
216
288
|
when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
|
|
@@ -218,8 +290,14 @@ module JsxRosetta
|
|
|
218
290
|
end
|
|
219
291
|
end
|
|
220
292
|
|
|
293
|
+
def render_orphan_render_prop(render_prop, translator, indent:)
|
|
294
|
+
translator.with_locals(render_prop.params) do
|
|
295
|
+
render_ir_node(render_prop.body, translator, indent: indent)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
221
299
|
def render_loop(loop_node, translator, indent:)
|
|
222
|
-
iterable_ruby =
|
|
300
|
+
iterable_ruby = render_loop_iterable(loop_node.iterable, translator)
|
|
223
301
|
js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
|
|
224
302
|
ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
|
|
225
303
|
binding_str = "|#{ruby_bindings.join(", ")}|"
|
|
@@ -235,6 +313,14 @@ module JsxRosetta
|
|
|
235
313
|
].join("\n")
|
|
236
314
|
end
|
|
237
315
|
|
|
316
|
+
def render_loop_iterable(iterable, translator)
|
|
317
|
+
case iterable
|
|
318
|
+
when IR::ArrayLiteral then render_array_literal_default(iterable, translator)
|
|
319
|
+
when IR::Interpolation then render_test_expression(iterable, translator)
|
|
320
|
+
else "[]"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
238
324
|
def render_element(element, translator, indent:)
|
|
239
325
|
return render_element_with_tag_builder(element, translator, indent: indent) if needs_tag_builder?(element)
|
|
240
326
|
|
|
@@ -312,13 +398,11 @@ module JsxRosetta
|
|
|
312
398
|
|
|
313
399
|
def tag_builder_data_action(events, translator)
|
|
314
400
|
descriptors = events.map { |event| tag_builder_event_descriptor(event, translator) }
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
elsif all_literal
|
|
319
|
-
%("#{descriptors.map { |d| d[1..-2] }.join(" ")}")
|
|
401
|
+
joined = case descriptors
|
|
402
|
+
in [single]
|
|
403
|
+
render_single_event_descriptor(single)
|
|
320
404
|
else
|
|
321
|
-
%("#{descriptors.map { |d|
|
|
405
|
+
%("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
|
|
322
406
|
end
|
|
323
407
|
%("data-action" => #{joined})
|
|
324
408
|
end
|
|
@@ -327,23 +411,34 @@ module JsxRosetta
|
|
|
327
411
|
case event
|
|
328
412
|
when IR::EventBinding
|
|
329
413
|
translated = translator.translate(event.handler.expression)
|
|
330
|
-
|
|
414
|
+
if translated
|
|
415
|
+
EventDescriptor.new(:ruby, translated.ruby)
|
|
416
|
+
else
|
|
417
|
+
EventDescriptor.new(:ruby, event.handler.expression.inspect)
|
|
418
|
+
end
|
|
331
419
|
when IR::StimulusBinding
|
|
332
|
-
|
|
420
|
+
EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
|
|
333
421
|
end
|
|
334
422
|
end
|
|
335
423
|
|
|
336
|
-
def
|
|
337
|
-
|
|
338
|
-
descriptor[1..-2]
|
|
339
|
-
else
|
|
340
|
-
"\#{#{descriptor}}"
|
|
341
|
-
end
|
|
424
|
+
def render_single_event_descriptor(descriptor)
|
|
425
|
+
descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
|
|
342
426
|
end
|
|
343
427
|
|
|
428
|
+
# Render a descriptor inline inside a Ruby string literal: literals
|
|
429
|
+
# are spliced verbatim, ruby expressions become `#{...}`.
|
|
430
|
+
def descriptor_in_string(descriptor)
|
|
431
|
+
descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Wrap the spread expression in `(… || {})` so a nil-valued prop
|
|
435
|
+
# default doesn't raise at render time. `<div {...maybeNil}>` →
|
|
436
|
+
# `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
|
|
437
|
+
# `|| {}` shortcuts on non-nil values.
|
|
344
438
|
def tag_builder_spread(expression, translator)
|
|
345
439
|
translated = translator.translate(expression)
|
|
346
|
-
translated ? translated.ruby : expression
|
|
440
|
+
ruby = translated ? translated.ruby : expression
|
|
441
|
+
"(#{ruby} || {})"
|
|
347
442
|
end
|
|
348
443
|
|
|
349
444
|
def render_component_invocation(invocation, translator, indent:)
|
|
@@ -354,7 +449,10 @@ module JsxRosetta
|
|
|
354
449
|
class_name = component_class_name(invocation.name)
|
|
355
450
|
new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
|
|
356
451
|
|
|
357
|
-
|
|
452
|
+
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
|
|
453
|
+
if render_prop
|
|
454
|
+
render_component_with_render_prop(new_call, render_prop, translator, indent)
|
|
455
|
+
elsif invocation.children.empty?
|
|
358
456
|
"#{spaces(indent)}<%= render #{new_call} %>"
|
|
359
457
|
else
|
|
360
458
|
inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
|
|
@@ -362,6 +460,15 @@ module JsxRosetta
|
|
|
362
460
|
end
|
|
363
461
|
end
|
|
364
462
|
|
|
463
|
+
def render_component_with_render_prop(new_call, render_prop, translator, indent)
|
|
464
|
+
snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
|
|
465
|
+
param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
|
|
466
|
+
inner = translator.with_locals(render_prop.params) do
|
|
467
|
+
render_ir_node(render_prop.body, translator, indent: indent + 2)
|
|
468
|
+
end
|
|
469
|
+
"#{spaces(indent)}<%= render #{new_call} do#{param_str} %>\n#{inner}\n#{spaces(indent)}<% end %>"
|
|
470
|
+
end
|
|
471
|
+
|
|
365
472
|
# JSX `<Foo.Bar>` → Ruby `Foo::BarComponent`. Plain `<Card>` stays as
|
|
366
473
|
# `CardComponent`. Each member-expression segment joins with `::`,
|
|
367
474
|
# and `Component` suffixes the leaf so the result is a constant path
|
|
@@ -455,52 +562,53 @@ module JsxRosetta
|
|
|
455
562
|
end
|
|
456
563
|
|
|
457
564
|
def render_style(style, translator)
|
|
458
|
-
rendered = style.declarations.map { |decl|
|
|
565
|
+
rendered = style.declarations.map { |decl| style_declaration(decl, translator, format: :erb) }.join(" ")
|
|
459
566
|
%(style="#{rendered}")
|
|
460
567
|
end
|
|
461
568
|
|
|
462
|
-
def
|
|
569
|
+
def render_class_list_attribute(class_list, translator)
|
|
570
|
+
parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :erb) }
|
|
571
|
+
%(class="#{parts.join(" ")}")
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def class_list_to_ruby_string(class_list, translator)
|
|
575
|
+
parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :ruby_string) }
|
|
576
|
+
%("#{parts.join(" ")}")
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Render one IR::Style declaration in either ERB-template form
|
|
580
|
+
# (`color: <%= @c %>;`) or Ruby-string-interpolation form
|
|
581
|
+
# (`color: #{@c};`).
|
|
582
|
+
def style_declaration(decl, translator, format:)
|
|
463
583
|
value = case decl.value
|
|
464
584
|
when String then decl.value
|
|
465
|
-
when IR::Interpolation
|
|
466
|
-
translated = translator.translate(decl.value.expression)
|
|
467
|
-
"<%= #{translated&.ruby || decl.value.expression} %>"
|
|
585
|
+
when IR::Interpolation then interpolation_value(decl.value.expression, translator, format: format)
|
|
468
586
|
end
|
|
469
587
|
"#{decl.property}: #{value};"
|
|
470
588
|
end
|
|
471
589
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
def class_segment_for_html(segment, translator)
|
|
590
|
+
# Render one ClassList segment in either ERB-template form or Ruby
|
|
591
|
+
# string-interpolation form.
|
|
592
|
+
def class_segment(segment, translator, format:)
|
|
478
593
|
case segment
|
|
479
594
|
when String then segment
|
|
480
|
-
when IR::Interpolation
|
|
481
|
-
|
|
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} : '' %>"
|
|
595
|
+
when IR::Interpolation then interpolation_value(segment.expression, translator, format: format)
|
|
596
|
+
when IR::ConditionalSegment then conditional_class_segment(segment, translator, format: format)
|
|
487
597
|
end
|
|
488
598
|
end
|
|
489
599
|
|
|
490
|
-
def
|
|
491
|
-
|
|
492
|
-
|
|
600
|
+
def interpolation_value(expression, translator, format:)
|
|
601
|
+
translated = translator.translate(expression)
|
|
602
|
+
ruby = translated&.ruby || expression
|
|
603
|
+
format == :erb ? "<%= #{ruby} %>" : "\#{#{ruby}}"
|
|
493
604
|
end
|
|
494
605
|
|
|
495
|
-
def
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
when IR::ConditionalSegment
|
|
502
|
-
cond_translated = translator.translate(segment.condition.expression)
|
|
503
|
-
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
606
|
+
def conditional_class_segment(segment, translator, format:)
|
|
607
|
+
cond_translated = translator.translate(segment.condition.expression)
|
|
608
|
+
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
609
|
+
if format == :erb
|
|
610
|
+
"<%= #{cond_ruby} ? #{segment.class_name.inspect} : '' %>"
|
|
611
|
+
else
|
|
504
612
|
%(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
|
|
505
613
|
end
|
|
506
614
|
end
|
|
@@ -598,15 +706,7 @@ module JsxRosetta
|
|
|
598
706
|
end
|
|
599
707
|
|
|
600
708
|
def style_to_ruby_string(style, translator)
|
|
601
|
-
parts = style.declarations.map
|
|
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
|
|
709
|
+
parts = style.declarations.map { |decl| style_declaration(decl, translator, format: :ruby_string) }
|
|
610
710
|
%("#{parts.join(" ")}")
|
|
611
711
|
end
|
|
612
712
|
|
data/lib/jsx_rosetta/backend.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "backend/base"
|
|
|
4
4
|
require_relative "backend/view_component"
|
|
5
5
|
require_relative "backend/view_component/expression_translator"
|
|
6
6
|
require_relative "backend/rails_view"
|
|
7
|
+
require_relative "backend/phlex"
|
|
7
8
|
require_relative "backend/routes_script"
|
|
8
9
|
|
|
9
10
|
module JsxRosetta
|
data/lib/jsx_rosetta/cli.rb
CHANGED
|
@@ -63,16 +63,40 @@ module JsxRosetta
|
|
|
63
63
|
|
|
64
64
|
out_dir = options[:out] || "."
|
|
65
65
|
typescript = options[:tsx] || input_path.end_with?(".tsx")
|
|
66
|
-
backend = options[:as]
|
|
66
|
+
backend = backend_for_as(options[:as])
|
|
67
|
+
backend_options = backend_options_for(backend, options)
|
|
67
68
|
|
|
68
69
|
source = File.read(input_path)
|
|
69
70
|
files = JsxRosetta.translate(
|
|
70
71
|
source,
|
|
71
72
|
backend: backend,
|
|
73
|
+
backend_options: backend_options,
|
|
72
74
|
typescript: typescript,
|
|
73
75
|
source_filename: input_path
|
|
74
76
|
)
|
|
75
77
|
|
|
78
|
+
write_emitted_files(files, out_dir)
|
|
79
|
+
EXIT_OK
|
|
80
|
+
rescue ParseError, IR::Lowering::LoweringError, ArgumentError => e
|
|
81
|
+
@stderr.puts "jsx_rosetta translate: #{e.message}"
|
|
82
|
+
EXIT_FAILURE
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def backend_for_as(value)
|
|
86
|
+
case value
|
|
87
|
+
when "view" then :rails_view
|
|
88
|
+
when "phlex" then :phlex
|
|
89
|
+
else :view_component
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def backend_options_for(backend, options)
|
|
94
|
+
return {} unless backend == :phlex
|
|
95
|
+
|
|
96
|
+
{ suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_emitted_files(files, out_dir)
|
|
76
100
|
FileUtils.mkdir_p(out_dir)
|
|
77
101
|
files.each do |file|
|
|
78
102
|
target = File.join(out_dir, file.path)
|
|
@@ -80,10 +104,6 @@ module JsxRosetta
|
|
|
80
104
|
File.write(target, file.contents)
|
|
81
105
|
@stdout.puts "wrote #{target}"
|
|
82
106
|
end
|
|
83
|
-
EXIT_OK
|
|
84
|
-
rescue ParseError, IR::Lowering::LoweringError => e
|
|
85
|
-
@stderr.puts "jsx_rosetta translate: #{e.message}"
|
|
86
|
-
EXIT_FAILURE
|
|
87
107
|
end
|
|
88
108
|
|
|
89
109
|
def run_routes
|
|
@@ -142,6 +162,10 @@ module JsxRosetta
|
|
|
142
162
|
when "--tsx", "--typescript" then options[:tsx] = true
|
|
143
163
|
when "--as" then options[:as] = @argv.shift
|
|
144
164
|
when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
|
|
165
|
+
when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
|
|
166
|
+
when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
|
|
167
|
+
when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
|
|
168
|
+
when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
|
|
145
169
|
else positional << arg
|
|
146
170
|
end
|
|
147
171
|
end
|
|
@@ -166,6 +190,10 @@ module JsxRosetta
|
|
|
166
190
|
Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
|
|
167
191
|
instead of a ViewComponent class + sidecar template — appropriate
|
|
168
192
|
for pages tied to a route.
|
|
193
|
+
Pass --as=phlex to emit a single-file Phlex 2.x view class
|
|
194
|
+
(`<snake>.rb`) instead of a ViewComponent. Configure the class
|
|
195
|
+
name with --phlex-suffix=Component or --phlex-namespace=Components
|
|
196
|
+
(mutually exclusive; default is bare class name).
|
|
169
197
|
routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
|
|
170
198
|
and emit a reviewable Ruby script that calls `rails generate
|
|
171
199
|
controller` and prints suggested config/routes.rb additions.
|