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,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast/node"
|
|
4
|
+
|
|
5
|
+
module JsxRosetta
|
|
6
|
+
module IR
|
|
7
|
+
# Heuristic classifier that labels a module whose top-level shape
|
|
8
|
+
# isn't a function component. Used by Lowering::no_component_error
|
|
9
|
+
# to produce triage-friendly messages explaining *why* a file didn't
|
|
10
|
+
# translate. Pure function: program in, label symbol out — no
|
|
11
|
+
# mutable state, no relationship to the rest of the lowering
|
|
12
|
+
# pipeline.
|
|
13
|
+
#
|
|
14
|
+
# Labels (in priority order — more specific shapes win):
|
|
15
|
+
# :class_component `class X extends React.Component { ... }`
|
|
16
|
+
# :hoc_wrapped `const X = React.memo(...)` etc.
|
|
17
|
+
# :columns_data top-level array literal export
|
|
18
|
+
# :hooks_only every export is a `use*` hook
|
|
19
|
+
# :utils_only every export is a lowercase non-hook helper
|
|
20
|
+
# :mixed_exports mixes hooks + non-hook lowercase exports
|
|
21
|
+
# :side_effects_only top-level expression statements, no exports
|
|
22
|
+
# :types_only types/constants only, no functions
|
|
23
|
+
# :unknown no signal — caller emits the bare error
|
|
24
|
+
class ModuleShapeClassifier
|
|
25
|
+
EXPORT_TYPES = %w[ExportNamedDeclaration ExportDefaultDeclaration].freeze
|
|
26
|
+
HOC_NAMES = %w[memo forwardRef lazy observer].freeze
|
|
27
|
+
|
|
28
|
+
def self.classify(program)
|
|
29
|
+
new(program).classify
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(program)
|
|
33
|
+
@program = program
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def classify
|
|
37
|
+
ast_shape = classify_ast_shape
|
|
38
|
+
return ast_shape if ast_shape
|
|
39
|
+
|
|
40
|
+
classify_by_export_names(top_level_export_names)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def classify_ast_shape
|
|
46
|
+
return :class_component if @program.body.any? { |stmt| class_component?(stmt) }
|
|
47
|
+
return :hoc_wrapped if @program.body.any? { |stmt| hoc_wrapped_export?(stmt) }
|
|
48
|
+
return :columns_data if @program.body.any? { |stmt| array_literal_export?(stmt) }
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def classify_by_export_names(names)
|
|
54
|
+
export_label = classify_by_export_pattern(names)
|
|
55
|
+
return export_label if export_label
|
|
56
|
+
|
|
57
|
+
classify_non_export_module
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def classify_by_export_pattern(names)
|
|
61
|
+
any_hooks = names.any? { |n| hook_name?(n) }
|
|
62
|
+
any_helpers = names.any? { |n| /\A[a-z]/.match?(n) && !hook_name?(n) }
|
|
63
|
+
return :mixed_exports if any_hooks && any_helpers
|
|
64
|
+
return :hooks_only if any_hooks
|
|
65
|
+
return :utils_only if any_helpers
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def classify_non_export_module
|
|
71
|
+
return :side_effects_only if @program.body.any? { |s| side_effect_statement?(s) }
|
|
72
|
+
return :types_only if top_level_has_anything?
|
|
73
|
+
|
|
74
|
+
:unknown
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def hook_name?(name)
|
|
78
|
+
name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def class_component?(stmt)
|
|
82
|
+
decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
|
|
83
|
+
AST::Node.matches?(decl, "ClassDeclaration")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Recognize `export const X = React.memo(...)` (export wrapper) or a
|
|
87
|
+
# top-level `const X = lazy(() => ...)` followed by `export default X`
|
|
88
|
+
# — a VariableDeclaration whose init is a CallExpression to a known HOC.
|
|
89
|
+
def hoc_wrapped_export?(stmt)
|
|
90
|
+
decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
|
|
91
|
+
return false unless AST::Node.matches?(decl, "VariableDeclaration")
|
|
92
|
+
|
|
93
|
+
decl[:declarations].any? do |d|
|
|
94
|
+
init = d[:init]
|
|
95
|
+
AST::Node.matches?(init, "CallExpression") && hoc_callee?(init[:callee])
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def hoc_callee?(callee)
|
|
100
|
+
return false unless callee.is_a?(AST::Node)
|
|
101
|
+
|
|
102
|
+
case callee.type
|
|
103
|
+
when "Identifier" then HOC_NAMES.include?(callee[:name])
|
|
104
|
+
when "MemberExpression"
|
|
105
|
+
property = callee.child(:property)
|
|
106
|
+
property&.of_type?("Identifier") && HOC_NAMES.include?(property[:name])
|
|
107
|
+
else false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def array_literal_export?(stmt)
|
|
112
|
+
return false unless stmt.of_type?(*EXPORT_TYPES)
|
|
113
|
+
|
|
114
|
+
decl = stmt[:declaration]
|
|
115
|
+
return true if AST::Node.matches?(decl, "ArrayExpression")
|
|
116
|
+
return false unless AST::Node.matches?(decl, "VariableDeclaration")
|
|
117
|
+
|
|
118
|
+
decl[:declarations].any? { |d| AST::Node.matches?(d[:init], "ArrayExpression") }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def side_effect_statement?(stmt)
|
|
122
|
+
AST::Node.matches?(stmt, "ExpressionStatement")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def top_level_has_anything?
|
|
126
|
+
@program.body.any? { |stmt| stmt.is_a?(AST::Node) && !stmt.of_type?("ImportDeclaration") }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def top_level_export_names
|
|
130
|
+
@program.body.flat_map { |stmt| extract_top_level_names(stmt) }.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def extract_top_level_names(stmt)
|
|
134
|
+
case stmt.type
|
|
135
|
+
when "FunctionDeclaration"
|
|
136
|
+
[stmt.child(:id)&.[](:name)]
|
|
137
|
+
when "VariableDeclaration"
|
|
138
|
+
stmt[:declarations].map { |d| AST::Node.matches?(d[:id], "Identifier") ? d[:id][:name] : nil }
|
|
139
|
+
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
140
|
+
decl = stmt.child(:declaration)
|
|
141
|
+
decl ? extract_top_level_names(decl) : []
|
|
142
|
+
else
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/jsx_rosetta/ir/types.rb
CHANGED
|
@@ -20,6 +20,15 @@ module JsxRosetta
|
|
|
20
20
|
# the component body. Backends typically render these as
|
|
21
21
|
# a TODO comment block since arbitrary JS-to-Ruby
|
|
22
22
|
# translation isn't attempted.
|
|
23
|
+
# local_binding_names : [String] — flat list of all names bound by the
|
|
24
|
+
# component body (destructure patterns, hook tuples,
|
|
25
|
+
# ordinary const bindings). Backends pass this into the
|
|
26
|
+
# ExpressionTranslator so an identifier reference like
|
|
27
|
+
# `count` resolves to a `nil` placeholder instead of
|
|
28
|
+
# a bare unresolved snake_case identifier that NameErrors
|
|
29
|
+
# at render time. Includes hook destructures (e.g.
|
|
30
|
+
# `open` and `setOpen` from `useState`) even though
|
|
31
|
+
# those names don't appear in `local_bindings`.
|
|
23
32
|
# stimulus_methods : [StimulusMethod] — event handlers extracted from
|
|
24
33
|
# inline arrows / const-bound arrows used in onX={...}.
|
|
25
34
|
# When non-empty, backends should emit a sibling
|
|
@@ -30,8 +39,18 @@ module JsxRosetta
|
|
|
30
39
|
# Surfaced as a distinct TODO block so the human
|
|
31
40
|
# reviewer knows to translate behavior to Stimulus
|
|
32
41
|
# and state to server-side rendering.
|
|
42
|
+
# module_bindings : [LocalBinding] — top-level `const`/`let` declarations
|
|
43
|
+
# outside the component function that aren't themselves
|
|
44
|
+
# components. Captured so backends can either translate
|
|
45
|
+
# to Ruby constants (literal initializers) or surface
|
|
46
|
+
# as a TODO comment block before the class definition.
|
|
47
|
+
# Without this capture, references to module-level
|
|
48
|
+
# constants from inside the JSX silently drop and
|
|
49
|
+
# produce unbacked snake_case references at render time.
|
|
33
50
|
Component = Data.define(:name, :props, :body, :rest_prop_name,
|
|
34
|
-
:local_bindings, :
|
|
51
|
+
:local_bindings, :local_binding_names,
|
|
52
|
+
:module_bindings,
|
|
53
|
+
:stimulus_methods, :react_hooks) do
|
|
35
54
|
include Node
|
|
36
55
|
end
|
|
37
56
|
|
|
@@ -250,11 +269,17 @@ module JsxRosetta
|
|
|
250
269
|
# Body translation is deferred to the human reviewer; we preserve the
|
|
251
270
|
# original JS body verbatim.
|
|
252
271
|
#
|
|
253
|
-
# name
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
|
|
272
|
+
# name : String — camelCase Stimulus method name (uniquified
|
|
273
|
+
# when two handlers collide on the same base name).
|
|
274
|
+
# body_source : String — verbatim JS body (the entire arrow function
|
|
275
|
+
# or the function expression body), preserved as a
|
|
276
|
+
# comment in the emitted controller skeleton.
|
|
277
|
+
# original_name : String — the requested base name before uniquification.
|
|
278
|
+
# Equals `name` when there was no collision. When
|
|
279
|
+
# `name != original_name`, backends emit a collision
|
|
280
|
+
# marker comment in the generated controller JS so the
|
|
281
|
+
# reviewer can see the silent rename.
|
|
282
|
+
StimulusMethod = Data.define(:name, :body_source, :original_name) do
|
|
258
283
|
include Node
|
|
259
284
|
end
|
|
260
285
|
|
|
@@ -272,5 +297,51 @@ module JsxRosetta
|
|
|
272
297
|
Loop = Data.define(:iterable, :item_binding, :index_binding, :body) do
|
|
273
298
|
include Node
|
|
274
299
|
end
|
|
300
|
+
|
|
301
|
+
# An object-literal value (`{ key: value, ... }`) inside JSX. Lowered
|
|
302
|
+
# from a JSX attribute or expression value whose root is an
|
|
303
|
+
# ObjectExpression. Each property's key is a String; the value can be
|
|
304
|
+
# any IR node (recursive). Backends render as a Ruby Hash literal,
|
|
305
|
+
# snake_casing identifier keys to match Ruby kwarg conventions.
|
|
306
|
+
#
|
|
307
|
+
# properties : [[String key, Node value]] — preserved in source order.
|
|
308
|
+
ObjectLiteral = Data.define(:properties) do
|
|
309
|
+
include Node
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# An array-literal value (`[a, b, ...]`) inside JSX. Lowered from a
|
|
313
|
+
# JSX attribute or expression value whose root is an ArrayExpression.
|
|
314
|
+
# Each element is an IR node (recursive). Backends render as a Ruby
|
|
315
|
+
# Array literal.
|
|
316
|
+
#
|
|
317
|
+
# elements : [Node]
|
|
318
|
+
ArrayLiteral = Data.define(:elements) do
|
|
319
|
+
include Node
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# An arrow/function expression appearing as an inline value (not in
|
|
323
|
+
# JSX child or event-handler position). Typical example: a `render`
|
|
324
|
+
# property inside an array-of-config-objects passed to an AG-Grid
|
|
325
|
+
# column descriptor or antd Select option. Backends emit as a Ruby
|
|
326
|
+
# method on the class (deterministically named) and reference it via
|
|
327
|
+
# `method(:name)` in the value position, since lambdas don't carry
|
|
328
|
+
# the Phlex execution context required to call tag.* helpers.
|
|
329
|
+
#
|
|
330
|
+
# params : [String]
|
|
331
|
+
# body : Node
|
|
332
|
+
Lambda = Data.define(:params, :body) do
|
|
333
|
+
include Node
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# A render-prop child: `<Form.List>{(fields) => <div>{fields}</div>}</Form.List>`.
|
|
337
|
+
# Backends emit this as a Ruby block on the render call, with the params
|
|
338
|
+
# bound as block arguments. Distinct from Loop (which iterates an
|
|
339
|
+
# iterable) and from Slot (which yields without args).
|
|
340
|
+
#
|
|
341
|
+
# params : [String] — param names (camelCase preserved; backends snake_case).
|
|
342
|
+
# body : Node — the lowered IR node produced by the arrow's body.
|
|
343
|
+
RenderProp = Data.define(:params, :body) do
|
|
344
|
+
include Node
|
|
345
|
+
end
|
|
275
346
|
end
|
|
276
347
|
end
|
data/lib/jsx_rosetta/version.rb
CHANGED
data/lib/jsx_rosetta.rb
CHANGED
|
@@ -14,18 +14,19 @@ module JsxRosetta
|
|
|
14
14
|
IR.lower(ast, source: source)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def self.translate(source, backend: :view_component,
|
|
18
|
-
typescript: false, source_filename: nil)
|
|
17
|
+
def self.translate(source, backend: :view_component, backend_options: {},
|
|
18
|
+
typescript: false, source_filename: nil, **legacy_options)
|
|
19
19
|
ast = parse(source, typescript: typescript, source_filename: source_filename)
|
|
20
20
|
components = IR.lower_all(ast, source: source)
|
|
21
|
-
backend_instance = backend_for(backend,
|
|
21
|
+
backend_instance = backend_for(backend, **legacy_options, **backend_options)
|
|
22
22
|
components.flat_map { |component| backend_instance.emit(component) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def self.backend_for(name,
|
|
25
|
+
def self.backend_for(name, **options)
|
|
26
26
|
case name
|
|
27
|
-
when :view_component then Backend::ViewComponent.new(
|
|
28
|
-
when :rails_view then Backend::RailsView.new(
|
|
27
|
+
when :view_component then Backend::ViewComponent.new(**options.slice(:helpers, :layout))
|
|
28
|
+
when :rails_view then Backend::RailsView.new(**options.slice(:helpers, :layout))
|
|
29
|
+
when :phlex then Backend::Phlex.new(**options.slice(:suffix, :namespace))
|
|
29
30
|
else
|
|
30
31
|
raise Error, "unknown backend: #{name.inspect}"
|
|
31
32
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jsx_rosetta
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sean McCleary
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- lib/jsx_rosetta/ast/visitor.rb
|
|
39
39
|
- lib/jsx_rosetta/backend.rb
|
|
40
40
|
- lib/jsx_rosetta/backend/base.rb
|
|
41
|
+
- lib/jsx_rosetta/backend/phlex.rb
|
|
41
42
|
- lib/jsx_rosetta/backend/rails_view.rb
|
|
42
43
|
- lib/jsx_rosetta/backend/routes_script.rb
|
|
43
44
|
- lib/jsx_rosetta/backend/view_component.rb
|
|
@@ -45,6 +46,7 @@ files:
|
|
|
45
46
|
- lib/jsx_rosetta/cli.rb
|
|
46
47
|
- lib/jsx_rosetta/ir.rb
|
|
47
48
|
- lib/jsx_rosetta/ir/lowering.rb
|
|
49
|
+
- lib/jsx_rosetta/ir/module_shape_classifier.rb
|
|
48
50
|
- lib/jsx_rosetta/ir/types.rb
|
|
49
51
|
- lib/jsx_rosetta/node_bridge.rb
|
|
50
52
|
- lib/jsx_rosetta/parse_error.rb
|