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.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node"
4
+
5
+ module JsxRosetta
6
+ module AST
7
+ # Subclasses for the Babel node types where named accessors carry their
8
+ # weight (mostly the JSX subtree). Other Babel types fall through to the
9
+ # generic Node class — they're still walkable, pattern-matchable, and
10
+ # field-addressable, just without bespoke methods.
11
+ #
12
+ # As lowering passes need more ergonomic access to specific node fields,
13
+ # add a class here. Don't pre-emptively wrap every Babel type.
14
+
15
+ class File < Node
16
+ register "File"
17
+
18
+ def program
19
+ self[:program]
20
+ end
21
+ end
22
+
23
+ class Program < Node
24
+ register "Program"
25
+
26
+ def body
27
+ self[:body]
28
+ end
29
+
30
+ def source_type
31
+ @raw["sourceType"]
32
+ end
33
+ end
34
+
35
+ class JSXElement < Node
36
+ register "JSXElement"
37
+
38
+ def opening_element
39
+ self[:opening_element]
40
+ end
41
+
42
+ def closing_element
43
+ self[:closing_element]
44
+ end
45
+
46
+ def jsx_children
47
+ self[:children]
48
+ end
49
+
50
+ def tag_name
51
+ opening_element&.tag_name
52
+ end
53
+
54
+ def self_closing?
55
+ opening_element&.self_closing? || false
56
+ end
57
+ end
58
+
59
+ class JSXOpeningElement < Node
60
+ register "JSXOpeningElement"
61
+
62
+ def name
63
+ self[:name]
64
+ end
65
+
66
+ def attributes
67
+ self[:attributes]
68
+ end
69
+
70
+ def tag_name
71
+ node_to_tag_name(name)
72
+ end
73
+
74
+ def self_closing?
75
+ @raw["selfClosing"] == true
76
+ end
77
+
78
+ private
79
+
80
+ def node_to_tag_name(node)
81
+ case node
82
+ when JSXIdentifier then node.name
83
+ when JSXMemberExpression, JSXNamespacedName then node.qualified_name
84
+ end
85
+ end
86
+ end
87
+
88
+ class JSXClosingElement < Node
89
+ register "JSXClosingElement"
90
+
91
+ def name
92
+ self[:name]
93
+ end
94
+ end
95
+
96
+ class JSXIdentifier < Node
97
+ register "JSXIdentifier"
98
+
99
+ def name
100
+ @raw["name"]
101
+ end
102
+ end
103
+
104
+ class JSXMemberExpression < Node
105
+ register "JSXMemberExpression"
106
+
107
+ def object
108
+ self[:object]
109
+ end
110
+
111
+ def property
112
+ self[:property]
113
+ end
114
+
115
+ def qualified_name
116
+ "#{object_name}.#{property.name}"
117
+ end
118
+
119
+ private
120
+
121
+ def object_name
122
+ case object
123
+ when JSXIdentifier then object.name
124
+ when JSXMemberExpression then object.qualified_name
125
+ end
126
+ end
127
+ end
128
+
129
+ class JSXNamespacedName < Node
130
+ register "JSXNamespacedName"
131
+
132
+ def namespace
133
+ self[:namespace]
134
+ end
135
+
136
+ def name
137
+ self[:name]
138
+ end
139
+
140
+ def qualified_name
141
+ "#{namespace.name}:#{name.name}"
142
+ end
143
+ end
144
+
145
+ class JSXAttribute < Node
146
+ register "JSXAttribute"
147
+
148
+ def name
149
+ self[:name]
150
+ end
151
+
152
+ def value
153
+ self[:value]
154
+ end
155
+
156
+ def attribute_name
157
+ case (attr_name = name)
158
+ when JSXIdentifier then attr_name.name
159
+ when JSXNamespacedName then attr_name.qualified_name
160
+ end
161
+ end
162
+ end
163
+
164
+ class JSXSpreadAttribute < Node
165
+ register "JSXSpreadAttribute"
166
+
167
+ def argument
168
+ self[:argument]
169
+ end
170
+ end
171
+
172
+ class JSXExpressionContainer < Node
173
+ register "JSXExpressionContainer"
174
+
175
+ def expression
176
+ self[:expression]
177
+ end
178
+ end
179
+
180
+ class JSXSpreadChild < Node
181
+ register "JSXSpreadChild"
182
+
183
+ def expression
184
+ self[:expression]
185
+ end
186
+ end
187
+
188
+ class JSXText < Node
189
+ register "JSXText"
190
+
191
+ def value
192
+ @raw["value"]
193
+ end
194
+ end
195
+
196
+ class JSXEmptyExpression < Node
197
+ register "JSXEmptyExpression"
198
+ end
199
+
200
+ class JSXFragment < Node
201
+ register "JSXFragment"
202
+
203
+ def opening_fragment
204
+ self[:opening_fragment]
205
+ end
206
+
207
+ def closing_fragment
208
+ self[:closing_fragment]
209
+ end
210
+
211
+ def jsx_children
212
+ self[:children]
213
+ end
214
+ end
215
+
216
+ class JSXOpeningFragment < Node
217
+ register "JSXOpeningFragment"
218
+ end
219
+
220
+ class JSXClosingFragment < Node
221
+ register "JSXClosingFragment"
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inflector"
4
+ require_relative "node"
5
+
6
+ module JsxRosetta
7
+ module AST
8
+ # Visitor base class. Subclasses define `visit_<snake_case_type>`
9
+ # methods to handle specific Babel node types. Anything without a
10
+ # dedicated handler falls through to `visit_default`, which by default
11
+ # recurses into the node's children.
12
+ #
13
+ # Example:
14
+ # class TagCollector < JsxRosetta::AST::Visitor
15
+ # attr_reader :tags
16
+ # def initialize; @tags = []; super; end
17
+ #
18
+ # def visit_jsx_element(node)
19
+ # @tags << node.tag_name
20
+ # visit_children(node)
21
+ # end
22
+ # end
23
+ #
24
+ # collector = TagCollector.new
25
+ # collector.visit(parsed_file)
26
+ class Visitor
27
+ def visit(node)
28
+ return unless node.is_a?(Node)
29
+
30
+ method_name = "visit_#{Inflector.underscore(node.type)}"
31
+ if respond_to?(method_name)
32
+ public_send(method_name, node)
33
+ else
34
+ visit_default(node)
35
+ end
36
+ end
37
+
38
+ def visit_default(node)
39
+ visit_children(node)
40
+ end
41
+
42
+ def visit_children(node)
43
+ node.each_child { |child| visit(child) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ast/inflector"
4
+ require_relative "ast/node"
5
+ require_relative "ast/types"
6
+ require_relative "ast/visitor"
7
+
8
+ module JsxRosetta
9
+ module AST
10
+ # Wrap a parsed Babel JSON tree (Hash) into typed AST::Node objects.
11
+ def self.build(json_hash)
12
+ Node.wrap(json_hash)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsxRosetta
4
+ module Backend
5
+ # Backends consume an IR::Component and emit one or more output files.
6
+ # The return value is an array of File value objects so callers can
7
+ # decide whether to write to disk, return as strings, or compare
8
+ # against a golden fixture.
9
+ class Base
10
+ # A single file produced by a backend.
11
+ #
12
+ # path : String — relative output path (e.g. "button_component.rb").
13
+ # contents : String — the file body.
14
+ File = Data.define(:path, :contents)
15
+
16
+ def emit(_ir_component)
17
+ raise NotImplementedError, "#{self.class} must implement #emit"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "view_component"
4
+
5
+ module JsxRosetta
6
+ module Backend
7
+ # Emits a translated component as a plain Rails view template
8
+ # (`<snake>.html.erb`) instead of a ViewComponent class + sidecar
9
+ # template. Pages tied to routes are conceptually Rails views, not
10
+ # reusable components — the controller sets `@instance_variables` and
11
+ # the template uses them directly.
12
+ #
13
+ # Reuses the ViewComponent backend's IR-rendering pipeline; only the
14
+ # output shape differs (no Ruby class, no sidecar directory). When the
15
+ # source JSX includes inline event handlers, a Stimulus controller is
16
+ # still emitted as a sibling file alongside the .html.erb.
17
+ class RailsView < ViewComponent
18
+ def emit(component)
19
+ prop_names = component.props.map(&:name)
20
+ prop_names << component.rest_prop_name if component.rest_prop_name
21
+ translator = ExpressionTranslator.new(prop_names: prop_names)
22
+
23
+ @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
24
+
25
+ files = [
26
+ File.new(
27
+ path: "#{AST::Inflector.underscore(component.name)}.html.erb",
28
+ contents: render_erb_template(component, translator)
29
+ )
30
+ ]
31
+ if component.stimulus_methods.any?
32
+ files << File.new(
33
+ path: "#{AST::Inflector.underscore(component.name)}_controller.js",
34
+ contents: render_stimulus_controller_js(component)
35
+ )
36
+ end
37
+ files
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast/inflector"
4
+
5
+ module JsxRosetta
6
+ module Backend
7
+ # Emits a reviewable Ruby script from an IR::RouteTree.
8
+ #
9
+ # The output prints the parsed routes, lists `system "rails", "generate",
10
+ # "controller", ...` invocations (commented out by default), and a
11
+ # suggested config/routes.rb block. The user is expected to review the
12
+ # script, uncomment the commands they want to run, and execute it with
13
+ # `ruby <output>.rb`.
14
+ class RoutesScript
15
+ RESERVED_CONTROLLER_NAMES = %w[application action rails].freeze
16
+
17
+ def initialize(source_path: nil, generated_at: Time.now.utc.strftime("%Y-%m-%d"))
18
+ @source_path = source_path
19
+ @generated_at = generated_at
20
+ end
21
+
22
+ def emit(route_tree)
23
+ lines = header_lines + body_lines(route_tree)
24
+ "#{lines.join("\n")}\n"
25
+ end
26
+
27
+ private
28
+
29
+ def header_lines
30
+ provenance = @source_path ? "from #{@source_path}" : ""
31
+ [
32
+ "#!/usr/bin/env ruby",
33
+ "# frozen_string_literal: true",
34
+ "#",
35
+ "# Generated by jsx_rosetta routes #{provenance} on #{@generated_at}.",
36
+ "# Review this script. Uncomment the system() calls you want to run,",
37
+ "# then execute with `ruby <this-file>`.",
38
+ ""
39
+ ]
40
+ end
41
+
42
+ def body_lines(route_tree)
43
+ return ["puts 'No <Route> entries were recognized.'"] if route_tree.routes.empty?
44
+
45
+ groups = group_routes(route_tree.routes)
46
+ routes_array_lines(route_tree.routes) +
47
+ [""] +
48
+ report_lines +
49
+ [""] +
50
+ generator_lines(groups) +
51
+ [""] +
52
+ routes_dsl_lines(groups)
53
+ end
54
+
55
+ # Group routes that share a resource shape so they can be emitted as
56
+ # `resources :plural, only: %i[index show]` instead of two siloed
57
+ # `get` lines. Returns an array where each element is either a Hash
58
+ # `{ kind: :resource, plural:, actions: }` or `{ kind: :route, route: }`.
59
+ def group_routes(routes)
60
+ buckets, ungrouped = bucket_routes_by_resource(routes)
61
+ groups = []
62
+ buckets.each do |plural, by_action|
63
+ if resource_pair?(by_action)
64
+ groups << { kind: :resource, plural: plural, actions: by_action.keys }
65
+ else
66
+ by_action.each_value { |route| ungrouped << route }
67
+ end
68
+ end
69
+ ungrouped.each { |route| groups << { kind: :route, route: route } }
70
+ groups
71
+ end
72
+
73
+ def bucket_routes_by_resource(routes)
74
+ # Build buckets keyed by plural noun (`/posts` and `/posts/:id` both map to `posts`).
75
+ buckets = Hash.new { |h, k| h[k] = {} }
76
+ ungrouped = []
77
+ routes.each do |route|
78
+ shape = resource_shape(route.path)
79
+ shape ? (buckets[shape[:plural]][shape[:action]] = route) : ungrouped << route
80
+ end
81
+ [buckets, ungrouped]
82
+ end
83
+
84
+ def resource_pair?(by_action)
85
+ by_action.size >= 2 && (by_action.keys & %w[index show]).size == 2
86
+ end
87
+
88
+ def resource_shape(path)
89
+ if (m = %r{\A/(?<plural>[a-z][a-z0-9_-]*)\z}.match(path))
90
+ { plural: m[:plural], action: "index" }
91
+ elsif (m = %r{\A/(?<plural>[a-z][a-z0-9_-]*)/:(?<param>[a-z_][a-z0-9_]*)\z}.match(path))
92
+ { plural: m[:plural], action: "show" }
93
+ end
94
+ end
95
+
96
+ def routes_array_lines(routes)
97
+ ["routes = ["] +
98
+ routes.map { |r| " { path: #{r.path.inspect}, element: #{r.element_name.inspect} }," } +
99
+ ["]"]
100
+ end
101
+
102
+ def report_lines
103
+ [
104
+ %(puts "Parsed routes:"),
105
+ %(routes.each { |r| puts " \#{r[:path]} → \#{r[:element]}" })
106
+ ]
107
+ end
108
+
109
+ def generator_lines(groups)
110
+ controllers = collect_controller_names(groups)
111
+ warning = collision_warning(controllers)
112
+ lines = ["# Run these by uncommenting the lines you want:"]
113
+ lines << warning if warning
114
+ groups.each { |group| lines << generator_line_for(group) }
115
+ lines
116
+ end
117
+
118
+ def generator_line_for(group)
119
+ if group[:kind] == :resource
120
+ plural = group[:plural]
121
+ %(# system "rails", "generate", "controller", "#{plural}", "index", "show", "--skip-routes")
122
+ else
123
+ route = group[:route]
124
+ controller = AST::Inflector.underscore(route.element_name)
125
+ action = action_for_path(route.path)
126
+ %(# system "rails", "generate", "controller", "#{controller}", "#{action}", "--skip-routes")
127
+ end
128
+ end
129
+
130
+ def routes_dsl_lines(groups)
131
+ body = groups.flat_map { |group| group_to_dsl_lines(group) }
132
+ [
133
+ "puts <<~RB",
134
+ "",
135
+ " # Suggested config/routes.rb additions:",
136
+ " Rails.application.routes.draw do"
137
+ ] + body + [
138
+ " end",
139
+ "RB"
140
+ ]
141
+ end
142
+
143
+ def group_to_dsl_lines(group)
144
+ if group[:kind] == :resource
145
+ [" resources :#{group[:plural]}, only: %i[#{group[:actions].sort.join(" ")}]"]
146
+ else
147
+ [route_to_dsl_line(group[:route])]
148
+ end
149
+ end
150
+
151
+ def route_to_dsl_line(route)
152
+ controller = AST::Inflector.underscore(route.element_name)
153
+ action = action_for_path(route.path)
154
+ path = normalize_catch_all(route.path)
155
+ if catch_all?(route.path)
156
+ %( match #{path.inspect}, to: "#{controller}##{action}", via: :all)
157
+ else
158
+ %( get #{path.inspect}, to: "#{controller}##{action}")
159
+ end
160
+ end
161
+
162
+ def action_for_path(path)
163
+ path.match?(/:[a-zA-Z_]/) ? "show" : "index"
164
+ end
165
+
166
+ def catch_all?(path)
167
+ path.start_with?("*")
168
+ end
169
+
170
+ # Rails routing requires a name for splat segments. A bare `*` from
171
+ # JSX (`<Route path="*" />`) is illegal; normalize to `*path`.
172
+ def normalize_catch_all(path)
173
+ path == "*" ? "*path" : path
174
+ end
175
+
176
+ def collect_controller_names(groups)
177
+ groups.flat_map do |group|
178
+ group[:kind] == :resource ? [group[:plural]] : [AST::Inflector.underscore(group[:route].element_name)]
179
+ end
180
+ end
181
+
182
+ def collision_warning(controllers)
183
+ collisions = controllers & RESERVED_CONTROLLER_NAMES
184
+ return nil if collisions.empty?
185
+
186
+ "# WARNING: these controller names collide with Rails reserved terms; " \
187
+ "rename before running: #{collisions.join(", ")}"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ast/inflector"
4
+
5
+ module JsxRosetta
6
+ module Backend
7
+ class ViewComponent
8
+ # Best-effort, narrowly-scoped JS-to-Ruby translation for the simple
9
+ # expression shapes that JSX components in real codebases use most
10
+ # often: bare identifiers, literals, simple member-expression chains
11
+ # (`item.label`), and template literals composed of identifier
12
+ # interpolations. Anything more complex (function calls, conditionals,
13
+ # subscripts) returns `nil` from `#translate` so the backend can emit
14
+ # a TODO marker and fall back to the verbatim JS source.
15
+ #
16
+ # Identifier resolution:
17
+ # * Names in the active local scope (e.g. loop bindings) translate
18
+ # to the bare snake_case identifier.
19
+ # * Names in `prop_names` translate to a `@snake_case` instance
20
+ # variable.
21
+ # * Anything else translates to the bare snake_case identifier and
22
+ # is recorded as unresolved.
23
+ #
24
+ # Local scopes can be pushed via `with_locals` and stack — each
25
+ # entry shadows lower entries.
26
+ class ExpressionTranslator
27
+ IDENTIFIER = /\A[a-zA-Z_$][a-zA-Z_$0-9]*\z/
28
+ STRING_LITERAL = /\A(['"])(.*)\1\z/m
29
+ NUMBER_LITERAL = /\A-?\d+(\.\d+)?\z/
30
+ TEMPLATE_LITERAL = /\A`(.*)`\z/m
31
+ TEMPLATE_INTERPOLATION = /\$\{([a-zA-Z_$][a-zA-Z_$0-9]*(?:\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\}/
32
+ MEMBER_CHAIN = /\A(?<root>[a-zA-Z_$][a-zA-Z_$0-9]*)(?<rest>(?:\.[a-zA-Z_$][a-zA-Z_$0-9]*)+)\z/
33
+ UNARY = /\A(?<op>!+|-|\+)(?<operand>.+)\z/m
34
+ SIMPLE_LITERALS = { "null" => "nil", "undefined" => "nil", "true" => "true", "false" => "false" }.freeze
35
+
36
+ Result = Data.define(:ruby, :unresolved_identifiers)
37
+
38
+ def initialize(prop_names:)
39
+ @prop_names = prop_names.to_set
40
+ @local_stack = []
41
+ end
42
+
43
+ def with_locals(names)
44
+ @local_stack.push(names.compact)
45
+ yield
46
+ ensure
47
+ @local_stack.pop
48
+ end
49
+
50
+ def translate(source)
51
+ source = source.strip
52
+ unresolved = []
53
+
54
+ ruby = translate_ruby(source, unresolved)
55
+ ruby && Result.new(ruby: ruby, unresolved_identifiers: unresolved.uniq)
56
+ end
57
+
58
+ private
59
+
60
+ def translate_ruby(source, unresolved)
61
+ if SIMPLE_LITERALS.key?(source) then SIMPLE_LITERALS[source]
62
+ elsif source.match?(NUMBER_LITERAL) || source.match?(STRING_LITERAL) then source
63
+ elsif source.match?(IDENTIFIER) then translate_identifier(source, unresolved)
64
+ elsif (m = MEMBER_CHAIN.match(source)) then translate_member_chain(m[:root], m[:rest], unresolved)
65
+ elsif (m = TEMPLATE_LITERAL.match(source)) then translate_template_literal(m[1], unresolved)
66
+ elsif (m = UNARY.match(source))
67
+ translate_unary(m[:op], m[:operand], unresolved)
68
+ end
69
+ end
70
+
71
+ def translate_unary(operator, operand, unresolved)
72
+ inner = translate_ruby(operand.strip, unresolved)
73
+ inner && "#{operator}#{inner}"
74
+ end
75
+
76
+ def in_local_scope?(name)
77
+ @local_stack.any? { |scope| scope.include?(name) }
78
+ end
79
+
80
+ def translate_identifier(name, unresolved)
81
+ snake = AST::Inflector.underscore(name)
82
+ if in_local_scope?(name)
83
+ snake
84
+ elsif @prop_names.include?(name)
85
+ "@#{snake}"
86
+ else
87
+ unresolved << name
88
+ snake
89
+ end
90
+ end
91
+
92
+ def translate_member_chain(root, rest, unresolved)
93
+ translated_root = translate_identifier(root, unresolved)
94
+ # Underscore each chain segment so JS camelCase identifiers map to
95
+ # Ruby snake_case (`post.coverImage` → `post.cover_image`).
96
+ ruby_rest = rest.gsub(/\.([a-zA-Z_$][a-zA-Z_$0-9]*)/) do
97
+ ".#{AST::Inflector.underscore(::Regexp.last_match(1))}"
98
+ end
99
+ "#{translated_root}#{ruby_rest}"
100
+ end
101
+
102
+ def translate_template_literal(content, unresolved)
103
+ return nil if content.include?("\\`")
104
+ return nil if content.scan("${").size != content.scan(TEMPLATE_INTERPOLATION).size
105
+
106
+ ruby_content = content.gsub(TEMPLATE_INTERPOLATION) do |_match|
107
+ captured = ::Regexp.last_match(1)
108
+ translated = if (m = MEMBER_CHAIN.match(captured))
109
+ translate_member_chain(m[:root], m[:rest], unresolved)
110
+ else
111
+ translate_identifier(captured, unresolved)
112
+ end
113
+ "\#{#{translated}}"
114
+ end
115
+ %("#{ruby_content}")
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end