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,727 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "types"
|
|
4
|
+
|
|
5
|
+
module JsxRosetta
|
|
6
|
+
module IR
|
|
7
|
+
# Lowers a parsed AST::File into an IR::Component tree.
|
|
8
|
+
#
|
|
9
|
+
# Phase 2 scope:
|
|
10
|
+
# - Single function-declaration component per file.
|
|
11
|
+
# - JSX elements with lowercase tags lower to IR::Element; others to
|
|
12
|
+
# IR::ComponentInvocation.
|
|
13
|
+
# - className attributes lower to IR::StyleBinding; everything else
|
|
14
|
+
# to IR::Attribute (event handlers like onClick are passed through
|
|
15
|
+
# as Attribute for now and will be re-lowered to EventBinding in
|
|
16
|
+
# a later phase).
|
|
17
|
+
# - JS expressions are preserved as opaque source text via
|
|
18
|
+
# IR::Interpolation. No JS-to-Ruby translation.
|
|
19
|
+
# - Pure-whitespace JSXText between elements is dropped (matches
|
|
20
|
+
# JSX runtime behavior); other text is preserved verbatim.
|
|
21
|
+
#
|
|
22
|
+
# Phase 4a additions:
|
|
23
|
+
# - {children} where `children` is a prop lowers to IR::Slot.
|
|
24
|
+
# - {cond && X}, {cond ? X : null}, and {cond ? X : Y} lower to
|
|
25
|
+
# IR::Conditional. Other LogicalExpression operators (||, ??) are
|
|
26
|
+
# left as opaque interpolations.
|
|
27
|
+
class Lowering
|
|
28
|
+
# A failure during AST → IR lowering. Carries optional line/column
|
|
29
|
+
# information when the failure can be tied to an AST node.
|
|
30
|
+
class LoweringError < JsxRosetta::Error
|
|
31
|
+
attr_reader :line, :column
|
|
32
|
+
|
|
33
|
+
def initialize(message, node: nil, source: nil)
|
|
34
|
+
@line = nil
|
|
35
|
+
@column = nil
|
|
36
|
+
|
|
37
|
+
if node && source && node.start_pos
|
|
38
|
+
@line, @column = compute_line_column(source, node.start_pos)
|
|
39
|
+
message = "#{message} (at line #{@line}, column #{@column})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
super(message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def compute_line_column(source, position)
|
|
48
|
+
prefix = source[0...position] || ""
|
|
49
|
+
line = prefix.count("\n") + 1
|
|
50
|
+
last_newline = prefix.rindex("\n")
|
|
51
|
+
column = last_newline ? position - last_newline - 1 : position
|
|
52
|
+
[line, column + 1]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.lower(file, source:)
|
|
57
|
+
new(source).lower_file(file)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.lower_all(file, source:)
|
|
61
|
+
new(source).lower_all_components(file)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
REACT_HOOKS = %w[
|
|
65
|
+
useState useEffect useRef useContext useMemo useCallback
|
|
66
|
+
useReducer useImperativeHandle useLayoutEffect useDebugValue
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
def initialize(source)
|
|
70
|
+
@source = source
|
|
71
|
+
@prop_names = []
|
|
72
|
+
@local_jsx = {}
|
|
73
|
+
@local_bindings = []
|
|
74
|
+
@local_arrows = {}
|
|
75
|
+
@local_polymorphic_tags = {}
|
|
76
|
+
@stimulus_methods = []
|
|
77
|
+
@stimulus_seen_names = {}
|
|
78
|
+
@react_hooks = []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def lower_file(file)
|
|
82
|
+
candidates = find_component_functions(file.program)
|
|
83
|
+
raise lowering_error("no component function found in module") if candidates.empty?
|
|
84
|
+
|
|
85
|
+
name, function = candidates.first
|
|
86
|
+
lower_component(name, function)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def lower_all_components(file)
|
|
90
|
+
candidates = find_component_functions(file.program)
|
|
91
|
+
raise lowering_error("no component function found in module") if candidates.empty?
|
|
92
|
+
|
|
93
|
+
candidates.map { |name, function| lower_component(name, function) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def lowering_error(message, node: nil)
|
|
99
|
+
LoweringError.new(message, node: node, source: @source)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def find_component_functions(program)
|
|
103
|
+
program.body.flat_map { |stmt| extract_components(stmt) }
|
|
104
|
+
.compact
|
|
105
|
+
.select { |(name, _)| component_name?(name) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# React convention: components are PascalCase, hooks are camelCase
|
|
109
|
+
# starting with `use`, plain helpers are lowercase. Only PascalCase
|
|
110
|
+
# names are treated as components.
|
|
111
|
+
def component_name?(name)
|
|
112
|
+
return false if name.nil? || name.empty?
|
|
113
|
+
|
|
114
|
+
first = name[0]
|
|
115
|
+
first == first.upcase && first != first.downcase
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_components(stmt)
|
|
119
|
+
case stmt.type
|
|
120
|
+
when "FunctionDeclaration"
|
|
121
|
+
[[stmt[:id]&.[](:name), stmt]]
|
|
122
|
+
when "VariableDeclaration"
|
|
123
|
+
extract_arrow_components(stmt)
|
|
124
|
+
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
125
|
+
extract_exported_components(stmt[:declaration])
|
|
126
|
+
else
|
|
127
|
+
[]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extract_exported_components(declaration)
|
|
132
|
+
return [] unless declaration.is_a?(AST::Node)
|
|
133
|
+
|
|
134
|
+
case declaration.type
|
|
135
|
+
when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
|
|
136
|
+
when "VariableDeclaration" then extract_arrow_components(declaration)
|
|
137
|
+
else []
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def extract_arrow_components(variable_declaration)
|
|
142
|
+
variable_declaration[:declarations].filter_map do |declarator|
|
|
143
|
+
init = declarator[:init]
|
|
144
|
+
next nil unless init.is_a?(AST::Node)
|
|
145
|
+
next nil unless %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
|
|
146
|
+
|
|
147
|
+
name = declarator[:id]&.[](:name)
|
|
148
|
+
name ? [name, init] : nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def lower_component(name, function)
|
|
153
|
+
if name.nil? || name.empty?
|
|
154
|
+
raise lowering_error("anonymous component functions are not supported", node: function)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
props, rest_prop_name = lower_params(function[:params])
|
|
158
|
+
@prop_names = props.map(&:name)
|
|
159
|
+
@local_bindings = []
|
|
160
|
+
@local_arrows = {}
|
|
161
|
+
@local_polymorphic_tags = {}
|
|
162
|
+
@stimulus_methods = []
|
|
163
|
+
@stimulus_seen_names = {}
|
|
164
|
+
@react_hooks = []
|
|
165
|
+
|
|
166
|
+
body = lower_function_body(function[:body])
|
|
167
|
+
|
|
168
|
+
Component.new(
|
|
169
|
+
name: name,
|
|
170
|
+
props: props,
|
|
171
|
+
body: body,
|
|
172
|
+
rest_prop_name: rest_prop_name,
|
|
173
|
+
local_bindings: @local_bindings,
|
|
174
|
+
stimulus_methods: @stimulus_methods,
|
|
175
|
+
react_hooks: @react_hooks
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def lower_params(params)
|
|
180
|
+
return [[], nil] if params.nil? || params.empty?
|
|
181
|
+
|
|
182
|
+
first_param = params.first
|
|
183
|
+
case first_param.type
|
|
184
|
+
when "ObjectPattern"
|
|
185
|
+
lower_object_pattern_params(first_param)
|
|
186
|
+
when "Identifier"
|
|
187
|
+
[[Prop.new(name: first_param[:name], default: nil)], nil]
|
|
188
|
+
else
|
|
189
|
+
raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def lower_object_pattern_params(pattern)
|
|
194
|
+
props = []
|
|
195
|
+
rest_name = nil
|
|
196
|
+
pattern[:properties].each do |property|
|
|
197
|
+
case property.type
|
|
198
|
+
when "ObjectProperty"
|
|
199
|
+
props << lower_object_prop(property)
|
|
200
|
+
when "RestElement"
|
|
201
|
+
argument = property[:argument]
|
|
202
|
+
rest_name = argument.type == "Identifier" ? argument[:name] : source_of(argument)
|
|
203
|
+
else
|
|
204
|
+
raise lowering_error("unsupported prop pattern: #{property.type}", node: property)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
[props, rest_name]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def lower_object_prop(property)
|
|
211
|
+
value = property[:value]
|
|
212
|
+
if value.type == "AssignmentPattern"
|
|
213
|
+
Prop.new(
|
|
214
|
+
name: value[:left][:name],
|
|
215
|
+
default: Interpolation.new(expression: source_of(value[:right]))
|
|
216
|
+
)
|
|
217
|
+
else
|
|
218
|
+
Prop.new(name: value[:name], default: nil)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def lower_function_body(body)
|
|
223
|
+
case body.type
|
|
224
|
+
when "BlockStatement"
|
|
225
|
+
collect_local_bindings(body[:body])
|
|
226
|
+
return_stmt = body[:body].find { |stmt| stmt.type == "ReturnStatement" }
|
|
227
|
+
raise lowering_error("component function has no return statement", node: body) unless return_stmt
|
|
228
|
+
|
|
229
|
+
lower_jsx(return_stmt[:argument])
|
|
230
|
+
when "JSXElement", "JSXFragment"
|
|
231
|
+
@local_jsx = {}
|
|
232
|
+
lower_jsx(body)
|
|
233
|
+
else
|
|
234
|
+
raise lowering_error("unsupported component body: #{body.type}", node: body)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def collect_local_bindings(statements)
|
|
239
|
+
@local_jsx = {}
|
|
240
|
+
@local_arrows = {}
|
|
241
|
+
@local_polymorphic_tags = {}
|
|
242
|
+
seen_other_stmts = {}
|
|
243
|
+
|
|
244
|
+
statements.each do |stmt|
|
|
245
|
+
case stmt.type
|
|
246
|
+
when "VariableDeclaration"
|
|
247
|
+
stmt[:declarations].each { |declarator| classify_local_binding(stmt, declarator, seen_other_stmts) }
|
|
248
|
+
when "ExpressionStatement"
|
|
249
|
+
detect_bare_hook_call(stmt)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def classify_local_binding(stmt, declarator, seen)
|
|
255
|
+
init = declarator[:init]
|
|
256
|
+
return unless init.is_a?(AST::Node)
|
|
257
|
+
|
|
258
|
+
if hook_call?(init)
|
|
259
|
+
@react_hooks << ReactHookCall.new(hook: init[:callee][:name], source: source_of(stmt).strip)
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
name = declarator[:id]&.[](:name)
|
|
264
|
+
return unless name
|
|
265
|
+
|
|
266
|
+
case init.type
|
|
267
|
+
when "JSXElement", "JSXFragment"
|
|
268
|
+
@local_jsx[name] = init
|
|
269
|
+
when "ArrowFunctionExpression", "FunctionExpression"
|
|
270
|
+
@local_arrows[name] = init
|
|
271
|
+
when "ConditionalExpression"
|
|
272
|
+
poly = lower_polymorphic_tag(init)
|
|
273
|
+
poly ? (@local_polymorphic_tags[name] = poly) : record_local_other_binding(stmt, name, seen)
|
|
274
|
+
else
|
|
275
|
+
record_local_other_binding(stmt, name, seen)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def detect_bare_hook_call(stmt)
|
|
280
|
+
expr = stmt[:expression]
|
|
281
|
+
return unless expr.is_a?(AST::Node) && expr.type == "CallExpression"
|
|
282
|
+
return unless hook_call?(expr)
|
|
283
|
+
|
|
284
|
+
@react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def hook_call?(call_expression)
|
|
288
|
+
return false unless call_expression.type == "CallExpression"
|
|
289
|
+
|
|
290
|
+
callee = call_expression[:callee]
|
|
291
|
+
callee.is_a?(AST::Node) && callee.type == "Identifier" && REACT_HOOKS.include?(callee[:name])
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Recognize the asChild-style polymorphic tag pattern:
|
|
295
|
+
# const Comp = condition ? <BranchA> : <BranchB>;
|
|
296
|
+
# where each branch is a JSX-renderable thing — a string-literal HTML
|
|
297
|
+
# tag name (`"button"`), an Identifier (`Slot`), or a MemberExpression
|
|
298
|
+
# (`Slot.Root`). Returns nil when the shape isn't recognized so the
|
|
299
|
+
# caller can fall back to the verbatim TODO-comment behavior.
|
|
300
|
+
def lower_polymorphic_tag(conditional)
|
|
301
|
+
true_branch = polymorphic_tag_branch(conditional[:consequent])
|
|
302
|
+
false_branch = polymorphic_tag_branch(conditional[:alternate])
|
|
303
|
+
return nil unless true_branch && false_branch
|
|
304
|
+
|
|
305
|
+
{ test: conditional[:test], true_branch: true_branch, false_branch: false_branch }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def polymorphic_tag_branch(node)
|
|
309
|
+
case node.type
|
|
310
|
+
when "StringLiteral" then { kind: :element, tag: node[:value] }
|
|
311
|
+
when "Identifier" then { kind: :component, tag: node[:name] }
|
|
312
|
+
when "MemberExpression" then { kind: :component, tag: source_of(node) }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def record_local_other_binding(stmt, name, seen)
|
|
317
|
+
seen[stmt.start_pos] ||= source_of(stmt).strip
|
|
318
|
+
@local_bindings << LocalBinding.new(name: name, source: seen[stmt.start_pos])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def lower_jsx(node)
|
|
322
|
+
case node
|
|
323
|
+
when AST::JSXElement then lower_jsx_element(node)
|
|
324
|
+
when AST::JSXFragment then lower_jsx_fragment(node)
|
|
325
|
+
when AST::JSXText then lower_jsx_text(node)
|
|
326
|
+
when AST::JSXExpressionContainer then lower_jsx_expression(node)
|
|
327
|
+
else
|
|
328
|
+
raise lowering_error("unexpected JSX node in lowering: #{node.type}", node: node)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def lower_jsx_element(element)
|
|
333
|
+
tag = element.tag_name
|
|
334
|
+
attributes = element.opening_element.attributes.filter_map { |attr| lower_attribute(attr) }
|
|
335
|
+
# `key` is a React-only reconciliation hint; never emit it to the DOM
|
|
336
|
+
# or to ViewComponent invocations.
|
|
337
|
+
attributes = attributes.reject { |attr| attr.is_a?(Attribute) && attr.name == "key" }
|
|
338
|
+
children = lower_children(element.jsx_children)
|
|
339
|
+
|
|
340
|
+
if (poly = @local_polymorphic_tags[tag])
|
|
341
|
+
lower_polymorphic_tag_use(poly, attributes, children)
|
|
342
|
+
elsif html_element?(tag)
|
|
343
|
+
Element.new(tag: tag, attributes: attributes, children: children)
|
|
344
|
+
else
|
|
345
|
+
ComponentInvocation.new(name: tag, props: attributes, children: children)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def lower_polymorphic_tag_use(poly, attributes, children)
|
|
350
|
+
Conditional.new(
|
|
351
|
+
test: Interpolation.new(expression: source_of(poly[:test])),
|
|
352
|
+
consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
|
|
353
|
+
alternate: build_polymorphic_branch(poly[:false_branch], attributes, children)
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def build_polymorphic_branch(branch, attributes, children)
|
|
358
|
+
case branch[:kind]
|
|
359
|
+
when :element
|
|
360
|
+
Element.new(tag: branch[:tag], attributes: attributes, children: children)
|
|
361
|
+
when :component
|
|
362
|
+
ComponentInvocation.new(name: branch[:tag], props: attributes, children: children)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def lower_jsx_fragment(fragment)
|
|
367
|
+
Fragment.new(children: lower_children(fragment.jsx_children))
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def lower_children(children)
|
|
371
|
+
children.filter_map do |child|
|
|
372
|
+
case child
|
|
373
|
+
when AST::JSXText
|
|
374
|
+
lower_jsx_text(child)
|
|
375
|
+
else
|
|
376
|
+
lower_jsx(child)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def lower_jsx_text(node)
|
|
382
|
+
value = normalize_jsx_text(node.value)
|
|
383
|
+
return nil if value.empty?
|
|
384
|
+
|
|
385
|
+
Text.new(value: value)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Apply JSX whitespace rules (matching Babel's cleanJSXElementLiteralChild):
|
|
389
|
+
# - tabs are converted to spaces
|
|
390
|
+
# - leading whitespace on every line except the first is stripped
|
|
391
|
+
# - trailing whitespace on every line except the last is stripped
|
|
392
|
+
# - non-empty lines are joined; each non-final non-empty line gets a
|
|
393
|
+
# trailing space appended
|
|
394
|
+
# - all-whitespace text becomes empty (caller drops it)
|
|
395
|
+
def normalize_jsx_text(value)
|
|
396
|
+
lines = value.split(/\r\n|\n|\r/)
|
|
397
|
+
last_non_empty = nil
|
|
398
|
+
lines.each_with_index { |line, i| last_non_empty = i if line.match?(/[^ \t]/) }
|
|
399
|
+
return "" if last_non_empty.nil?
|
|
400
|
+
|
|
401
|
+
result = String.new
|
|
402
|
+
lines.each_with_index do |line, i|
|
|
403
|
+
trimmed = line.tr("\t", " ")
|
|
404
|
+
trimmed = trimmed.sub(/\A +/, "") unless i.zero?
|
|
405
|
+
trimmed = trimmed.sub(/ +\z/, "") unless i == lines.length - 1
|
|
406
|
+
next if trimmed.empty?
|
|
407
|
+
|
|
408
|
+
trimmed += " " unless i == last_non_empty
|
|
409
|
+
result << trimmed
|
|
410
|
+
end
|
|
411
|
+
result
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def lower_jsx_expression(node)
|
|
415
|
+
expression = node.expression
|
|
416
|
+
return lower_jsx_comment(expression) if expression.is_a?(AST::JSXEmptyExpression)
|
|
417
|
+
|
|
418
|
+
case expression.type
|
|
419
|
+
when "StringLiteral" then Text.new(value: expression[:value])
|
|
420
|
+
when "NumericLiteral" then Text.new(value: expression[:value].to_s)
|
|
421
|
+
when "BooleanLiteral", "NullLiteral" then nil
|
|
422
|
+
when "LogicalExpression" then lower_logical_expression(expression)
|
|
423
|
+
when "ConditionalExpression" then lower_ternary_expression(expression)
|
|
424
|
+
when "Identifier" then lower_identifier_expression(expression)
|
|
425
|
+
when "CallExpression" then lower_call_expression(expression)
|
|
426
|
+
else
|
|
427
|
+
Interpolation.new(expression: source_of(expression))
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def lower_jsx_comment(empty_expression)
|
|
432
|
+
comments = empty_expression.raw["innerComments"]
|
|
433
|
+
return nil if comments.nil? || comments.empty?
|
|
434
|
+
|
|
435
|
+
Comment.new(text: comments.map { |c| c["value"] }.join("\n").strip)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def lower_call_expression(expression)
|
|
439
|
+
loop_node = try_lower_map_loop(expression)
|
|
440
|
+
loop_node || Interpolation.new(expression: source_of(expression))
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
444
|
+
def try_lower_map_loop(call_expression)
|
|
445
|
+
callee = call_expression[:callee]
|
|
446
|
+
return nil unless callee.is_a?(AST::Node) && callee.type == "MemberExpression"
|
|
447
|
+
return nil unless callee[:property].is_a?(AST::Node) && callee[:property][:name] == "map"
|
|
448
|
+
|
|
449
|
+
args = call_expression[:arguments]
|
|
450
|
+
return nil if args.size != 1
|
|
451
|
+
return nil unless args.first.type == "ArrowFunctionExpression"
|
|
452
|
+
|
|
453
|
+
arrow = args.first
|
|
454
|
+
params = arrow[:params]
|
|
455
|
+
return nil if params.empty? || params.size > 2
|
|
456
|
+
return nil unless params.all? { |p| p.is_a?(AST::Node) && p.type == "Identifier" }
|
|
457
|
+
|
|
458
|
+
body = lower_arrow_body(arrow[:body])
|
|
459
|
+
return nil unless body
|
|
460
|
+
|
|
461
|
+
Loop.new(
|
|
462
|
+
iterable: Interpolation.new(expression: source_of(callee[:object])),
|
|
463
|
+
item_binding: params[0][:name],
|
|
464
|
+
index_binding: params[1] && params[1][:name],
|
|
465
|
+
body: body
|
|
466
|
+
)
|
|
467
|
+
end
|
|
468
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
469
|
+
|
|
470
|
+
def lower_arrow_body(body)
|
|
471
|
+
case body.type
|
|
472
|
+
when "JSXElement", "JSXFragment"
|
|
473
|
+
lower_jsx(body)
|
|
474
|
+
when "BlockStatement"
|
|
475
|
+
return_stmt = body[:body].find { |s| s.type == "ReturnStatement" }
|
|
476
|
+
return nil unless return_stmt
|
|
477
|
+
|
|
478
|
+
arg = return_stmt[:argument]
|
|
479
|
+
return nil unless %w[JSXElement JSXFragment].include?(arg&.type)
|
|
480
|
+
|
|
481
|
+
lower_jsx(arg)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def lower_logical_expression(expr)
|
|
486
|
+
if expr[:operator] == "&&"
|
|
487
|
+
Conditional.new(
|
|
488
|
+
test: Interpolation.new(expression: source_of(expr[:left])),
|
|
489
|
+
consequent: lower_jsx_or_value(expr[:right]),
|
|
490
|
+
alternate: nil
|
|
491
|
+
)
|
|
492
|
+
else
|
|
493
|
+
Interpolation.new(expression: source_of(expr))
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def lower_ternary_expression(expr)
|
|
498
|
+
alternate_node = expr[:alternate]
|
|
499
|
+
alternate = alternate_node.type == "NullLiteral" ? nil : lower_jsx_or_value(alternate_node)
|
|
500
|
+
|
|
501
|
+
Conditional.new(
|
|
502
|
+
test: Interpolation.new(expression: source_of(expr[:test])),
|
|
503
|
+
consequent: lower_jsx_or_value(expr[:consequent]),
|
|
504
|
+
alternate: alternate
|
|
505
|
+
)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def lower_identifier_expression(identifier)
|
|
509
|
+
name = identifier[:name]
|
|
510
|
+
if name == "children" && @prop_names.include?("children")
|
|
511
|
+
Slot.new(name: "children")
|
|
512
|
+
elsif (jsx = @local_jsx[name])
|
|
513
|
+
lower_jsx(jsx)
|
|
514
|
+
else
|
|
515
|
+
Interpolation.new(expression: name)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def lower_jsx_or_value(node)
|
|
520
|
+
case node.type
|
|
521
|
+
when "JSXElement", "JSXFragment"
|
|
522
|
+
lower_jsx(node)
|
|
523
|
+
when "Identifier"
|
|
524
|
+
jsx = @local_jsx[node[:name]]
|
|
525
|
+
jsx ? lower_jsx(jsx) : Interpolation.new(expression: source_of(node))
|
|
526
|
+
else
|
|
527
|
+
Interpolation.new(expression: source_of(node))
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def lower_attribute(attr)
|
|
532
|
+
case attr
|
|
533
|
+
when AST::JSXAttribute
|
|
534
|
+
lower_jsx_attribute(attr)
|
|
535
|
+
when AST::JSXSpreadAttribute
|
|
536
|
+
SpreadAttribute.new(expression: source_of(attr.argument))
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def lower_jsx_attribute(attr)
|
|
541
|
+
name = attr.attribute_name
|
|
542
|
+
|
|
543
|
+
return lower_class_name(attr.value) if name == "className"
|
|
544
|
+
return lower_style_attribute_or_fallback(attr.value) if name == "style"
|
|
545
|
+
if event_attribute?(name) && attr.value.is_a?(AST::JSXExpressionContainer)
|
|
546
|
+
return lower_event_attribute(name, attr.value)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
Attribute.new(name: name, value: lower_attribute_value(attr.value))
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def lower_style_attribute_or_fallback(value)
|
|
553
|
+
lower_style_attribute(value) || Attribute.new(name: "style", value: lower_attribute_value(value))
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def lower_style_attribute(value)
|
|
557
|
+
return nil unless value.is_a?(AST::JSXExpressionContainer)
|
|
558
|
+
|
|
559
|
+
expression = value.expression
|
|
560
|
+
return nil unless expression.is_a?(AST::Node) && expression.type == "ObjectExpression"
|
|
561
|
+
|
|
562
|
+
declarations = expression[:properties].map { |prop| lower_style_property(prop) }
|
|
563
|
+
return nil if declarations.any?(&:nil?)
|
|
564
|
+
|
|
565
|
+
Style.new(declarations: declarations)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def lower_style_property(property)
|
|
569
|
+
return nil unless property.type == "ObjectProperty"
|
|
570
|
+
|
|
571
|
+
property_name =
|
|
572
|
+
case property[:key].type
|
|
573
|
+
when "Identifier" then css_property_from_camel(property[:key][:name])
|
|
574
|
+
when "StringLiteral" then property[:key][:value]
|
|
575
|
+
end
|
|
576
|
+
return nil if property_name.nil?
|
|
577
|
+
|
|
578
|
+
value = lower_style_value(property[:value])
|
|
579
|
+
return nil if value.nil?
|
|
580
|
+
|
|
581
|
+
StyleDeclaration.new(property: property_name, value: value)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def lower_style_value(value)
|
|
585
|
+
case value.type
|
|
586
|
+
when "StringLiteral" then value[:value]
|
|
587
|
+
when "NumericLiteral" then value[:value].to_s
|
|
588
|
+
when "Identifier", "MemberExpression"
|
|
589
|
+
Interpolation.new(expression: source_of(value))
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def css_property_from_camel(name)
|
|
594
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def lower_class_name(value)
|
|
598
|
+
if value.is_a?(AST::JSXExpressionContainer)
|
|
599
|
+
decomposed = try_lower_class_helper(value.expression)
|
|
600
|
+
return decomposed if decomposed
|
|
601
|
+
end
|
|
602
|
+
StyleBinding.new(expression: style_binding_expression(value))
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def try_lower_class_helper(expression)
|
|
606
|
+
return nil unless expression.is_a?(AST::Node) && expression.type == "CallExpression"
|
|
607
|
+
|
|
608
|
+
callee = expression[:callee]
|
|
609
|
+
return nil unless callee.is_a?(AST::Node) && callee.type == "Identifier"
|
|
610
|
+
return nil unless %w[cn clsx classnames].include?(callee[:name])
|
|
611
|
+
|
|
612
|
+
segments = expression[:arguments].flat_map { |arg| lower_class_helper_arg(arg) }
|
|
613
|
+
return nil if segments.any?(&:nil?)
|
|
614
|
+
|
|
615
|
+
ClassList.new(segments: segments)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def lower_class_helper_arg(arg)
|
|
619
|
+
case arg.type
|
|
620
|
+
when "StringLiteral" then arg[:value]
|
|
621
|
+
when "Identifier", "MemberExpression" then Interpolation.new(expression: source_of(arg))
|
|
622
|
+
when "ObjectExpression" then lower_class_helper_object(arg)
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def lower_class_helper_object(object_expression)
|
|
627
|
+
object_expression[:properties].map do |prop|
|
|
628
|
+
break [nil] unless prop.type == "ObjectProperty"
|
|
629
|
+
|
|
630
|
+
class_name =
|
|
631
|
+
case prop[:key].type
|
|
632
|
+
when "StringLiteral" then prop[:key][:value]
|
|
633
|
+
when "Identifier" then prop[:key][:name]
|
|
634
|
+
end
|
|
635
|
+
break [nil] if class_name.nil?
|
|
636
|
+
|
|
637
|
+
ConditionalSegment.new(
|
|
638
|
+
class_name: class_name,
|
|
639
|
+
condition: Interpolation.new(expression: source_of(prop[:value]))
|
|
640
|
+
)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def event_attribute?(name)
|
|
645
|
+
name.match?(/\Aon[A-Z]\w*\z/)
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def lower_event_attribute(name, value)
|
|
649
|
+
event = name.sub(/\Aon/, "").downcase
|
|
650
|
+
expression = value.expression
|
|
651
|
+
|
|
652
|
+
stimulus = try_promote_to_stimulus(name, event, expression)
|
|
653
|
+
return stimulus if stimulus
|
|
654
|
+
|
|
655
|
+
EventBinding.new(
|
|
656
|
+
event: event,
|
|
657
|
+
handler: Interpolation.new(expression: source_of(expression))
|
|
658
|
+
)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def try_promote_to_stimulus(attr_name, event, expression)
|
|
662
|
+
arrow_node, name_hint = stimulus_arrow_for(expression)
|
|
663
|
+
return nil unless arrow_node
|
|
664
|
+
|
|
665
|
+
method_name = stimulus_method_name(name_hint || default_stimulus_method_name(attr_name))
|
|
666
|
+
body_source = source_of(arrow_node[:body])
|
|
667
|
+
@stimulus_methods << StimulusMethod.new(name: method_name, body_source: body_source)
|
|
668
|
+
@local_arrows.delete(name_hint) if name_hint
|
|
669
|
+
|
|
670
|
+
StimulusBinding.new(event: event, method_name: method_name)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def stimulus_arrow_for(expression)
|
|
674
|
+
case expression.type
|
|
675
|
+
when "ArrowFunctionExpression", "FunctionExpression"
|
|
676
|
+
[expression, nil]
|
|
677
|
+
when "Identifier"
|
|
678
|
+
arrow = @local_arrows[expression[:name]]
|
|
679
|
+
arrow ? [arrow, expression[:name]] : nil
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def default_stimulus_method_name(attr_name)
|
|
684
|
+
# `onClick` → `clickHandler`
|
|
685
|
+
event = attr_name.sub(/\Aon/, "")
|
|
686
|
+
"#{event[0].downcase}#{event[1..]}Handler"
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def stimulus_method_name(base)
|
|
690
|
+
@stimulus_seen_names[base] ||= 0
|
|
691
|
+
@stimulus_seen_names[base] += 1
|
|
692
|
+
@stimulus_seen_names[base] == 1 ? base : "#{base}#{@stimulus_seen_names[base]}"
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def lower_attribute_value(value)
|
|
696
|
+
case value
|
|
697
|
+
when nil
|
|
698
|
+
true
|
|
699
|
+
when AST::JSXExpressionContainer
|
|
700
|
+
Interpolation.new(expression: source_of(value.expression))
|
|
701
|
+
else
|
|
702
|
+
value.raw["value"]
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def style_binding_expression(value)
|
|
707
|
+
case value
|
|
708
|
+
when nil then "true"
|
|
709
|
+
when AST::JSXExpressionContainer then source_of(value.expression)
|
|
710
|
+
else source_of(value)
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def html_element?(tag)
|
|
715
|
+
return false if tag.nil? || tag.empty?
|
|
716
|
+
return false if tag.include?(".")
|
|
717
|
+
|
|
718
|
+
first = tag[0]
|
|
719
|
+
first == first.downcase
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def source_of(node)
|
|
723
|
+
@source[node.start_pos...node.end_pos]
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
end
|