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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsxRosetta
4
+ VERSION = "0.1.0"
5
+ end
@@ -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
+ });