jsx_rosetta 0.4.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +342 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/ROADMAP.md +92 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +32 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
- data/lib/jsx_rosetta/backend/view_component.rb +261 -31
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +264 -19
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +30 -1
|
@@ -78,9 +78,28 @@ module JsxRosetta
|
|
|
78
78
|
name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Only flag class-component shape when the class has no usable render
|
|
82
|
+
# method. Classes WITH `render()` lower via the
|
|
83
|
+
# ClassDeclaration → ViewComponent path added in v0.5.0, so the
|
|
84
|
+
# classifier should leave them alone and let the regular component
|
|
85
|
+
# finder pick them up.
|
|
81
86
|
def class_component?(stmt)
|
|
82
87
|
decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
|
|
83
|
-
AST::Node.matches?(decl, "ClassDeclaration")
|
|
88
|
+
return false unless AST::Node.matches?(decl, "ClassDeclaration")
|
|
89
|
+
|
|
90
|
+
!class_has_render_method?(decl)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def class_has_render_method?(class_decl)
|
|
94
|
+
body = class_decl.child(:body)
|
|
95
|
+
return false unless body
|
|
96
|
+
|
|
97
|
+
body[:body].any? do |member|
|
|
98
|
+
next false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
|
|
99
|
+
|
|
100
|
+
key = member.child(:key)
|
|
101
|
+
AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
|
|
102
|
+
end
|
|
84
103
|
end
|
|
85
104
|
|
|
86
105
|
# Recognize `export const X = React.memo(...)` (export wrapper) or a
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsxRosetta
|
|
4
|
+
module IR
|
|
5
|
+
# When a shadcn TSX wraps a Radix primitive like
|
|
6
|
+
# `<SeparatorPrimitive.Root orientation="horizontal" />`, the translator
|
|
7
|
+
# has historically lowered it to a `ComponentInvocation` whose name is
|
|
8
|
+
# the member chain (`"SeparatorPrimitive.Root"`) — which emits as
|
|
9
|
+
# `render SeparatorPrimitive::Root.new(...)`, an undefined-constant
|
|
10
|
+
# NameError at render time.
|
|
11
|
+
#
|
|
12
|
+
# The shapes underneath are stable enough across Radix that we can map
|
|
13
|
+
# the common (LocalImportName, MemberName) pairs to plain HTML elements
|
|
14
|
+
# plus any always-applied attributes. The mapping is keyed on the LOCAL
|
|
15
|
+
# binding name shadcn uses by convention (e.g. `SeparatorPrimitive`),
|
|
16
|
+
# which matches the import-aliasing pattern in every shadcn fork I've
|
|
17
|
+
# surveyed. Unknown pairs fall through to the existing ComponentInvocation
|
|
18
|
+
# / TODO behavior so consumers can hand-shim primitives we don't cover.
|
|
19
|
+
#
|
|
20
|
+
# Source specifiers that count as "Radix" — both the `radix-ui` umbrella
|
|
21
|
+
# package and the older `@radix-ui/react-*` per-primitive packages.
|
|
22
|
+
RADIX_SOURCE_PATTERN = %r{\A(?:radix-ui|@radix-ui/react-[\w-]+)\z}
|
|
23
|
+
|
|
24
|
+
# Local binding names that resolve to Radix's `Slot` primitive.
|
|
25
|
+
# Matches `Slot` (shadcn-v4 umbrella) and `SlotPrimitive` (older
|
|
26
|
+
# convention). Anything looser would silently drop user-defined
|
|
27
|
+
# components whose names happen to start with "Slot".
|
|
28
|
+
SLOT_LOCAL_NAME_PATTERN = /\ASlot(?:Primitive)?\z/
|
|
29
|
+
|
|
30
|
+
module RadixRegistry
|
|
31
|
+
# Each entry maps [PrimitiveBaseName, MemberName] →
|
|
32
|
+
# { tag: <html-tag>, attrs: { <fixed kwarg name> => <value> } }
|
|
33
|
+
# The `attrs` are merged into the element's lowered attributes (the
|
|
34
|
+
# consumer's own kwargs win on conflict — see `merge_radix_attrs`).
|
|
35
|
+
#
|
|
36
|
+
# `PrimitiveBaseName` is the *canonical* Radix primitive name
|
|
37
|
+
# (e.g. `Separator`); the lookup strips an optional `Primitive` suffix
|
|
38
|
+
# from the consumer's local binding before keying in, so both shadcn-v3
|
|
39
|
+
# (`import { Separator as SeparatorPrimitive } from "radix-ui"`) and
|
|
40
|
+
# shadcn-v4 (`import { Separator } from "radix-ui"`) umbrella idioms
|
|
41
|
+
# resolve. Namespace imports (`import * as SeparatorPrimitive from
|
|
42
|
+
# "@radix-ui/react-separator"`) also strip the suffix.
|
|
43
|
+
MAP = {
|
|
44
|
+
# Separator / Label / Avatar / Switch primitives — the shapes we hit
|
|
45
|
+
# most often in the bulk shadcn translation pass.
|
|
46
|
+
%w[Separator Root] => { tag: "div", attrs: { role: "separator" } },
|
|
47
|
+
%w[Label Root] => { tag: "label", attrs: {} },
|
|
48
|
+
%w[Avatar Root] => { tag: "span", attrs: {} },
|
|
49
|
+
%w[Avatar Image] => { tag: "img", attrs: {} },
|
|
50
|
+
%w[Avatar Fallback] => { tag: "span", attrs: {} },
|
|
51
|
+
%w[Switch Root] => { tag: "button", attrs: { type: "button", role: "switch" } },
|
|
52
|
+
%w[Switch Thumb] => { tag: "span", attrs: {} },
|
|
53
|
+
%w[Progress Root] => { tag: "div", attrs: { role: "progressbar" } },
|
|
54
|
+
%w[Progress Indicator] => { tag: "div", attrs: {} },
|
|
55
|
+
# Aspect-ratio + scroll-area roots are presentational containers.
|
|
56
|
+
%w[AspectRatio Root] => { tag: "div", attrs: {} },
|
|
57
|
+
%w[ScrollArea Root] => { tag: "div", attrs: {} },
|
|
58
|
+
%w[ScrollArea Viewport] => { tag: "div", attrs: {} },
|
|
59
|
+
# Tabs primitives — `data-orientation` comes from the JSX attrs;
|
|
60
|
+
# role=tablist on List + role=tab on Trigger + role=tabpanel on Content.
|
|
61
|
+
%w[Tabs Root] => { tag: "div", attrs: {} },
|
|
62
|
+
%w[Tabs List] => { tag: "div", attrs: { role: "tablist" } },
|
|
63
|
+
%w[Tabs Trigger] => { tag: "button", attrs: { type: "button", role: "tab" } },
|
|
64
|
+
%w[Tabs Content] => { tag: "div", attrs: { role: "tabpanel" } },
|
|
65
|
+
# Toggle / ToggleGroup — pressable buttons.
|
|
66
|
+
%w[Toggle Root] => { tag: "button", attrs: { type: "button" } },
|
|
67
|
+
%w[ToggleGroup Root] => { tag: "div", attrs: { role: "group" } },
|
|
68
|
+
%w[ToggleGroup Item] => { tag: "button", attrs: { type: "button" } },
|
|
69
|
+
# Collapsible — presentational containers; the open/closed state is
|
|
70
|
+
# data-state driven by the consumer (Stimulus or otherwise).
|
|
71
|
+
%w[Collapsible Root] => { tag: "div", attrs: {} },
|
|
72
|
+
%w[Collapsible Trigger] => { tag: "button", attrs: { type: "button" } },
|
|
73
|
+
%w[Collapsible Content] => { tag: "div", attrs: {} }
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
# Strip an optional `Primitive` suffix from the local binding so the
|
|
77
|
+
# registry's canonical names match both umbrella imports (`Separator`)
|
|
78
|
+
# and the older convention (`SeparatorPrimitive`).
|
|
79
|
+
def self.lookup(local_name, member)
|
|
80
|
+
MAP[[local_name, member]] || MAP[[local_name.sub(/Primitive\z/, ""), member]]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/jsx_rosetta/ir/types.rb
CHANGED
|
@@ -33,12 +33,19 @@ module JsxRosetta
|
|
|
33
33
|
# inline arrows / const-bound arrows used in onX={...}.
|
|
34
34
|
# When non-empty, backends should emit a sibling
|
|
35
35
|
# Stimulus controller file alongside the .rb/.erb pair.
|
|
36
|
-
# react_hooks : [ReactHookCall] —
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
36
|
+
# react_hooks : [ReactHookCall] — every recognized hook invocation
|
|
37
|
+
# in the component body, regardless of library.
|
|
38
|
+
# Includes React's built-in hooks (useState, useEffect,
|
|
39
|
+
# useRef, useContext, useMemo, useCallback, useReducer,
|
|
40
|
+
# useImperativeHandle, useLayoutEffect), Apollo hooks
|
|
41
|
+
# (useQuery, useMutation, useLazyQuery, useSubscription,
|
|
42
|
+
# useApolloClient), and Next.js navigation hooks
|
|
43
|
+
# (useRouter, usePathname, useSearchParams, useParams,
|
|
44
|
+
# useSelectedLayoutSegment(s)). Each call carries a
|
|
45
|
+
# `library` tag so backends can group them and emit a
|
|
46
|
+
# library-specific TODO pointing at the right Rails
|
|
47
|
+
# analog (Stimulus/server-render for React; controller
|
|
48
|
+
# fetch for Apollo; request.path/params for Next.js).
|
|
42
49
|
# module_bindings : [LocalBinding] — top-level `const`/`let` declarations
|
|
43
50
|
# outside the component function that aren't themselves
|
|
44
51
|
# components. Captured so backends can either translate
|
|
@@ -47,10 +54,90 @@ module JsxRosetta
|
|
|
47
54
|
# Without this capture, references to module-level
|
|
48
55
|
# constants from inside the JSX silently drop and
|
|
49
56
|
# produce unbacked snake_case references at render time.
|
|
57
|
+
# module_imports : [ModuleImport] — top-level `import` declarations.
|
|
58
|
+
# Backends thread the imported names into the
|
|
59
|
+
# ExpressionTranslator so any expression-context
|
|
60
|
+
# reference to an import (e.g. `styles.listContainer`
|
|
61
|
+
# from `import styles from "./X.module.css"`, or
|
|
62
|
+
# `AlertStatusEnum.Pending` from a TS enum import)
|
|
63
|
+
# bails out to a TODO instead of snake-casing to a
|
|
64
|
+
# bare identifier that NameErrors at render time.
|
|
65
|
+
# render_methods : [RenderMethod] — local arrow bindings that return JSX
|
|
66
|
+
# and are invoked from the JSX body (`const renderHeader
|
|
67
|
+
# = () => <div/>; ... {renderHeader()}`). Backends emit
|
|
68
|
+
# each as a private method on the generated class and
|
|
69
|
+
# reference it from a LocalRenderCall at the use site.
|
|
70
|
+
# mode : Symbol — `:view` for a normal Phlex/ViewComponent component
|
|
71
|
+
# whose body is rendered as JSX (the default); `:data_factory`
|
|
72
|
+
# for column-descriptor / option-list modules whose top-level
|
|
73
|
+
# export is a function returning an array of object literals.
|
|
74
|
+
# When `:data_factory`, the backend emits a snake_case method
|
|
75
|
+
# that returns the translated data, instead of `view_template`.
|
|
76
|
+
# JSX inside object properties still extracts to private
|
|
77
|
+
# methods on the class via the IR::Lambda path.
|
|
78
|
+
# server_data_source : ServerDataSource | nil — capture of a
|
|
79
|
+
# Next.js `getServerSideProps` / `getStaticProps`
|
|
80
|
+
# export from the same source file as this component.
|
|
81
|
+
# Body preserved verbatim so the Phlex backend can
|
|
82
|
+
# emit it as a TODO comment block above the class
|
|
83
|
+
# (the matching Rails controller action is where the
|
|
84
|
+
# user ports the data-loading logic). Attached only
|
|
85
|
+
# to the first component when a file contains
|
|
86
|
+
# multiple — Next.js page files have exactly one
|
|
87
|
+
# default-export component, so collisions are rare.
|
|
88
|
+
# hoc_wrappers : [String] — Higher-Order Component wrapper names
|
|
89
|
+
# that the lowering peeled off when finding this
|
|
90
|
+
# component (e.g. `memo`, `forwardRef`, `observer`,
|
|
91
|
+
# `connect`, `withRouter`). Recorded in
|
|
92
|
+
# outside-in order so a `memo(forwardRef(...))`
|
|
93
|
+
# emits as ["memo", "forwardRef"]. Backends surface
|
|
94
|
+
# these as a TODO comment block above the class
|
|
95
|
+
# explaining each wrapper's Rails analog (or
|
|
96
|
+
# lack of one). Empty for components without
|
|
97
|
+
# wrappers — the common case.
|
|
50
98
|
Component = Data.define(:name, :props, :body, :rest_prop_name,
|
|
51
99
|
:local_bindings, :local_binding_names,
|
|
52
|
-
:module_bindings,
|
|
53
|
-
:stimulus_methods, :react_hooks
|
|
100
|
+
:module_bindings, :module_imports,
|
|
101
|
+
:stimulus_methods, :react_hooks,
|
|
102
|
+
:render_methods, :mode, :server_data_source,
|
|
103
|
+
:hoc_wrappers) do
|
|
104
|
+
include Node
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A Next.js server data-loading export (`getServerSideProps` /
|
|
108
|
+
# `getStaticProps`). Captured at lowering time so the Phlex backend can
|
|
109
|
+
# surface the body as a TODO comment block pointed at the matching
|
|
110
|
+
# Rails controller action. Body is preserved verbatim — no JS-to-Ruby
|
|
111
|
+
# translation is attempted per project rules.
|
|
112
|
+
#
|
|
113
|
+
# hook_name : String — "getServerSideProps" or "getStaticProps".
|
|
114
|
+
# source : String — verbatim JS of the entire export statement.
|
|
115
|
+
ServerDataSource = Data.define(:hook_name, :source) do
|
|
116
|
+
include Node
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A top-level `import` declaration. Captured at lowering time so the
|
|
120
|
+
# ExpressionTranslator can recognize use-site references and bail out
|
|
121
|
+
# to a TODO instead of emitting a bare snake_case identifier that
|
|
122
|
+
# NameErrors at render time.
|
|
123
|
+
#
|
|
124
|
+
# name : String — the local binding name (the side the source uses
|
|
125
|
+
# to reference the imported value). For `import { foo as bar }`
|
|
126
|
+
# this is "bar"; for `import * as styles` this is "styles";
|
|
127
|
+
# for `import Default` this is "Default".
|
|
128
|
+
# source : String — the module specifier verbatim
|
|
129
|
+
# (e.g. "./styles.module.css", "@apollo/client", "react").
|
|
130
|
+
# Lets backends apply per-source policy later (e.g. always
|
|
131
|
+
# strip `*.module.css` references).
|
|
132
|
+
# kind : Symbol — :default | :named | :namespace.
|
|
133
|
+
# imported_name : String? — original exported name from the source module.
|
|
134
|
+
# For `import { ChevronRight as CR } from "lucide-react"`,
|
|
135
|
+
# `name` is `"CR"` and `imported_name` is `"ChevronRight"`.
|
|
136
|
+
# nil for default / namespace imports where there's no
|
|
137
|
+
# distinct exported name. Backends that look up vendored
|
|
138
|
+
# data by canonical name (icons) need the imported name;
|
|
139
|
+
# most callers want the local binding.
|
|
140
|
+
ModuleImport = Data.define(:name, :source, :kind, :imported_name) do
|
|
54
141
|
include Node
|
|
55
142
|
end
|
|
56
143
|
|
|
@@ -64,22 +151,116 @@ module JsxRosetta
|
|
|
64
151
|
include Node
|
|
65
152
|
end
|
|
66
153
|
|
|
67
|
-
# A
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
154
|
+
# A module-level call to `cva()` from class-variance-authority. shadcn
|
|
155
|
+
# components ubiquitously use this builder to attach a base class string
|
|
156
|
+
# plus per-axis variant maps to a JSX component. The translator
|
|
157
|
+
# recognizes the pattern at lowering time so backends can emit real
|
|
158
|
+
# Ruby constants (`FOO_BASE_CLASS`, `FOO_VARIANT_CLASSES`) instead of
|
|
159
|
+
# leaving the call as a TODO comment, and so the use-site
|
|
160
|
+
# `cn(fooVariants({ variant }), className)` translates to a proper
|
|
161
|
+
# Ruby string interpolation.
|
|
162
|
+
#
|
|
163
|
+
# name : String — the const binding name (e.g. "buttonVariants").
|
|
164
|
+
# base_class : String — the first string argument to cva().
|
|
165
|
+
# variants : Hash[String => Hash[String => String]]
|
|
166
|
+
# — { "variant" => { "default" => "...", "outline" => "..." } }
|
|
167
|
+
# default_variants : Hash[String => String] — per-axis default value name
|
|
168
|
+
# (matched against the variant axis keys).
|
|
169
|
+
# compound_source : String | nil — INTENTIONALLY UNPARSED verbatim JS
|
|
170
|
+
# source of any `compoundVariants` entry. Field name
|
|
171
|
+
# reads structural; it isn't — backends only print
|
|
172
|
+
# it as a TODO comment alongside the constants since
|
|
173
|
+
# compoundVariants semantics aren't supported in the
|
|
174
|
+
# first cut.
|
|
175
|
+
CvaBinding = Data.define(:name, :base_class, :variants, :default_variants, :compound_source) do
|
|
176
|
+
include Node
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A literal-shaped module-level `const` declaration that lowers to a real
|
|
180
|
+
# Ruby constant. Distinct from `LocalBinding` (verbatim TODO block) and
|
|
181
|
+
# `CvaBinding` (structured cva variants). The detector accepts initializers
|
|
182
|
+
# whose value reduces to a Ruby-literal-friendly object — strings, numbers,
|
|
183
|
+
# booleans, null, arrays/hashes of the same. Non-literal initializers
|
|
184
|
+
# (call expressions, identifier references, JSX) still fall through to
|
|
185
|
+
# the `LocalBinding` path so their verbatim source surfaces in the
|
|
186
|
+
# module-bindings TODO block.
|
|
187
|
+
#
|
|
188
|
+
# name : String — original JS identifier (e.g. "TAGS", "COLUMNS").
|
|
189
|
+
# constant_name : String — Ruby constant identifier emitted above the class
|
|
190
|
+
# (`AST::Inflector.underscore(name).upcase`). Stored on the
|
|
191
|
+
# IR so future collision-detection has somewhere to bind.
|
|
192
|
+
# value : Object — Ruby-literal-friendly value (String, Integer,
|
|
193
|
+
# Float, true, false, nil, Array of the same, Hash with
|
|
194
|
+
# String keys mapping to the same). Backends call `.inspect`
|
|
195
|
+
# to emit the literal text.
|
|
196
|
+
ModuleConstant = Data.define(:name, :constant_name, :value) do
|
|
197
|
+
include Node
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A className attribute value that resolves to a known cva binding's
|
|
201
|
+
# call shape — `className={cn(buttonVariants({ variant, size }),
|
|
202
|
+
# className)}` or the no-cn direct form `className={buttonVariants({
|
|
203
|
+
# variant })}`. The translator recognizes the AST shape at lowering
|
|
204
|
+
# so the backend never has to regex over verbatim JS source; this
|
|
205
|
+
# naturally handles literal-pinned axes, reversed arg order, and
|
|
206
|
+
# the cn-vs-no-cn forms.
|
|
207
|
+
#
|
|
208
|
+
# binding_name : String — referenced cva binding's const name.
|
|
209
|
+
# axes : [CvaAxisPair] — preserved in JSX source order.
|
|
210
|
+
# class_arg : Interpolation | nil — the optional trailing className
|
|
211
|
+
# arg from `cn(<cvaCall>, <classArg>)`. Nil for the
|
|
212
|
+
# single-arg `cn(<cvaCall>)` and the no-cn direct
|
|
213
|
+
# forms.
|
|
214
|
+
CvaCallSite = Data.define(:binding_name, :axes, :class_arg) do
|
|
215
|
+
include Node
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# One axis-value pair inside a cva call's options object. The
|
|
219
|
+
# discriminator `kind` tells the backend how to render the value:
|
|
71
220
|
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
221
|
+
# :prop_ref — JS identifier referencing a prop (`{ variant }` or
|
|
222
|
+
# `{ variant: someProp }`). Backends render as
|
|
223
|
+
# `@snake_case` against the receiving Phlex component.
|
|
224
|
+
# :literal_string — `{ variant: "default" }` — the literal value
|
|
225
|
+
# is the variant-table key.
|
|
226
|
+
# :literal_other — `{ size: 42 }` / `{ active: true }` — Ruby
|
|
227
|
+
# literal passed through to the bracket key.
|
|
228
|
+
# :literal_nil — `{ variant: null }` or `undefined`.
|
|
229
|
+
CvaAxisPair = Data.define(:axis, :kind, :source) do
|
|
230
|
+
include Node
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A hook invocation detected in the component body. Covers React's
|
|
234
|
+
# built-in hooks plus framework hooks we recognize (Apollo's `useQuery`/
|
|
235
|
+
# `useMutation`/etc., Next.js's `useRouter`/`usePathname`/etc.).
|
|
236
|
+
# Surfaced separately from local_bindings so backends can emit a more
|
|
237
|
+
# specific TODO pointing at the Rails equivalent for each library,
|
|
238
|
+
# instead of a generic "translate this JS".
|
|
239
|
+
#
|
|
240
|
+
# hook : String — hook function name (`"useState"`, `"useQuery"`, …)
|
|
241
|
+
# source : String — verbatim JS of the entire statement.
|
|
242
|
+
# library : Symbol — `:react`, `:apollo`, or `:next_js`. Backends
|
|
243
|
+
# group hooks by library and emit one TODO block per group,
|
|
244
|
+
# since each library maps to a different Rails analog.
|
|
245
|
+
# operation : String | nil — for Apollo hooks called with a bare-Identifier
|
|
246
|
+
# first argument (`useQuery(GET_USERS_QUERY, …)`), the
|
|
247
|
+
# captured operation name. nil when the first argument is
|
|
248
|
+
# not a simple Identifier, or when the hook isn't Apollo.
|
|
249
|
+
# Backends echo it in the TODO so the reviewer can match
|
|
250
|
+
# the operation back to its GraphQL document and to the
|
|
251
|
+
# Rails controller / model fetch it should become.
|
|
252
|
+
ReactHookCall = Data.define(:hook, :source, :library, :operation) do
|
|
75
253
|
include Node
|
|
76
254
|
end
|
|
77
255
|
|
|
78
256
|
# A component prop, possibly with a default value.
|
|
79
257
|
#
|
|
80
|
-
# name
|
|
81
|
-
# default
|
|
82
|
-
|
|
258
|
+
# name : String — the prop name on the parent (e.g. "data-testid").
|
|
259
|
+
# default : Interpolation | nil
|
|
260
|
+
# alias_name : String | nil — the local binding name inside the body when
|
|
261
|
+
# the destructure renames it (`"data-testid": dataTestId`).
|
|
262
|
+
# Use sites of the alias resolve to the prop's ivar.
|
|
263
|
+
Prop = Data.define(:name, :default, :alias_name) do
|
|
83
264
|
include Node
|
|
84
265
|
end
|
|
85
266
|
|
|
@@ -279,7 +460,17 @@ module JsxRosetta
|
|
|
279
460
|
# `name != original_name`, backends emit a collision
|
|
280
461
|
# marker comment in the generated controller JS so the
|
|
281
462
|
# reviewer can see the silent rename.
|
|
282
|
-
|
|
463
|
+
# params : [String | nil] — original arrow/function parameter
|
|
464
|
+
# names (e.g. `["e"]`, `["event"]`, or `[]` for
|
|
465
|
+
# `() => …`). A `nil` entry signals a non-identifier
|
|
466
|
+
# param (destructured `({target}) =>`, rest `(...args) =>`)
|
|
467
|
+
# that the pasted body can't safely reference — backends
|
|
468
|
+
# bail to the TODO form when any entry is nil.
|
|
469
|
+
# body_is_block : Boolean — true when the arrow body was a BlockStatement
|
|
470
|
+
# (`(e) => { … }`), false for an expression-form body
|
|
471
|
+
# (`(e) => doX(e)`). Backends use this to decide whether
|
|
472
|
+
# to strip outer braces when pasting verbatim.
|
|
473
|
+
StimulusMethod = Data.define(:name, :body_source, :original_name, :params, :body_is_block) do
|
|
283
474
|
include Node
|
|
284
475
|
end
|
|
285
476
|
|
|
@@ -333,6 +524,25 @@ module JsxRosetta
|
|
|
333
524
|
include Node
|
|
334
525
|
end
|
|
335
526
|
|
|
527
|
+
# An arrow/function expression whose body isn't JSX — typically a
|
|
528
|
+
# JSX event handler on a PascalCase component (`onClick={() => doX()}`)
|
|
529
|
+
# or a non-JSX-returning callback prop. We can't translate arbitrary
|
|
530
|
+
# JS bodies to Ruby procedurally, so backends emit a stub method on
|
|
531
|
+
# the class with the verbatim JS body preserved as a TODO comment;
|
|
532
|
+
# the kwarg at the use site becomes `method(:method_name)` so the
|
|
533
|
+
# receiving component has a callable reference and the structural
|
|
534
|
+
# attachment is preserved end-to-end.
|
|
535
|
+
#
|
|
536
|
+
# params : [String]
|
|
537
|
+
# body_source : String — verbatim JS of the arrow/function body
|
|
538
|
+
# (the part after `=>` for arrows, or the full block
|
|
539
|
+
# body for FunctionExpressions). Preserved as a
|
|
540
|
+
# comment in the emitted method so the reviewer can
|
|
541
|
+
# translate the behavior to Ruby.
|
|
542
|
+
EventHandler = Data.define(:params, :body_source) do
|
|
543
|
+
include Node
|
|
544
|
+
end
|
|
545
|
+
|
|
336
546
|
# A render-prop child: `<Form.List>{(fields) => <div>{fields}</div>}</Form.List>`.
|
|
337
547
|
# Backends emit this as a Ruby block on the render call, with the params
|
|
338
548
|
# bound as block arguments. Distinct from Loop (which iterates an
|
|
@@ -343,5 +553,40 @@ module JsxRosetta
|
|
|
343
553
|
RenderProp = Data.define(:params, :body) do
|
|
344
554
|
include Node
|
|
345
555
|
end
|
|
556
|
+
|
|
557
|
+
# The Next.js `_app.tsx` content slot — what JSX writes as
|
|
558
|
+
# `<Component {...pageProps} />`. Lowered to a distinguished node so
|
|
559
|
+
# the Phlex backend can emit `yield` (the Rails layout convention) in
|
|
560
|
+
# its place when the host file is a layout. Outside the layout
|
|
561
|
+
# rendering path the node still emits a yield, since the only way to
|
|
562
|
+
# produce one in our IR is to hit this exact source shape.
|
|
563
|
+
LayoutYield = Data.define do
|
|
564
|
+
include Node
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# A locally-declared JSX-returning arrow that's invoked inside the
|
|
568
|
+
# render body: `const renderHeader = (count) => <h1>{count}</h1>;
|
|
569
|
+
# ... {renderHeader(headerCount)}`. Backends emit one private method
|
|
570
|
+
# per RenderMethod on the generated class and reference it via a
|
|
571
|
+
# LocalRenderCall at each use site.
|
|
572
|
+
#
|
|
573
|
+
# name : String — snake_case method name on the class.
|
|
574
|
+
# params : [String] — arrow param names (camelCase preserved; backends
|
|
575
|
+
# snake_case to form Ruby parameter names).
|
|
576
|
+
# body : Node — the lowered IR node produced by the arrow's body.
|
|
577
|
+
RenderMethod = Data.define(:name, :params, :body) do
|
|
578
|
+
include Node
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# A call to a locally-declared JSX-returning arrow at its use site.
|
|
582
|
+
# Pairs with a sibling RenderMethod on Component#render_methods.
|
|
583
|
+
#
|
|
584
|
+
# method_name : String — snake_case method name (matches RenderMethod#name).
|
|
585
|
+
# args : [Interpolation] — argument expressions captured verbatim
|
|
586
|
+
# (each Interpolation's expression is translated by the
|
|
587
|
+
# backend's ExpressionTranslator at emission time).
|
|
588
|
+
LocalRenderCall = Data.define(:method_name, :args) do
|
|
589
|
+
include Node
|
|
590
|
+
end
|
|
346
591
|
end
|
|
347
592
|
end
|
data/lib/jsx_rosetta/ir.rb
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ir/types"
|
|
4
|
+
require_relative "ir/radix_registry"
|
|
4
5
|
require_relative "ir/lowering"
|
|
5
6
|
|
|
6
7
|
module JsxRosetta
|
|
7
8
|
module IR
|
|
8
|
-
def self.lower(ast_file, source:)
|
|
9
|
-
Lowering.lower(ast_file, source: source)
|
|
9
|
+
def self.lower(ast_file, source:, keep_slot: false)
|
|
10
|
+
Lowering.lower(ast_file, source: source, keep_slot: keep_slot)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def self.lower_all(ast_file, source:)
|
|
13
|
-
Lowering.lower_all(ast_file, source: source)
|
|
13
|
+
def self.lower_all(ast_file, source:, keep_slot: false)
|
|
14
|
+
Lowering.lower_all(ast_file, source: source, keep_slot: keep_slot)
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|