jsx_rosetta 0.3.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.
@@ -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
@@ -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, :stimulus_methods, :react_hooks) do
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 : String — camelCase Stimulus method name.
254
- # body_source : String verbatim JS body (the entire arrow function or
255
- # the function expression body), preserved as a comment in
256
- # the emitted controller skeleton.
257
- StimulusMethod = Data.define(:name, :body_source) do
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsxRosetta
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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, helpers: nil, layout: :sidecar,
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, helpers: helpers, layout: layout)
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, helpers: nil, layout: :sidecar)
25
+ def self.backend_for(name, **options)
26
26
  case name
27
- when :view_component then Backend::ViewComponent.new(helpers: helpers, layout: layout)
28
- when :rails_view then Backend::RailsView.new(helpers: helpers, layout: layout)
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.3.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