jsx_rosetta 0.2.0 → 0.4.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 +205 -0
- data/README.md +69 -7
- data/lib/jsx_rosetta/ast/inflector.rb +1 -0
- data/lib/jsx_rosetta/ast/node.rb +28 -1
- data/lib/jsx_rosetta/backend/phlex.rb +756 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +3 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +54 -12
- data/lib/jsx_rosetta/backend/view_component.rb +169 -69
- data/lib/jsx_rosetta/backend.rb +1 -0
- data/lib/jsx_rosetta/cli.rb +33 -5
- data/lib/jsx_rosetta/ir/lowering.rb +631 -104
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +148 -0
- data/lib/jsx_rosetta/ir/types.rb +77 -6
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +7 -6
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7b64119bbfe86413da7a779ada7e47cab06088411cb4667be2e79f8cfba3464
|
|
4
|
+
data.tar.gz: c1c1ac742642015a6ae7037538a34bf58b42b6e2fc232bedf48f8660b13a640d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44a3dc2fc89b0dbb59ca5026f2cb27cc7f22e498cf125483725e1db07a39db1e45616baff9766be82bc52e443b33bf64180b90175660536c91f4bfc89e5b1926
|
|
7
|
+
data.tar.gz: a9c999e35af23ff2aba38a02ff57fc6229bf051dc339db859c341c9c1d607f88278542abd07a742ac84840616a305dcfe48b8bad32767ffbf5fc8d08c45ef7a9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,210 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.4.0] - 2026-05-11
|
|
6
|
+
|
|
7
|
+
Closes nine translation gaps identified during a sample review of 12
|
|
8
|
+
random Phlex outputs from the v0.3.0 stress run. Each gap was a silent
|
|
9
|
+
drop, a render-time NameError, or a semantic mistranslation — the
|
|
10
|
+
generated Ruby parsed but didn't behave like the source JSX.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Render-prop / function-as-children support.**
|
|
15
|
+
`<Form.List>{(fields) => <p>{fields}</p>}</Form.List>` now lowers to a
|
|
16
|
+
new `IR::RenderProp` and emits as a Ruby block on the parent `render`
|
|
17
|
+
call: `render Form::List.new do |fields| ... end`. Both Phlex and
|
|
18
|
+
ViewComponent backends emit the block; param names snake_case to match
|
|
19
|
+
Ruby convention. Previously dropped as `plain "[untranslated: ...]"`.
|
|
20
|
+
- **Recursive object/array/lambda translation in attribute values.**
|
|
21
|
+
New IR types `ObjectLiteral`, `ArrayLiteral`, and `Lambda` mean that
|
|
22
|
+
`<Select options={[{ value: 10, label: "10 / page" }]} />` now emits
|
|
23
|
+
`options: [{ value: 10, label: "10 / page" }]` (Ruby array of hashes)
|
|
24
|
+
instead of `options: nil` with a TODO. Identifier keys snake_case
|
|
25
|
+
(`dataLabel` → `data_label`); nested literals recurse. Function-valued
|
|
26
|
+
properties — e.g. AG-Grid `render: (v) => <Tag>{v}</Tag>` — extract to
|
|
27
|
+
private methods on the class (`def render_render(v); span do; ...; end`)
|
|
28
|
+
and the property emits as `render: method(:render_render)` so the
|
|
29
|
+
body has access to the Phlex tag.* helpers.
|
|
30
|
+
- **Module-level constants captured into `Component#module_bindings`.**
|
|
31
|
+
`const FOO = 400; function X() { return <p>{FOO}</p>; }` no longer
|
|
32
|
+
silently drops the FOO declaration — the backend emits a TODO comment
|
|
33
|
+
block above the class with the original source so the reviewer either
|
|
34
|
+
translates it to a Ruby constant or moves it to a Rails initializer.
|
|
35
|
+
- **Destructure-pattern names recognized by the translator.**
|
|
36
|
+
`const [count, setCount] = useState(0); <p>{count}</p>` previously
|
|
37
|
+
emitted a bare `plain count` that NameErrored at render time. The
|
|
38
|
+
destructured names are now captured in `Component#local_binding_names`
|
|
39
|
+
and the `ExpressionTranslator` emits a `nil` placeholder for them so
|
|
40
|
+
the file at least loads. Covers `ArrayPattern`, `ObjectPattern`
|
|
41
|
+
(including aliased properties, `AssignmentPattern` defaults, and
|
|
42
|
+
`RestElement`), and hook-tuple destructures.
|
|
43
|
+
- **Member-expression destructure resolution.** `const { Content } = Layout`
|
|
44
|
+
followed by `<Content/>` now resolves to `Layout::Content`, not a
|
|
45
|
+
free-floating `ContentComponent`. Multiple destructured names from the
|
|
46
|
+
same parent identifier all resolve correctly.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Component-prop callbacks no longer over-promote to Stimulus.**
|
|
51
|
+
`<Select onChange={onChange} />` (PascalCase tag) previously emitted
|
|
52
|
+
`data-action="change->foo#onChange"` — a Stimulus action descriptor
|
|
53
|
+
that never fires because the receiving component is a Ruby class, not
|
|
54
|
+
a DOM element. The lowering now checks `html_element?(tag)` before
|
|
55
|
+
promoting `on*` attrs and treats component-prop callbacks as regular
|
|
56
|
+
`IR::Attribute` kwargs. Stimulus promotion still applies to lowercase
|
|
57
|
+
HTML tags as before. Closes a regression introduced by the v0.3.0
|
|
58
|
+
prop-handler Stimulus promotion.
|
|
59
|
+
- **Spread-of-nil no longer raises at render time.** `<div {...maybeNil}>`
|
|
60
|
+
used to emit `**@maybe_nil`, which raises when the prop is `nil`. Both
|
|
61
|
+
Phlex and ViewComponent backends now wrap as `**(@maybe_nil || {})`.
|
|
62
|
+
Cheap to emit unconditionally and idempotent.
|
|
63
|
+
- **Duplicate handler names are no longer silently renamed.** Two
|
|
64
|
+
`onClick={handleReset}` handlers in one component previously produced
|
|
65
|
+
`handleReset` / `handleReset2` with no marker. `StimulusMethod` now
|
|
66
|
+
carries an `original_name` field, and the generated controller JS
|
|
67
|
+
emits a `// NOTE: method renamed from "handleReset"` comment above the
|
|
68
|
+
renamed method.
|
|
69
|
+
- **ReactNode-typed props get a `raw` hint comment.** When an
|
|
70
|
+
interpolation translates to a bare `@ivar` reference (likely a prop),
|
|
71
|
+
the Phlex backend emits a comment hint —
|
|
72
|
+
`plain @extra # NOTE: use \`raw\` instead of \`plain\` if this is a
|
|
73
|
+
ReactNode-typed prop`. `plain` HTML-escapes its argument, which is
|
|
74
|
+
wrong for prebuilt-markup props but right for strings; we can't tell
|
|
75
|
+
at translation time, so we default to safe (`plain`) and surface the
|
|
76
|
+
choice.
|
|
77
|
+
|
|
78
|
+
### Investigation
|
|
79
|
+
|
|
80
|
+
- **Sibling named exports — not a gap.** Probed four shapes of
|
|
81
|
+
`export default Foo; export function Loading() {}` against the gem;
|
|
82
|
+
all four shapes correctly emit both `foo.rb` and `loading.rb`. Closed
|
|
83
|
+
the suspected gap as a false positive from the random sample.
|
|
84
|
+
|
|
85
|
+
### Refactored
|
|
86
|
+
|
|
87
|
+
- `Lowering` class shrunk by ~150 lines (v0.3.0 prep, now shipping):
|
|
88
|
+
pure-heuristic `ModuleShapeClassifier` lives in its own file; helper
|
|
89
|
+
methods `AST::Node#child`, `#of_type?`, `Node.matches?` replaced ~25
|
|
90
|
+
defensive `is_a?(AST::Node) && type ==` checks; class/style rendering
|
|
91
|
+
in the ViewComponent backend deduplicated across HTML-vs-Ruby output
|
|
92
|
+
formats; `tag_builder_data_action` replaced its "parse what I just
|
|
93
|
+
emitted" heuristic with a structured `EventDescriptor` intermediate.
|
|
94
|
+
|
|
95
|
+
### Stress test outcome
|
|
96
|
+
|
|
97
|
+
- 929-file Phlex stress run on `reserv-web`: 887/929 clean translations
|
|
98
|
+
(unchanged — rejection logic untouched), **0/1224 syntax failures**
|
|
99
|
+
(down from 25 on v0.3.0). All emitted `.rb` files now pass `ruby -c`.
|
|
100
|
+
- Five residual bugs were caught during the v0.4.0 stress rerun and
|
|
101
|
+
fixed inline:
|
|
102
|
+
- Prop default expressions that translated to `nil # TODO: ...` inside
|
|
103
|
+
`initialize(...)` swallowed the closing `)`. Prop defaults now route
|
|
104
|
+
through the same recursive lowering as attribute values.
|
|
105
|
+
- Multi-line JSX comments only prefixed the first line with `#`. Every
|
|
106
|
+
line of a comment now gets a `#` prefix.
|
|
107
|
+
- Template literals with inner `"` or `\\` characters could break the
|
|
108
|
+
surrounding Ruby string. Literal segments now escape both.
|
|
109
|
+
- `token.blue` (where `token` was a captured local binding) translated
|
|
110
|
+
to `nil.blue` — `NoMethodError` at render time. Member-chain roots
|
|
111
|
+
that resolve to a known-local binding now fall through to the
|
|
112
|
+
snake_case bare reference with an unresolved marker rather than the
|
|
113
|
+
`nil` placeholder.
|
|
114
|
+
- `["a", "b"].map((x) => <li/>)` lost the array literal and emitted
|
|
115
|
+
`[].each` because the translator can't parse `[...]`. The lowering
|
|
116
|
+
now recognizes ArrayExpression iterables and routes them through
|
|
117
|
+
the recursive ArrayLiteral path.
|
|
118
|
+
|
|
119
|
+
### Spec count
|
|
120
|
+
|
|
121
|
+
- Up to 343 examples (from 304), all green. `bundle exec rubocop` clean.
|
|
122
|
+
|
|
123
|
+
## [0.3.0] - 2026-05-10
|
|
124
|
+
|
|
125
|
+
Driven by a 929-file stress run against the entire `reserv-web` codebase
|
|
126
|
+
(`reserv-web/src/` + `reserv-web/pages/` + `packages/`). Baseline outcome
|
|
127
|
+
on v0.2.0: 838/929 (90.2%) clean exit, 91 hard failures across 5 distinct
|
|
128
|
+
error categories. This release ships fixes for all five plus a follow-up
|
|
129
|
+
that opens up lowercase JSX-returning helpers as components, lifting the
|
|
130
|
+
corpus to **887/929 (95.5%) clean exit**. The 42 remaining failures are
|
|
131
|
+
non-component modules (utility/hook libraries, AG-Grid column
|
|
132
|
+
descriptors, class-based ErrorBoundary components, side-effect
|
|
133
|
+
initializers); each now reports a classifier-tagged error that explains
|
|
134
|
+
*why* it didn't translate.
|
|
135
|
+
|
|
136
|
+
### Fixed
|
|
137
|
+
|
|
138
|
+
- **StringLiteral destructure keys no longer crash.**
|
|
139
|
+
`function X({ "data-testid": dataTestId })` previously surfaced as a
|
|
140
|
+
`bundler: failed to load command` after `Inflector.underscore(nil)` —
|
|
141
|
+
the v0.2.0 ObjectPattern fix only handled `Identifier` keys. The
|
|
142
|
+
lowering now reads `:value` from `StringLiteral` keys. Closes 11 files.
|
|
143
|
+
- **Hyphenated prop names emit valid Ruby.** `Inflector.underscore` now
|
|
144
|
+
converts hyphens to underscores, so `data-testid` becomes `data_testid`
|
|
145
|
+
in Ruby identifiers (kwarg, ivar). HTML attribute names continue to
|
|
146
|
+
preserve hyphens — they're rendered from `Attribute.name` directly.
|
|
147
|
+
|
|
148
|
+
### Added — return-shape lowering
|
|
149
|
+
|
|
150
|
+
- **`return null;`, `return identifier;`, `return call();`** in return
|
|
151
|
+
position. Previously each raised "unexpected JSX node in lowering: …"
|
|
152
|
+
and crashed translation. The return-position dispatcher now accepts:
|
|
153
|
+
- `NullLiteral` → empty `IR::Text` (renders nothing in ERB; valid as a
|
|
154
|
+
Conditional alternate)
|
|
155
|
+
- `Identifier` → `IR::Interpolation`, with inlining when the identifier
|
|
156
|
+
is bound to a JSX local (`const card = <p/>; return card;`)
|
|
157
|
+
- `CallExpression` → `IR::Interpolation` of the verbatim source
|
|
158
|
+
Closes 20 files.
|
|
159
|
+
- **Trailing `switch` and `try` body shapes.** Component bodies whose
|
|
160
|
+
only return path lives inside a trailing `switch (subject) { case A:
|
|
161
|
+
return X; default: return Y; }` or `try { return X; } catch …` now
|
|
162
|
+
lower cleanly. Switch fall-through groups (`case A: case B: return X;`)
|
|
163
|
+
emit a single Conditional with an OR-joined test
|
|
164
|
+
(`subject === A || subject === B`). Cases with multi-statement bodies
|
|
165
|
+
(other than a single block-wrapped return) bail and the gem still
|
|
166
|
+
raises "no return statement". Catch/finally handlers are dropped —
|
|
167
|
+
they typically encode JS-only error semantics. Closes ~5 files.
|
|
168
|
+
- **Leading `if (X) return Y;` guards** wrap around any trailing return
|
|
169
|
+
structure (return / if-chain / switch / try). Previously a guard
|
|
170
|
+
before a trailing if-chain or switch was silently dropped (or caused
|
|
171
|
+
the surrounding structure to bail).
|
|
172
|
+
|
|
173
|
+
### Added — what counts as a component
|
|
174
|
+
|
|
175
|
+
- **Lowercase-named JSX-returning helpers** (`textRender`,
|
|
176
|
+
`booleanRender`, `cellFor`) now lower as components. The PascalCase
|
|
177
|
+
rule was tightened to "PascalCase OR (lowercase + body returns JSX,
|
|
178
|
+
excluding `use*` hook names)." A pre-lowering AST scan walks the
|
|
179
|
+
function body's return paths (recursing into BlockStatement,
|
|
180
|
+
IfStatement, SwitchStatement, TryStatement, ConditionalExpression,
|
|
181
|
+
LogicalExpression) to detect any reachable JSX value. Closes ~10
|
|
182
|
+
utility-renderer files.
|
|
183
|
+
- **Permissive return-position dispatcher.** Function bodies that
|
|
184
|
+
return arbitrary non-JSX expressions (`return money.formattedValue;`,
|
|
185
|
+
`return computeValue();`, `` return `${name}` ``) now lower cleanly.
|
|
186
|
+
Member access, template literals, binary expressions, and other
|
|
187
|
+
bare-expression returns become `IR::Interpolation`; string and
|
|
188
|
+
numeric literals become `IR::Text`. This is what makes lowercase
|
|
189
|
+
JSX-helpers tractable — their guard returns are usually non-JSX.
|
|
190
|
+
- **Implicit-return arrow bodies of any shape.** Previously
|
|
191
|
+
`const X = () => <div/>` worked but `const X = () => cond ? <a/> : <b/>`
|
|
192
|
+
raised "unsupported component body". The body dispatcher now routes
|
|
193
|
+
any non-block body through the return-position dispatcher.
|
|
194
|
+
|
|
195
|
+
### Improved
|
|
196
|
+
|
|
197
|
+
- **Module-shape classifier with eight labels and per-shape messages.**
|
|
198
|
+
Every `no component function found in module` error now appends a
|
|
199
|
+
specific label and a concrete suggestion: `:hoc_wrapped` (peel
|
|
200
|
+
`React.memo` / `forwardRef` / `lazy` / `observer`), `:class_component`
|
|
201
|
+
(rewrite as a function), `:hooks_only` (move behavior to Stimulus,
|
|
202
|
+
state to ivars), `:columns_data` (data lives in models or presenters),
|
|
203
|
+
`:types_only` (TypeScript types erase), `:utils_only` (only
|
|
204
|
+
JSX-returning helpers translate), `:mixed_exports` (split the file),
|
|
205
|
+
`:side_effects_only` (use a Rails initializer). Stress-test
|
|
206
|
+
validation: 42 remaining failures, 0 unlabeled.
|
|
207
|
+
|
|
3
208
|
## [0.2.0] - 2026-05-10
|
|
4
209
|
|
|
5
210
|
Driven by an empirical probe of v0.1.0 against a 39-file Next.js production
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ gem itself emits.
|
|
|
13
13
|
|
|
14
14
|
```
|
|
15
15
|
JSX text ──► Babel AST ──► Ruby AST ──► IR ──► backend ──► .rb / .html.erb / _controller.js
|
|
16
|
-
(Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / RoutesScript)
|
|
16
|
+
(Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / Phlex / RoutesScript)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
@@ -40,6 +40,17 @@ jsx_rosetta translate path/to/Button.tsx -o app/components
|
|
|
40
40
|
# app/components/button_component/button_component.html.erb
|
|
41
41
|
# app/components/button_component/button_controller.js (when inline arrow handlers)
|
|
42
42
|
|
|
43
|
+
# Translate as a single-file Phlex 2.x view class (no separate ERB)
|
|
44
|
+
jsx_rosetta translate path/to/Button.tsx --as=phlex -o app/components
|
|
45
|
+
# → app/components/button.rb
|
|
46
|
+
# app/components/button_controller.js (when inline arrow handlers)
|
|
47
|
+
# With suffix:
|
|
48
|
+
jsx_rosetta translate path/to/Button.tsx --as=phlex --phlex-suffix=Component -o app/components
|
|
49
|
+
# → app/components/button_component.rb (class ButtonComponent)
|
|
50
|
+
# With namespace:
|
|
51
|
+
jsx_rosetta translate path/to/Button.tsx --as=phlex --phlex-namespace=Components -o app/components
|
|
52
|
+
# → app/components/button.rb (class Components::Button)
|
|
53
|
+
|
|
43
54
|
# Translate a route-tied page as a Rails view template (no Ruby class)
|
|
44
55
|
jsx_rosetta translate path/to/Home.tsx --as=view -o app/views/home
|
|
45
56
|
# → app/views/home/home.html.erb (rename to index.html.erb)
|
|
@@ -96,12 +107,17 @@ JsxRosetta::Backend::RoutesScript.new(source_path: "router.tsx").emit(route_tree
|
|
|
96
107
|
```
|
|
97
108
|
|
|
98
109
|
Optional kwargs to `JsxRosetta.translate`:
|
|
99
|
-
- `backend: :view_component | :rails_view` (default `:view_component`)
|
|
100
|
-
- `
|
|
101
|
-
- `
|
|
110
|
+
- `backend: :view_component | :rails_view | :phlex` (default `:view_component`)
|
|
111
|
+
- `backend_options: Hash` — per-backend options (see below)
|
|
112
|
+
- `helpers: nil | Hash | false` — JSX-name → Rails-helper mapping (see below; ViewComponent/RailsView only)
|
|
113
|
+
- `layout: :sidecar | :flat` (default `:sidecar`; ViewComponent only)
|
|
102
114
|
- `typescript: true` — force the TypeScript Babel plugin
|
|
103
115
|
- `source_filename:` — surfaces in parse errors
|
|
104
116
|
|
|
117
|
+
Phlex backend options (pass via `backend_options:`):
|
|
118
|
+
- `suffix: "Component"` — append a suffix to the class name and file name. Mutually exclusive with `namespace:`.
|
|
119
|
+
- `namespace: "Components"` — wrap the class in a `module Namespace` block. Mutually exclusive with `suffix:`.
|
|
120
|
+
|
|
105
121
|
## What translates
|
|
106
122
|
|
|
107
123
|
| JSX construct | Translation |
|
|
@@ -159,13 +175,13 @@ auto-perform. Common cases:
|
|
|
159
175
|
recreate React's runtime in Ruby.
|
|
160
176
|
- React Router's data-router form (`createBrowserRouter([...])`). Only the
|
|
161
177
|
declarative `<Routes><Route>` shape is parsed today.
|
|
162
|
-
- Backends other than ViewComponent and
|
|
178
|
+
- Backends other than ViewComponent, RailsView, and Phlex (Slim, LiveView).
|
|
163
179
|
- Suspense → Turbo Frame mapping (depends on a data-fetching translation
|
|
164
180
|
story we don't have yet).
|
|
165
181
|
|
|
166
|
-
##
|
|
182
|
+
## Emission targets
|
|
167
183
|
|
|
168
|
-
|
|
184
|
+
Three backends, picked via `--as` on the CLI or `backend:` in the API:
|
|
169
185
|
|
|
170
186
|
**`:view_component` (default)** — emits a sidecar layout matching
|
|
171
187
|
ViewComponent's `--sidecar` generator convention:
|
|
@@ -185,6 +201,52 @@ Pass `layout: :flat` to revert to the older flat layout
|
|
|
185
201
|
no Ruby class and no sidecar. Place at `app/views/<controller>/<action>.html.erb`;
|
|
186
202
|
the controller's instance variables become the template's `@x` references.
|
|
187
203
|
|
|
204
|
+
**`:phlex`** — emits a single-file [Phlex 2.x](https://www.phlex.fun/) view
|
|
205
|
+
class. The JSX template lives inside `view_template` as Ruby method calls,
|
|
206
|
+
not in a separate ERB file:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# app/components/card.rb (default mode)
|
|
210
|
+
# app/components/card_component.rb (suffix: "Component")
|
|
211
|
+
class Card < Phlex::HTML
|
|
212
|
+
def initialize(title: nil)
|
|
213
|
+
@title = title
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def view_template
|
|
217
|
+
div(class: "card") do
|
|
218
|
+
h2 { plain @title }
|
|
219
|
+
yield
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Three mutually exclusive naming strategies (configurable):
|
|
226
|
+
- **default** — `class FlashyHeader < Phlex::HTML` (collision-prone in large
|
|
227
|
+
apps where names like `Card` / `User` / `Image` overlap with models).
|
|
228
|
+
- **`suffix: "Component"`** — `class FlashyHeaderComponent < Phlex::HTML`,
|
|
229
|
+
matches the ViewComponent layout, safest for migrations.
|
|
230
|
+
- **`namespace: "Components"`** — wraps the class in a `module Components`
|
|
231
|
+
block. Cross-references inside the namespace stay bare (`render Card.new`)
|
|
232
|
+
and resolve via Ruby's constant lookup.
|
|
233
|
+
|
|
234
|
+
Stimulus handlers still emit a sibling `_controller.js` skeleton alongside
|
|
235
|
+
the `.rb` — the `data-controller`/`data-action` attrs go inline on the
|
|
236
|
+
element (emitted as `data_controller:`/`data_action:` kwargs; Phlex
|
|
237
|
+
auto-hyphenates underscores at render time), the handler body goes into
|
|
238
|
+
the JS skeleton as a TODO comment.
|
|
239
|
+
|
|
240
|
+
Attribute emission specifics:
|
|
241
|
+
- Hyphenated JSX attrs (`data-testid`, `aria-label`) emit as snake_case
|
|
242
|
+
kwargs (`data_testid:`, `aria_label:`). Phlex 2.x converts the
|
|
243
|
+
underscores back to hyphens on render.
|
|
244
|
+
- camelCase JSX attrs (`viewBox`, `preserveAspectRatio`) preserve
|
|
245
|
+
verbatim — Phlex only converts underscores, so SVG attributes stay
|
|
246
|
+
intact.
|
|
247
|
+
- Attributes with characters that aren't valid Ruby identifiers (e.g.
|
|
248
|
+
`xml:lang`) fall back to a quoted string key inside `**{ "xml:lang" => x }`.
|
|
249
|
+
|
|
188
250
|
## Helper mappings
|
|
189
251
|
|
|
190
252
|
Capitalized JSX tags that have a direct Rails helper analog skip the
|
data/lib/jsx_rosetta/ast/node.rb
CHANGED
|
@@ -66,7 +66,10 @@ module JsxRosetta
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Field access. Accepts snake_case symbols/strings (translated to
|
|
69
|
-
# camelCase) and camelCase strings (used verbatim).
|
|
69
|
+
# camelCase) and camelCase strings (used verbatim). Returns whatever
|
|
70
|
+
# the raw hash contains at that key (Node, Array of Nodes, String,
|
|
71
|
+
# Hash, nil) — caller is responsible for type-checking. For typed
|
|
72
|
+
# access, prefer `#child` (returns Node | nil).
|
|
70
73
|
def [](key)
|
|
71
74
|
raw_key = key.to_s
|
|
72
75
|
return Node.wrap(@raw[raw_key]) if @raw.key?(raw_key)
|
|
@@ -75,6 +78,30 @@ module JsxRosetta
|
|
|
75
78
|
Node.wrap(@raw[camel_key])
|
|
76
79
|
end
|
|
77
80
|
|
|
81
|
+
# Typed child access — returns the wrapped Node at `key`, or nil if
|
|
82
|
+
# absent or non-Node-shaped. Use this in lowering passes to avoid
|
|
83
|
+
# repetitive `is_a?(AST::Node)` defenses.
|
|
84
|
+
def child(key)
|
|
85
|
+
value = self[key]
|
|
86
|
+
value.is_a?(Node) ? value : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Type predicate on a known Node. Use only when the receiver is
|
|
90
|
+
# guaranteed to be a Node — otherwise prefer `Node.matches?` (class
|
|
91
|
+
# method) which tolerates nil / non-Node values from hash lookups.
|
|
92
|
+
# Accepts multiple types: `node.of_type?("StringLiteral", "NumericLiteral")`.
|
|
93
|
+
def of_type?(*types)
|
|
94
|
+
types.include?(type)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Defensive type predicate. True iff `value` is an AST::Node whose
|
|
98
|
+
# type is one of `types`. False for nil, arrays, strings, hashes, or
|
|
99
|
+
# any non-Node — making it safe for raw hash field accesses where
|
|
100
|
+
# the contents may be anything.
|
|
101
|
+
def self.matches?(value, *types)
|
|
102
|
+
value.is_a?(Node) && types.include?(value.type)
|
|
103
|
+
end
|
|
104
|
+
|
|
78
105
|
def dig(*keys)
|
|
79
106
|
keys.reduce(self) do |current, key|
|
|
80
107
|
break nil if current.nil?
|