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,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
|