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,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsxRosetta
|
|
4
|
+
module IR
|
|
5
|
+
# Marker module included by every IR node type. Lets backends and
|
|
6
|
+
# tests sanity-check that something is an IR value (`is_a?(IR::Node)`).
|
|
7
|
+
module Node
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# A translated component definition. The root of a lowered IR tree.
|
|
11
|
+
#
|
|
12
|
+
# name : String — component name as it appears in JSX (e.g. "Button").
|
|
13
|
+
# props : [Prop]
|
|
14
|
+
# body : Node — usually an Element or Fragment.
|
|
15
|
+
# rest_prop_name : String | nil — name of a rest-destructured prop
|
|
16
|
+
# (`function X({ a, ...rest })`). When non-nil, the
|
|
17
|
+
# backend should generate a `**rest` initializer kwarg
|
|
18
|
+
# and make it available via `@rest_prop_name`.
|
|
19
|
+
# local_bindings : [LocalBinding] — non-JSX local `const` bindings inside
|
|
20
|
+
# the component body. Backends typically render these as
|
|
21
|
+
# a TODO comment block since arbitrary JS-to-Ruby
|
|
22
|
+
# translation isn't attempted.
|
|
23
|
+
# stimulus_methods : [StimulusMethod] — event handlers extracted from
|
|
24
|
+
# inline arrows / const-bound arrows used in onX={...}.
|
|
25
|
+
# When non-empty, backends should emit a sibling
|
|
26
|
+
# Stimulus controller file alongside the .rb/.erb pair.
|
|
27
|
+
# react_hooks : [ReactHookCall] — calls to React hooks (useState,
|
|
28
|
+
# useEffect, useRef, useContext, useMemo, useCallback,
|
|
29
|
+
# useReducer, useImperativeHandle, useLayoutEffect).
|
|
30
|
+
# Surfaced as a distinct TODO block so the human
|
|
31
|
+
# reviewer knows to translate behavior to Stimulus
|
|
32
|
+
# and state to server-side rendering.
|
|
33
|
+
Component = Data.define(:name, :props, :body, :rest_prop_name,
|
|
34
|
+
:local_bindings, :stimulus_methods, :react_hooks) do
|
|
35
|
+
include Node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A non-JSX local binding declared inside the component body
|
|
39
|
+
# (`const date = parseISO(dateString)`). The verbatim source is
|
|
40
|
+
# preserved so the human reviewer can translate it.
|
|
41
|
+
#
|
|
42
|
+
# name : String
|
|
43
|
+
# source : String — verbatim JS of the entire VariableDeclaration statement.
|
|
44
|
+
LocalBinding = Data.define(:name, :source) do
|
|
45
|
+
include Node
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A React hook invocation detected in the component body (`useState`,
|
|
49
|
+
# `useEffect`, …). Surfaced separately from local_bindings so backends
|
|
50
|
+
# can emit a more specific TODO that points at the Stimulus / Hotwire
|
|
51
|
+
# / server-render alternative, instead of a generic "translate this JS".
|
|
52
|
+
#
|
|
53
|
+
# hook : String — hook function name (`"useState"`, `"useEffect"`, …)
|
|
54
|
+
# source : String — verbatim JS of the entire statement.
|
|
55
|
+
ReactHookCall = Data.define(:hook, :source) do
|
|
56
|
+
include Node
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A component prop, possibly with a default value.
|
|
60
|
+
#
|
|
61
|
+
# name : String
|
|
62
|
+
# default : Interpolation | nil
|
|
63
|
+
Prop = Data.define(:name, :default) do
|
|
64
|
+
include Node
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# An HTML element (lowercase tag name, no member-expression form).
|
|
68
|
+
#
|
|
69
|
+
# tag : String — "button", "div", etc.
|
|
70
|
+
# attributes : [Attribute | StyleBinding] (and EventBinding once Phase 4 lands)
|
|
71
|
+
# children : [Element | ComponentInvocation | Text | Interpolation | Fragment]
|
|
72
|
+
Element = Data.define(:tag, :attributes, :children) do
|
|
73
|
+
include Node
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A component invocation. Distinct from Element so backends can render
|
|
77
|
+
# it as `render Component.new(...)` rather than as a raw HTML tag.
|
|
78
|
+
#
|
|
79
|
+
# name : String — "Button", "Foo.Bar", etc.
|
|
80
|
+
# props : [Attribute | StyleBinding]
|
|
81
|
+
# children : [Element | ComponentInvocation | Text | Interpolation | Fragment]
|
|
82
|
+
ComponentInvocation = Data.define(:name, :props, :children) do
|
|
83
|
+
include Node
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A spread attribute: `{...rest}` in JSX. The expression is preserved
|
|
87
|
+
# verbatim; backends emit it as `**<expression>` or equivalent.
|
|
88
|
+
#
|
|
89
|
+
# expression : String — verbatim JS source of the spread argument
|
|
90
|
+
# (typically a single identifier, sometimes a member chain).
|
|
91
|
+
SpreadAttribute = Data.define(:expression) do
|
|
92
|
+
include Node
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A name/value pair on an Element or ComponentInvocation.
|
|
96
|
+
#
|
|
97
|
+
# name : String
|
|
98
|
+
# value : String | true | Interpolation
|
|
99
|
+
# - String: literal value from a JSX string-literal attribute
|
|
100
|
+
# (e.g. `type="button"` → value: "button").
|
|
101
|
+
# - true: boolean attribute with no value (e.g. `<input disabled />`).
|
|
102
|
+
# - Interpolation: expression-container value (e.g. `href={url}`).
|
|
103
|
+
Attribute = Data.define(:name, :value) do
|
|
104
|
+
include Node
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A class-binding expression — what JSX expresses as `className={...}`.
|
|
108
|
+
#
|
|
109
|
+
# expression : String — verbatim JS source of the className value
|
|
110
|
+
# (literal string in quotes, template literal,
|
|
111
|
+
# call to `cn(...)`, etc.). Decomposition into
|
|
112
|
+
# individual classes/conditionals is deferred.
|
|
113
|
+
StyleBinding = Data.define(:expression) do
|
|
114
|
+
include Node
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A decomposed className expression — the result of recognizing a
|
|
118
|
+
# `cn(...)` / `clsx(...)` / `classnames(...)` call at lowering time.
|
|
119
|
+
# Each segment is one of:
|
|
120
|
+
# String — literal class chunk like "btn btn-primary"
|
|
121
|
+
# Interpolation — variable reference (translated by backend)
|
|
122
|
+
# ConditionalSegment — `{ "active": isActive }` style entry
|
|
123
|
+
ClassList = Data.define(:segments) do
|
|
124
|
+
include Node
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A conditional class entry (`{ "active": isActive }` from cn-style
|
|
128
|
+
# helpers). Renders the class_name when the condition is truthy.
|
|
129
|
+
#
|
|
130
|
+
# class_name : String — literal class string to emit when condition is truthy.
|
|
131
|
+
# condition : Interpolation — verbatim JS source of the condition.
|
|
132
|
+
ConditionalSegment = Data.define(:class_name, :condition) do
|
|
133
|
+
include Node
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A decomposed inline-style expression (JSX `style={{ ... }}`).
|
|
137
|
+
#
|
|
138
|
+
# declarations : [StyleDeclaration] — one per property in the source order
|
|
139
|
+
Style = Data.define(:declarations) do
|
|
140
|
+
include Node
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A single CSS property/value pair, with the property already converted
|
|
144
|
+
# from JSX camelCase to CSS kebab-case.
|
|
145
|
+
#
|
|
146
|
+
# property : String — kebab-case CSS property (e.g. "font-size")
|
|
147
|
+
# value : String | Interpolation — String for literal CSS values
|
|
148
|
+
# already quoted ready for output, Interpolation for runtime
|
|
149
|
+
# values to be ERB-interpolated.
|
|
150
|
+
StyleDeclaration = Data.define(:property, :value) do
|
|
151
|
+
include Node
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# An opaque JS expression embedded in JSX (between curlies). The
|
|
155
|
+
# expression text is preserved verbatim so the backend can emit it
|
|
156
|
+
# into `<%= %>` (or its target equivalent) for human review.
|
|
157
|
+
#
|
|
158
|
+
# expression : String — verbatim JS source.
|
|
159
|
+
Interpolation = Data.define(:expression) do
|
|
160
|
+
include Node
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A literal text node.
|
|
164
|
+
#
|
|
165
|
+
# value : String
|
|
166
|
+
Text = Data.define(:value) do
|
|
167
|
+
include Node
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A comment lifted from JSX (`{/* … */}`). Backends decide how to
|
|
171
|
+
# surface it (ERB `<%# … %>`, HTML `<!-- -->`, etc.).
|
|
172
|
+
#
|
|
173
|
+
# text : String — comment body verbatim, including any leading/trailing
|
|
174
|
+
# whitespace from the JSX source.
|
|
175
|
+
Comment = Data.define(:text) do
|
|
176
|
+
include Node
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A JSX fragment (`<>...</>`).
|
|
180
|
+
#
|
|
181
|
+
# children : [Element | ComponentInvocation | Text | Interpolation | Fragment]
|
|
182
|
+
Fragment = Data.define(:children) do
|
|
183
|
+
include Node
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A conditional render. Lowered from any of:
|
|
187
|
+
# {cond && <X />}
|
|
188
|
+
# {cond ? <X /> : null}
|
|
189
|
+
# {cond ? <X /> : <Y />}
|
|
190
|
+
#
|
|
191
|
+
# test : Interpolation — verbatim JS source of the condition.
|
|
192
|
+
# consequent : Node — what to render when test is truthy.
|
|
193
|
+
# alternate : Node | nil — what to render otherwise (nil for `cond &&`
|
|
194
|
+
# or for `cond ? X : null`).
|
|
195
|
+
Conditional = Data.define(:test, :consequent, :alternate) do
|
|
196
|
+
include Node
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A content slot. Backends decide how to realize it (ViewComponent's
|
|
200
|
+
# `content` for the default slot, named renders_one slots for others).
|
|
201
|
+
#
|
|
202
|
+
# name : String — "children" for the default slot, or a prop name.
|
|
203
|
+
Slot = Data.define(:name) do
|
|
204
|
+
include Node
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# An event handler binding. Lowered from a JSX attribute named
|
|
208
|
+
# `on<Event>` whose value is an expression container.
|
|
209
|
+
#
|
|
210
|
+
# event : String — lowercased DOM event name ("click", "change",
|
|
211
|
+
# "mouseenter").
|
|
212
|
+
# handler : Interpolation — the JS expression bound to the event,
|
|
213
|
+
# verbatim. For ViewComponent + Stimulus, the caller is
|
|
214
|
+
# expected to supply a Stimulus action descriptor string
|
|
215
|
+
# (e.g. "click->my-controller#handleClick"); the component
|
|
216
|
+
# just renders it through.
|
|
217
|
+
EventBinding = Data.define(:event, :handler) do
|
|
218
|
+
include Node
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# An event handler routed through a generated Stimulus controller.
|
|
222
|
+
#
|
|
223
|
+
# event : String — lowercased DOM event name.
|
|
224
|
+
# method_name : String — Stimulus controller method (camelCase per
|
|
225
|
+
# Stimulus convention).
|
|
226
|
+
StimulusBinding = Data.define(:event, :method_name) do
|
|
227
|
+
include Node
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A flat list of React Router routes parsed from a router file.
|
|
231
|
+
# Distinct from Component — RouteTree is the top-level result of
|
|
232
|
+
# `JsxRosetta::Routes.lower(file)`, not part of a translated component.
|
|
233
|
+
#
|
|
234
|
+
# routes : [RouteEntry]
|
|
235
|
+
RouteTree = Data.define(:routes) do
|
|
236
|
+
include Node
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A single React Router route entry.
|
|
240
|
+
#
|
|
241
|
+
# path : String — the JSX path attribute verbatim (e.g. "/posts/:id").
|
|
242
|
+
# element_name : String — the JSX element name from element={<X />}
|
|
243
|
+
# (e.g. "PostShow"). Member-expression forms ("Layout.Index")
|
|
244
|
+
# are flattened to the rightmost name.
|
|
245
|
+
RouteEntry = Data.define(:path, :element_name) do
|
|
246
|
+
include Node
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A handler method to be emitted on the generated Stimulus controller.
|
|
250
|
+
# Body translation is deferred to the human reviewer; we preserve the
|
|
251
|
+
# original JS body verbatim.
|
|
252
|
+
#
|
|
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
|
|
258
|
+
include Node
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A list-rendering loop. Lowered from a JSX expression of the form:
|
|
262
|
+
# {items.map((item) => <X />)}
|
|
263
|
+
# {items.map((item, index) => <X />)}
|
|
264
|
+
# plus the arrow-with-block form `(item) => { return <X />; }`.
|
|
265
|
+
#
|
|
266
|
+
# iterable : Interpolation — verbatim source of the iterable
|
|
267
|
+
# expression (e.g. "items", "todos.filter(...)").
|
|
268
|
+
# item_binding : String — name of the item parameter, in original
|
|
269
|
+
# camelCase. Backends snake_case as needed.
|
|
270
|
+
# index_binding : String | nil — name of the index parameter, if present.
|
|
271
|
+
# body : Node — the lowered IR node rendered for each iteration.
|
|
272
|
+
Loop = Data.define(:iterable, :item_binding, :index_binding, :body) do
|
|
273
|
+
include Node
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ir/types"
|
|
4
|
+
require_relative "ir/lowering"
|
|
5
|
+
|
|
6
|
+
module JsxRosetta
|
|
7
|
+
module IR
|
|
8
|
+
def self.lower(ast_file, source:)
|
|
9
|
+
Lowering.lower(ast_file, source: source)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.lower_all(ast_file, source:)
|
|
13
|
+
Lowering.lower_all(ast_file, source: source)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module JsxRosetta
|
|
7
|
+
# Spawns the Node sidecar (`node/parse.js`) as a one-shot subprocess
|
|
8
|
+
# per parse request. A long-lived worker is a future optimization.
|
|
9
|
+
class NodeBridge
|
|
10
|
+
SIDECAR_DIR = File.expand_path("../../node", __dir__)
|
|
11
|
+
SIDECAR_SCRIPT = File.join(SIDECAR_DIR, "parse.js")
|
|
12
|
+
NODE_MODULES_DIR = File.join(SIDECAR_DIR, "node_modules")
|
|
13
|
+
|
|
14
|
+
class MissingNode < Error; end
|
|
15
|
+
class MissingDependencies < Error; end
|
|
16
|
+
|
|
17
|
+
def parse(source, typescript: false, source_filename: nil)
|
|
18
|
+
ensure_dependencies_installed!
|
|
19
|
+
|
|
20
|
+
request = JSON.generate(
|
|
21
|
+
source: source,
|
|
22
|
+
typescript: typescript,
|
|
23
|
+
source_filename: source_filename
|
|
24
|
+
)
|
|
25
|
+
stdout, stderr, status = Open3.capture3(node_executable, SIDECAR_SCRIPT, stdin_data: request)
|
|
26
|
+
|
|
27
|
+
raise Error, "jsx_rosetta sidecar exited with status #{status.exitstatus}: #{stderr.strip}" unless status.success?
|
|
28
|
+
|
|
29
|
+
JSON.parse(stdout)
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
raise MissingNode, missing_node_message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def node_executable
|
|
37
|
+
ENV["JSX_ROSETTA_NODE"] || "node"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ensure_dependencies_installed!
|
|
41
|
+
return if File.directory?(File.join(NODE_MODULES_DIR, "@babel", "parser"))
|
|
42
|
+
|
|
43
|
+
raise MissingDependencies, <<~MSG.strip
|
|
44
|
+
Node dependencies for jsx_rosetta are not installed.
|
|
45
|
+
Run `bundle exec jsx_rosetta install` (or `cd #{SIDECAR_DIR} && npm install`).
|
|
46
|
+
MSG
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def missing_node_message
|
|
50
|
+
<<~MSG.strip
|
|
51
|
+
Could not find Node.js. jsx_rosetta requires Node.js (>= 18) on PATH,
|
|
52
|
+
or set JSX_ROSETTA_NODE to the absolute path of a node executable.
|
|
53
|
+
MSG
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsxRosetta
|
|
4
|
+
class ParseError < Error
|
|
5
|
+
attr_reader :line, :column
|
|
6
|
+
|
|
7
|
+
def initialize(message, line: nil, column: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@line = line
|
|
10
|
+
@column = column
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
return super if line.nil?
|
|
15
|
+
|
|
16
|
+
"#{super} (#{line}:#{column})"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ast"
|
|
4
|
+
require_relative "node_bridge"
|
|
5
|
+
require_relative "parse_error"
|
|
6
|
+
|
|
7
|
+
module JsxRosetta
|
|
8
|
+
# Public entry point for JSX → AST parsing.
|
|
9
|
+
#
|
|
10
|
+
# Returns a typed AST::Node tree (rooted at AST::File) that mirrors the
|
|
11
|
+
# Babel AST shape with Ruby ergonomics: snake_case field accessors,
|
|
12
|
+
# source location preservation, traversal helpers, and pattern-matching
|
|
13
|
+
# support.
|
|
14
|
+
class Parser
|
|
15
|
+
def initialize(node_bridge: NodeBridge.new)
|
|
16
|
+
@node_bridge = node_bridge
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse(source, typescript: false, source_filename: nil)
|
|
20
|
+
response = @node_bridge.parse(source, typescript: typescript, source_filename: source_filename)
|
|
21
|
+
|
|
22
|
+
if response["ok"]
|
|
23
|
+
AST.build(response["ast"])
|
|
24
|
+
else
|
|
25
|
+
error = response["error"] || {}
|
|
26
|
+
raise ParseError.new(error["message"] || "JSX parse failed", line: error["line"], column: error["column"])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ir/types"
|
|
4
|
+
|
|
5
|
+
module JsxRosetta
|
|
6
|
+
# React Router → IR::RouteTree extraction. Recognizes the JSX-based
|
|
7
|
+
# declarative form (<Routes><Route path="..." element={<X />} /></Routes>)
|
|
8
|
+
# and the bare `<Route path="..." element={<X />} />` form anywhere in
|
|
9
|
+
# the AST. The data-router form (createBrowserRouter([{ path, element }]))
|
|
10
|
+
# is a future addition.
|
|
11
|
+
module Routes
|
|
12
|
+
def self.lower(ast_file)
|
|
13
|
+
Lowering.new.lower(ast_file)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Lowering
|
|
17
|
+
def lower(ast_file)
|
|
18
|
+
routes = []
|
|
19
|
+
ast_file.walk do |node|
|
|
20
|
+
next unless node.is_a?(AST::JSXElement)
|
|
21
|
+
next unless node.tag_name == "Route"
|
|
22
|
+
|
|
23
|
+
path = extract_path(node)
|
|
24
|
+
element_name = extract_element_name(node)
|
|
25
|
+
next if path.nil? || element_name.nil?
|
|
26
|
+
|
|
27
|
+
routes << IR::RouteEntry.new(path: path, element_name: element_name)
|
|
28
|
+
end
|
|
29
|
+
IR::RouteTree.new(routes: routes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def find_attribute(element, name)
|
|
35
|
+
element.opening_element.attributes.find do |attr|
|
|
36
|
+
attr.is_a?(AST::JSXAttribute) && attr.attribute_name == name
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_path(element)
|
|
41
|
+
attr = find_attribute(element, "path")
|
|
42
|
+
return nil unless attr
|
|
43
|
+
|
|
44
|
+
value = attr.value
|
|
45
|
+
case value
|
|
46
|
+
when nil
|
|
47
|
+
nil
|
|
48
|
+
when AST::JSXExpressionContainer
|
|
49
|
+
inner = value.expression
|
|
50
|
+
inner.type == "StringLiteral" ? inner[:value] : nil
|
|
51
|
+
else
|
|
52
|
+
value.raw["value"] if value.raw["type"] == "StringLiteral"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_element_name(element)
|
|
57
|
+
attr = find_attribute(element, "element")
|
|
58
|
+
return nil unless attr.is_a?(AST::JSXAttribute)
|
|
59
|
+
return nil unless attr.value.is_a?(AST::JSXExpressionContainer)
|
|
60
|
+
|
|
61
|
+
inner = attr.value.expression
|
|
62
|
+
return nil unless inner.is_a?(AST::JSXElement)
|
|
63
|
+
|
|
64
|
+
flatten_element_name(inner.tag_name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def flatten_element_name(tag_name)
|
|
68
|
+
tag_name.to_s.split(".").last
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/jsx_rosetta.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "jsx_rosetta/version"
|
|
4
|
+
|
|
5
|
+
module JsxRosetta
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
def self.parse(source, typescript: false, source_filename: nil)
|
|
9
|
+
Parser.new.parse(source, typescript: typescript, source_filename: source_filename)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.lower(source, typescript: false, source_filename: nil)
|
|
13
|
+
ast = parse(source, typescript: typescript, source_filename: source_filename)
|
|
14
|
+
IR.lower(ast, source: source)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.translate(source, backend: :view_component, helpers: nil, layout: :sidecar,
|
|
18
|
+
typescript: false, source_filename: nil)
|
|
19
|
+
ast = parse(source, typescript: typescript, source_filename: source_filename)
|
|
20
|
+
components = IR.lower_all(ast, source: source)
|
|
21
|
+
backend_instance = backend_for(backend, helpers: helpers, layout: layout)
|
|
22
|
+
components.flat_map { |component| backend_instance.emit(component) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.backend_for(name, helpers: nil, layout: :sidecar)
|
|
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)
|
|
29
|
+
else
|
|
30
|
+
raise Error, "unknown backend: #{name.inspect}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
require_relative "jsx_rosetta/parse_error"
|
|
36
|
+
require_relative "jsx_rosetta/node_bridge"
|
|
37
|
+
require_relative "jsx_rosetta/parser"
|
|
38
|
+
require_relative "jsx_rosetta/ir"
|
|
39
|
+
require_relative "jsx_rosetta/routes"
|
|
40
|
+
require_relative "jsx_rosetta/backend"
|
|
41
|
+
require_relative "jsx_rosetta/cli"
|
data/node/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
node_modules/
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jsx_rosetta-sidecar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "jsx_rosetta-sidecar",
|
|
9
|
+
"version": "0.1.0",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@babel/parser": "^7.25.0"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"node_modules/@babel/helper-string-parser": {
|
|
18
|
+
"version": "7.27.1",
|
|
19
|
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
|
20
|
+
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=6.9.0"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"node_modules/@babel/helper-validator-identifier": {
|
|
27
|
+
"version": "7.28.5",
|
|
28
|
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
|
29
|
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=6.9.0"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"node_modules/@babel/parser": {
|
|
36
|
+
"version": "7.29.3",
|
|
37
|
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
|
38
|
+
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@babel/types": "^7.29.0"
|
|
42
|
+
},
|
|
43
|
+
"bin": {
|
|
44
|
+
"parser": "bin/babel-parser.js"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=6.0.0"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"node_modules/@babel/types": {
|
|
51
|
+
"version": "7.29.0",
|
|
52
|
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
|
53
|
+
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@babel/helper-string-parser": "^7.27.1",
|
|
57
|
+
"@babel/helper-validator-identifier": "^7.28.5"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=6.9.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
data/node/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jsx_rosetta-sidecar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Node sidecar for the jsx_rosetta Ruby gem. Parses JSX/TSX via @babel/parser and returns the AST as JSON.",
|
|
6
|
+
"main": "parse.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"parse": "node parse.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@babel/parser": "^7.25.0"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
}
|
|
16
|
+
}
|
data/node/parse.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// jsx_rosetta Node sidecar.
|
|
3
|
+
//
|
|
4
|
+
// Reads a JSON request from stdin, parses the source with @babel/parser,
|
|
5
|
+
// and writes a JSON response to stdout.
|
|
6
|
+
//
|
|
7
|
+
// Request: { "source": "...", "typescript": false, "source_filename": "..." }
|
|
8
|
+
// Response (success): { "ok": true, "ast": <Babel File node> }
|
|
9
|
+
// Response (failure): { "ok": false, "error": { "message", "line", "column" } }
|
|
10
|
+
//
|
|
11
|
+
// Errors during stdin read or JSON parse exit with a non-zero status and
|
|
12
|
+
// print the error message to stderr.
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const { parse } = require("@babel/parser");
|
|
17
|
+
|
|
18
|
+
function readStdin() {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let data = "";
|
|
21
|
+
process.stdin.setEncoding("utf8");
|
|
22
|
+
process.stdin.on("data", (chunk) => {
|
|
23
|
+
data += chunk;
|
|
24
|
+
});
|
|
25
|
+
process.stdin.on("end", () => resolve(data));
|
|
26
|
+
process.stdin.on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildPlugins(typescript) {
|
|
31
|
+
const plugins = ["jsx"];
|
|
32
|
+
if (typescript) plugins.push("typescript");
|
|
33
|
+
return plugins;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main() {
|
|
37
|
+
const raw = await readStdin();
|
|
38
|
+
let request;
|
|
39
|
+
try {
|
|
40
|
+
request = JSON.parse(raw);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
process.stderr.write(`jsx_rosetta sidecar: invalid JSON request: ${e.message}\n`);
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const source = request.source ?? "";
|
|
47
|
+
const typescript = Boolean(request.typescript);
|
|
48
|
+
const sourceFilename = request.source_filename;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const ast = parse(source, {
|
|
52
|
+
sourceType: "module",
|
|
53
|
+
sourceFilename,
|
|
54
|
+
allowImportExportEverywhere: true,
|
|
55
|
+
allowReturnOutsideFunction: true,
|
|
56
|
+
plugins: buildPlugins(typescript),
|
|
57
|
+
tokens: false,
|
|
58
|
+
ranges: true,
|
|
59
|
+
});
|
|
60
|
+
process.stdout.write(JSON.stringify({ ok: true, ast }));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const response = {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: {
|
|
65
|
+
message: err.message,
|
|
66
|
+
line: err.loc ? err.loc.line : null,
|
|
67
|
+
column: err.loc ? err.loc.column : null,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
process.stdout.write(JSON.stringify(response));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch((err) => {
|
|
75
|
+
process.stderr.write(`jsx_rosetta sidecar: unexpected error: ${err && err.stack ? err.stack : err}\n`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|