jsx_rosetta 0.5.1 → 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 +128 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +17 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
- data/lib/jsx_rosetta/backend/view_component.rb +48 -2
- 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 +720 -31
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +187 -3
- 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 +29 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# jsx_rosetta — Next.js filesystem routing → Rails routes/controllers/views
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
The Phlex backend emits `_page.rb` files preserving the source directory structure under `pages/` — including Next.js bracket conventions (`[id]`, `[[...extra]]`) and named segments. Visually inspecting `tmp/stress/phlex_out/reserv-web/pages/` confirms the tree mirrors a Rails route table: top-level files are entry routes, named subdirectories look like resource collections, `[id]/` segments are member routes, deeper nests are nested resources.
|
|
6
|
+
|
|
7
|
+
The gem already has a `Routes` module + `routes_script` backend, but those handle a **different** source: JSX `<Route path=… element={<X/>} />` declarative routes (React Router). The Next.js filesystem-routing case is unaddressed — generated Phlex pages currently land in `pages/...` without any `config/routes.rb`, controllers, or `app/views/` placement, so a user has to hand-wire the Rails side after translation.
|
|
8
|
+
|
|
9
|
+
Goal: ship a deterministic, filesystem-driven path from a Next.js `pages/` directory to a usable Rails skeleton. The directory tree is the source of truth; no JS parsing is required for routing alone.
|
|
10
|
+
|
|
11
|
+
## Slicing
|
|
12
|
+
|
|
13
|
+
Three slices, planned separately, landed in order:
|
|
14
|
+
|
|
15
|
+
1. **Slice 1 — routes only** *(this plan)*. Walk the source `pages/` tree, emit `config/routes.rb`. No file moves, no controllers, no class renames. New CLI subcommand `jsx_rosetta pages-routes`.
|
|
16
|
+
|
|
17
|
+
2. **Slice 2 — controllers + view-placement** *(separate plan after slice 1 lands)*. Emit `app/controllers/<resource>_controller.rb` skeletons. Hook into `translate` so Phlex pages land at `app/views/<controller>/<action>.rb` with class `Views::<Controller>::<Action> < Views::Base` per Phlex Rails convention.
|
|
18
|
+
|
|
19
|
+
3. **Slice 3 — `<Link>`/`href` rewrites to URL helpers** *(separate plan after slice 2)*. With route table from slice 1 in hand, the Phlex backend rewrites literal/simple-template hrefs (`<Link href="/claims/123">`, `<Link href={`/claims/${id}`}>`) into Rails helpers (`claim_path(123)`, `claim_path(id)`). Anything past simple template — dynamic compute, query strings, `router.push` in event handlers — stays verbatim with a TODO (handler bodies are preserved as raw JS in `IR::EventHandler` and rewriting them is the JS-to-Ruby translation territory the project avoids).
|
|
20
|
+
|
|
21
|
+
This plan covers slice 1 in full. Slices 2 and 3 are sketched at the end for context only — they get their own plan files when their predecessor ships.
|
|
22
|
+
|
|
23
|
+
## Slice 1 — design
|
|
24
|
+
|
|
25
|
+
### Input
|
|
26
|
+
|
|
27
|
+
Filesystem-only walker. Reads file paths, not contents.
|
|
28
|
+
|
|
29
|
+
- Input: a directory the user points at (typically `<repo>/pages/` or `<repo>/src/pages/`).
|
|
30
|
+
- File types scanned: `.tsx`, `.jsx` (and optionally `.ts`/`.js` if the user has a JS-only Next.js project; default to TSX/JSX, configurable via flag).
|
|
31
|
+
- No `JsxRosetta.parse` / `JsxRosetta.lower` — the route table is fully encoded in path shape. Parsing 80+ files just to read names that we already have from `Dir.glob` would burn ~30s of Node round-trips for zero added signal.
|
|
32
|
+
- Slice 2 will piggyback on the existing `translate` flow, where parse+lower already runs per file, so the AST/IR is "free" at that point.
|
|
33
|
+
|
|
34
|
+
### Path → Rails route mapping
|
|
35
|
+
|
|
36
|
+
Bracket segments translate to Rails params with camelCase → snake_case via `JsxRosetta::AST::Inflector.underscore`. `[providerSlug]` → `:provider_slug`, `[organizationId]` → `:organization_id`.
|
|
37
|
+
|
|
38
|
+
| Source path (relative to pages/) | Rails route line | Controller#action |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `index.tsx` | `root to: "pages#index"` | PagesController#index |
|
|
41
|
+
| `<name>.tsx` (top-level, not `_*`) | `get "/<name>", to: "pages#<name>"` | PagesController#<name> |
|
|
42
|
+
| `<res>/index.tsx` | `get "/<res>", to: "<res>#index"` | <Res>Controller#index |
|
|
43
|
+
| `<res>/new.tsx` | `get "/<res>/new", to: "<res>#new"` | <Res>Controller#new |
|
|
44
|
+
| `<res>/[id].tsx` | `get "/<res>/:id", to: "<res>#show"` | <Res>Controller#show |
|
|
45
|
+
| `<res>/[id]/index.tsx` | `get "/<res>/:id", to: "<res>#show"` | (same — duplicate-route warning) |
|
|
46
|
+
| `<res>/[id]/edit.tsx` | `get "/<res>/:id/edit", to: "<res>#edit"` | <Res>Controller#edit |
|
|
47
|
+
| `<res>/[id]/<x>.tsx` | `get "/<res>/:id/<x>", to: "<res>#<x>"` | <Res>Controller#<x> |
|
|
48
|
+
| `<res>/[id]/[[...extra]].tsx` | `get "/<res>/:id(/*extra)", to: "<res>#show"` | <Res>Controller#show |
|
|
49
|
+
| `<res>/[id]/<sub>/[sub_id]/...` (deep) | nested params in path; controller = outermost named segment | <Res>Controller#<leaf-action> |
|
|
50
|
+
| `_app.tsx`, `_document.tsx` | **skipped**; commented in output as layout files | (none) |
|
|
51
|
+
| `_error.tsx`, `404.tsx`, `500.tsx` | **skipped**; commented as Rails error handlers (`config.exceptions_app`) | (none) |
|
|
52
|
+
|
|
53
|
+
### Controller-name selection rule
|
|
54
|
+
|
|
55
|
+
- Top-level file → `pages` controller.
|
|
56
|
+
- File inside a subdirectory → controller is the **first non-bracket segment** (the outermost named directory). For `policies/[providerSlug]/[policyId]/edit.tsx`, controller is `policies`; the rest of the path becomes URL params.
|
|
57
|
+
- Nested named dirs *between* the resource and the leaf — e.g., `workflows/[id]/versions/index.tsx` — get expressed as path segments, not separate controllers, in slice 1. Slice 2 may upgrade some of these to namespaces (`Workflows::Versions`) once we have the controller story figured out.
|
|
58
|
+
|
|
59
|
+
### Action-name selection rule
|
|
60
|
+
|
|
61
|
+
| Leaf filename | Inside one or more `[…]` dirs? | Action |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `index.tsx` | no | `index` |
|
|
64
|
+
| `index.tsx` | yes | `show` |
|
|
65
|
+
| `new.tsx` | no | `new` |
|
|
66
|
+
| `edit.tsx` | yes | `edit` |
|
|
67
|
+
| `[id].tsx` (the bracket file *is* the leaf) | n/a | `show` |
|
|
68
|
+
| `[[...extra]].tsx` | n/a | `show` |
|
|
69
|
+
| any other `<name>.tsx` | * | `<name>` (snake_cased) |
|
|
70
|
+
|
|
71
|
+
### Output
|
|
72
|
+
|
|
73
|
+
A complete `config/routes.rb` — wrapped in `Rails.application.routes.draw do … end`, not a snippet. Sections:
|
|
74
|
+
|
|
75
|
+
1. Header comment: source dir, generation timestamp, gem version.
|
|
76
|
+
2. Skipped-files block at top, commented, with TODO markers pointing at Rails layout / error-handling counterparts.
|
|
77
|
+
3. Routes grouped by controller, alphabetized. One blank line between groups. Comment header per group (`# == accounts ==`).
|
|
78
|
+
4. Closing `end`.
|
|
79
|
+
|
|
80
|
+
The emitted file passes `ruby -c`. Suggested companion `rails generate controller …` invocations (matching the existing `routes_script` pattern) are commented out at the bottom — copy-paste-able but not run.
|
|
81
|
+
|
|
82
|
+
### Implementation
|
|
83
|
+
|
|
84
|
+
New module + files. Mirrors the layout of `lib/jsx_rosetta/routes.rb` (sibling to `Routes`, not nested inside it, because the two have unrelated inputs).
|
|
85
|
+
|
|
86
|
+
| Path | Status | Scope |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `lib/jsx_rosetta/pages_routing.rb` | new | `Scanner`, `Route`, `Emitter`, ~150 lines |
|
|
89
|
+
| `lib/jsx_rosetta.rb` | modify | one new `require_relative "jsx_rosetta/pages_routing"` line |
|
|
90
|
+
| `lib/jsx_rosetta/cli.rb` | modify | new `when "pages-routes"` branch + `run_pages_routes` method + help text update |
|
|
91
|
+
| `lib/jsx_rosetta/ir/types.rb` | unchanged | no new IR types needed for slice 1 |
|
|
92
|
+
|
|
93
|
+
Internal shapes:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
module JsxRosetta
|
|
97
|
+
module PagesRouting
|
|
98
|
+
Route = Data.define(:rails_path, :controller, :action, :source_path)
|
|
99
|
+
Skipped = Data.define(:source_path, :reason)
|
|
100
|
+
|
|
101
|
+
module Scanner
|
|
102
|
+
def self.scan(dir, extensions: %w[.tsx .jsx])
|
|
103
|
+
# walks dir, returns [routes:, skipped:]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
module Emitter
|
|
108
|
+
def self.emit(routes:, skipped:, source_dir:)
|
|
109
|
+
# returns String (full routes.rb contents)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Existing utilities to reuse
|
|
117
|
+
|
|
118
|
+
- `JsxRosetta::AST::Inflector.underscore` — for camelCase → snake_case on param names and controller names (already does the right thing for `providerSlug` → `provider_slug`).
|
|
119
|
+
- The existing `RoutesScript` backend's output template (header comment style, `rails generate` invocation phrasing) — copy the style, don't share code, since their inputs differ. Match phrasing for consistency.
|
|
120
|
+
|
|
121
|
+
### Specs
|
|
122
|
+
|
|
123
|
+
Match the existing flat-spec convention (`spec/routes_spec.rb`, `spec/cli_spec.rb` are both single files, not directories).
|
|
124
|
+
|
|
125
|
+
| Path | Status | Coverage |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `spec/pages_routing_spec.rb` | new | Scanner: each row of the mapping table (~15 examples). Emitter: ordering, grouping, comment headers, skipped-files block (~6 examples). Use `Dir.mktmpdir` + `FileUtils.touch` to build synthetic trees. |
|
|
128
|
+
| `spec/cli_spec.rb` | modify | New describe block for `pages-routes` subcommand (~3 examples: happy path, missing dir error, `-o` flag). |
|
|
129
|
+
|
|
130
|
+
Edge cases worth one spec each:
|
|
131
|
+
- Empty `pages/` dir → emit an empty `routes.rb` (with header comment) without crashing.
|
|
132
|
+
- `pages/` with only `_app.tsx`, `_document.tsx` → all-skipped output; route table empty.
|
|
133
|
+
- `pages/` with a single `index.tsx` → exactly one `root` line.
|
|
134
|
+
- Deep nesting (`pages/policies/[providerSlug]/[policyId]/edit.tsx`) → param order preserved, snake_case applied to both.
|
|
135
|
+
- `[...rest]` non-optional rest catch-all — not seen in the stress corpus; treat as `*rest` (no parens) and add one spec.
|
|
136
|
+
|
|
137
|
+
### CLI surface
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
jsx_rosetta pages-routes <pages-dir> [-o <path>] [--ext .tsx,.jsx,.ts,.js]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Defaults: `-o` writes to stdout if absent; `--ext` defaults to `.tsx,.jsx`. Help text updated in `print_help`.
|
|
144
|
+
|
|
145
|
+
### Verification
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
cd /home/sean/code/jsx_rosetta
|
|
149
|
+
bundle exec rspec spec/pages_routing_spec.rb spec/cli_spec.rb
|
|
150
|
+
bundle exec rubocop lib/jsx_rosetta/pages_routing.rb lib/jsx_rosetta/cli.rb spec/pages_routing_spec.rb
|
|
151
|
+
bundle exec rake # full default suite
|
|
152
|
+
|
|
153
|
+
# Round-trip against the stress corpus' page directory shape.
|
|
154
|
+
# (The stress run emits .rb files, but the bracket directory names
|
|
155
|
+
# carry the same Next.js shape — point the scanner at them with --ext .rb
|
|
156
|
+
# to confirm the path classifier handles real-world depth.)
|
|
157
|
+
bundle exec exe/jsx_rosetta pages-routes tmp/stress/phlex_out/reserv-web/pages \
|
|
158
|
+
--ext .rb -o /tmp/routes.rb
|
|
159
|
+
ruby -c /tmp/routes.rb # must succeed
|
|
160
|
+
wc -l /tmp/routes.rb # ballpark: ~100 route lines for ~80 leaf files
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Spot-check 5 routes by hand against the original `pages/...` paths to confirm controller + action selection.
|
|
164
|
+
|
|
165
|
+
## Slice 2 sketch (separate plan, after slice 1)
|
|
166
|
+
|
|
167
|
+
Coupled changes — best landed together so the controller's `render` call references the relocated view class:
|
|
168
|
+
|
|
169
|
+
1. **Controller emission**: for each unique controller from slice 1's route table, emit `app/controllers/<controller>_controller.rb` with one empty `def <action>; end` per action. Add a `before_action` TODO comment listing the URL params (`:id`, `:provider_slug`, etc.). Skip emission if the controller file already exists (never clobber user code).
|
|
170
|
+
|
|
171
|
+
2. **View relocation + class rename**: change the Phlex backend so when it knows it's running with a route map (via a `--rails-routes <pages-dir>` flag or a `JsxRosetta.translate` option), it routes each page file to `app/views/<controller>/<action>.rb` instead of preserving the source directory shape, and renames the class from `<Foo>Page` to `Views::<Controller>::<Action>` with parent `Views::Base`. The Phlex Rails docs ([phlex.fun/rails/layouts](https://www.phlex.fun/rails/layouts)) confirm `Views::Articles::Index < Views::Base` (which inherits from `Phlex::HTML`) at `app/views/articles/index.rb` is the canonical shape.
|
|
172
|
+
|
|
173
|
+
Deferred to that plan: action-name collisions, namespace nesting (`pages/admin/users/...` → `Admin::UsersController`?), the `_app.tsx`/`_document.tsx` → application-layout component handling, optional detection of `getServerSideProps` to mark controller actions as needing data-fetching TODOs (the only place AST/IR meaningfully helps).
|
|
174
|
+
|
|
175
|
+
## Slice 3 sketch (separate plan, after slice 2)
|
|
176
|
+
|
|
177
|
+
URL-helper rewrite in the Phlex backend:
|
|
178
|
+
|
|
179
|
+
- `<Link href="/claims/123">` → `link_to "...", claim_path(123)` when `/claims/:id` is in the route table.
|
|
180
|
+
- `<Link href={`/claims/${claim.id}`}>` → match the template literal's static segments against the route table; rewrite if exactly one route matches with one `:id`-shaped hole.
|
|
181
|
+
- `<Link href={someComputed}>` → leave verbatim + TODO.
|
|
182
|
+
- `router.push("/claims/...")` in event handlers — **skipped** (handler bodies are preserved JS in `IR::EventHandler`; rewriting requires a JS-AST pass which conflicts with the "no speculative JS-to-Ruby translation" project rule).
|
|
183
|
+
|
|
184
|
+
Practicality: literal & simple-template href rewrites are deterministic and high-value (every `<Link>` to a known resource gets idiomatic Rails). Anything past that stays verbatim. Worth doing once the route table is real.
|
|
185
|
+
|
|
186
|
+
## Out of scope for slice 1
|
|
187
|
+
|
|
188
|
+
- Controller skeletons (slice 2).
|
|
189
|
+
- View relocation / rename (slice 2).
|
|
190
|
+
- `<Link>` / `href` → URL helper rewrites (slice 3).
|
|
191
|
+
- `getServerSideProps` / `getStaticProps` detection — would require AST parsing per file. Belongs in slice 2 if at all.
|
|
192
|
+
- Route groups like `(group)/` — none in the stress corpus; defer until encountered.
|
|
193
|
+
- Application-layout / error-handler emission for `_app.tsx`, `_document.tsx`, `_error.tsx` — slice 1 just lists them as TODO comments at the top of the emitted `routes.rb`.
|
|
194
|
+
- Sharing infrastructure with the existing `RoutesScript` backend — they have different inputs and different output shapes; deduplicate later if both grow.
|
|
195
|
+
|
|
196
|
+
## Risks
|
|
197
|
+
|
|
198
|
+
- **Action name collisions across files.** Two leaves in the same controller may both want the same action name (e.g., a `[id]/edit.tsx` and a `new_edit.tsx` both wanting `edit`). Scanner detects, emits a duplicate-route warning comment in the output, picks one and suffixes the other with `_2`. Spec covers this.
|
|
199
|
+
- **Deeply-nested resources don't fit Rails REST naming.** `pages/policies/[providerSlug]/[policyId]/edit.tsx` becomes `policies#edit` with two URL params — semantically right, but a Rails dev might expect a `PoliciesProvidersPoliciesController`. Slice 1 deliberately punts on this — emit the flat route, let the user reshape in slice 2 if they want.
|
|
200
|
+
- **Files outside the pages root.** If the user points at a parent of `pages/`, the scanner will treat every `.tsx` file as a top-level page. Scanner errors out if `<dir>` doesn't end in `pages` (or contain a `pages/` subdir) unless `--allow-any-dir` is passed.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# jsx_rosetta — slice 2: controller skeletons + Phlex view placement
|
|
2
|
+
|
|
3
|
+
Builds on slice 1 (`pages-routes` → routes.rb). Slice 1 produced the route table; this slice consumes it to (a) emit controller files matching the route table and (b) reshape Phlex backend output to match Rails view conventions.
|
|
4
|
+
|
|
5
|
+
Parent plan: `plans/nextjs_pages_to_rails.md` (slice 2 sketch section).
|
|
6
|
+
|
|
7
|
+
## Two coupled deliverables
|
|
8
|
+
|
|
9
|
+
These ship together because the controller's `render` (implicit) references the view class that view-placement renames.
|
|
10
|
+
|
|
11
|
+
### A. Controller emission
|
|
12
|
+
|
|
13
|
+
For each unique `controller` in the route table:
|
|
14
|
+
|
|
15
|
+
- Emit `app/controllers/<controller>_controller.rb`.
|
|
16
|
+
- One empty `def <action>; end` per route in that controller, alphabetized.
|
|
17
|
+
- Comment above each action listing URL params extracted from the matching `rails_path` (e.g. `# params: :id, :provider_slug`).
|
|
18
|
+
- Skip emission entirely if the file already exists on disk (never clobber user code — emit a stderr note).
|
|
19
|
+
- Parent class: `ApplicationController` (Rails convention; user is expected to have it).
|
|
20
|
+
|
|
21
|
+
### B. Phlex view placement
|
|
22
|
+
|
|
23
|
+
New backend option `rails_view:` on `Backend::Phlex`. When set to a route entry (responding to `controller` and `action`):
|
|
24
|
+
|
|
25
|
+
- File path: `<controller>/<action>.rb` (slot under `app/views/` at the CLI level).
|
|
26
|
+
- Class name: `Views::<ControllerCamel>::<ActionCamel>`.
|
|
27
|
+
- Parent class: `Views::Base` (user-defined per Phlex Rails convention; reference [phlex.fun/rails/layouts](https://www.phlex.fun/rails/layouts)).
|
|
28
|
+
|
|
29
|
+
The existing `suffix:` / `namespace:` Phlex options are mutually exclusive with `rails_view:` — error out if combined.
|
|
30
|
+
|
|
31
|
+
The page-aware `effective_suffix_for` / `page?` helpers added in v0.5.x stop applying when `rails_view:` is set — the Rails convention takes precedence.
|
|
32
|
+
|
|
33
|
+
## CLI surface
|
|
34
|
+
|
|
35
|
+
### Extended `pages-routes`
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
jsx_rosetta pages-routes <pages-dir> [-o routes.rb] [--controllers DIR] \
|
|
39
|
+
[--ext .tsx,.jsx] [--allow-any-dir]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--controllers DIR` is independent of `-o`. When set:
|
|
43
|
+
|
|
44
|
+
- Writes one `<controller>_controller.rb` per controller into `DIR`.
|
|
45
|
+
- Prints `wrote <path>` lines to stdout.
|
|
46
|
+
- Skips files that already exist; prints `skipped <path> (exists)`.
|
|
47
|
+
|
|
48
|
+
### Extended `translate`
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
jsx_rosetta translate <file.tsx> --as=phlex --rails-routes <pages-dir> -o <views-dir>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
When `--rails-routes` is set:
|
|
55
|
+
|
|
56
|
+
- Scans `<pages-dir>` to build a route lookup once.
|
|
57
|
+
- Resolves `<file>`'s path relative to `<pages-dir>`. Error if the file is not under it.
|
|
58
|
+
- Looks up the route by source path. Error if the file is not represented in the table (e.g., skipped `_app.tsx`).
|
|
59
|
+
- Passes `rails_view: Route` as a Phlex backend option.
|
|
60
|
+
- Output lands at `<views-dir>/<controller>/<action>.rb`.
|
|
61
|
+
|
|
62
|
+
`--rails-routes` is invalid without `--as=phlex` (ViewComponent + RailsView backends are out of scope for this slice).
|
|
63
|
+
|
|
64
|
+
`--rails-routes` is invalid combined with `--phlex-suffix` / `--phlex-namespace`.
|
|
65
|
+
|
|
66
|
+
## Implementation
|
|
67
|
+
|
|
68
|
+
| Path | Status | Scope |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | New `Emitter.emit_controllers(routes:)` returning `[File-like]`. Action-name camelization helpers. |
|
|
71
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | Accept `rails_view:` option. Override `class_name`, parent class, and file path when set. |
|
|
72
|
+
| `lib/jsx_rosetta.rb` | modify | Surface `rails_view:` in `backend_options.slice` allowlist for the Phlex backend. |
|
|
73
|
+
| `lib/jsx_rosetta/cli.rb` | modify | `--controllers DIR` on pages-routes; `--rails-routes DIR` on translate. |
|
|
74
|
+
|
|
75
|
+
### URL-param extraction for controller comments
|
|
76
|
+
|
|
77
|
+
From a `rails_path` like `/policies/:provider_slug/:policy_id/edit`, strip everything that isn't a `:param` token: `[:provider_slug, :policy_id]`. Catch-all params (`*rest`, `(/*extra)`) included as `*rest`-style entries. Emitted as `# params: :provider_slug, :policy_id` above the action.
|
|
78
|
+
|
|
79
|
+
### Class-name camelization
|
|
80
|
+
|
|
81
|
+
`AST::Inflector.camelize` returns lowerCamelCase. For class names this slice needs UpperCamelCase. Add `AST::Inflector.upper_camelize` (or inline `parts.map(&:capitalize).join`) — pick whichever keeps the inflector module focused.
|
|
82
|
+
|
|
83
|
+
## Specs
|
|
84
|
+
|
|
85
|
+
| Path | Status | Coverage |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `spec/pages_routing_spec.rb` | modify | `Emitter.emit_controllers` shape, action ordering, param comments, skip-on-exists is a CLI concern not module concern (covered in cli_spec). |
|
|
88
|
+
| `spec/backend/phlex_spec.rb` | modify | `rails_view:` option overrides class + parent + path; mutual-exclusion errors with `suffix:` / `namespace:`. |
|
|
89
|
+
| `spec/cli_spec.rb` | modify | `pages-routes --controllers DIR`: writes per-controller files, skips existing; `translate --rails-routes <pages-dir>`: writes view to `<controller>/<action>.rb`, errors when file is outside pages-dir, errors when file is skipped. |
|
|
90
|
+
|
|
91
|
+
## Verification
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
cd /home/sean/code/jsx_rosetta
|
|
95
|
+
bundle exec rake
|
|
96
|
+
|
|
97
|
+
# Round-trip a real page through the new pipeline.
|
|
98
|
+
ruby -Ilib exe/jsx_rosetta pages-routes tmp/stress/phlex_out/reserv-web/pages \
|
|
99
|
+
--ext .rb -o /tmp/routes.rb --controllers /tmp/controllers
|
|
100
|
+
ls /tmp/controllers
|
|
101
|
+
ruby -c /tmp/controllers/*.rb
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Spot-check a few generated controllers against the route table to confirm action lists, param comments, and class names.
|
|
105
|
+
|
|
106
|
+
## Out of scope (deferred to future slices or punted)
|
|
107
|
+
|
|
108
|
+
- Namespace nesting (`pages/admin/users/...` → `Admin::UsersController`). Keep flat controllers for slice 2; revisit if it bites.
|
|
109
|
+
- `_app.tsx` / `_document.tsx` → application-layout class. The skipped-files block in slice 1's routes.rb already flags these.
|
|
110
|
+
- `getServerSideProps` / `getStaticProps` detection — requires AST per file. Not in slice 2.
|
|
111
|
+
- Action-name collisions across files in the same controller — slice 1 already dedupes identical routes; cross-path collisions remain rare. Punt to slice 3 or later.
|
|
112
|
+
- `--rails-routes` for non-Phlex backends — slice 3+ if needed.
|
|
113
|
+
|
|
114
|
+
## Risks
|
|
115
|
+
|
|
116
|
+
- **`Views::Base` not defined.** User must add it themselves per Phlex Rails docs. We emit a one-line `# TODO: ensure app/views/base.rb defines Views::Base < Phlex::HTML` comment at the top of each view file. No autogeneration of `Views::Base` — out of scope.
|
|
117
|
+
- **Controller file already exists.** Slice 2 skips with a stderr note rather than clobbering. Risks: user may not realize their controller is out of sync with the new routes. Mitigation: stderr message lists which actions the routes.rb expects so they can reconcile.
|
|
118
|
+
- **`ApplicationController` missing.** Same as above — emit referencing it; user wires up.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# jsx_rosetta — slice 3: `<Link>` / `href` → Rails URL helpers
|
|
2
|
+
|
|
3
|
+
Builds on slices 1 (`pages-routes` → routes.rb) and 2 (controller skeletons + Phlex view placement). With the route table now available end-to-end through the `--rails-routes` flag, the Phlex backend can rewrite literal/simple-template URLs into matching Rails URL helpers.
|
|
4
|
+
|
|
5
|
+
Parent plan: `plans/nextjs_pages_to_rails.md` (slice 3 sketch).
|
|
6
|
+
|
|
7
|
+
## What gets rewritten
|
|
8
|
+
|
|
9
|
+
Only the `href` (or `to`) attribute on tags whose name is `a`, `Link`, `NavLink`, or `RouterLink`:
|
|
10
|
+
|
|
11
|
+
| Source | Rewritten to | Notes |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `<a href="/accounts">` | `a(href: accounts_path)` | exact path match against route table |
|
|
14
|
+
| `<Link href="/accounts/123">` | `Link(href: account_path(123))` | numeric literals pass through as integer |
|
|
15
|
+
| `<Link href={`/accounts/${id}`}>` | `Link(href: account_path(id))` | single `:id`-shaped hole matched against an identifier or member expression |
|
|
16
|
+
| `<Link href={`/policies/${slug}/${policyId}/edit`}>` | `Link(href: edit_policy_path(slug, policy_id))` | multi-param simple template |
|
|
17
|
+
| `<Link href={someComputed}>` | unchanged + `# TODO:` | not deterministic |
|
|
18
|
+
| `<a href="https://external.example">` | unchanged | external URL — absolute scheme |
|
|
19
|
+
| `<a href="#anchor">` | unchanged | fragment-only |
|
|
20
|
+
| `router.push("/foo")` in handlers | unchanged | handler bodies are preserved JS (`IR::EventHandler`) — out of scope per project rule against speculative JS-to-Ruby translation |
|
|
21
|
+
|
|
22
|
+
## URL helper naming
|
|
23
|
+
|
|
24
|
+
Helpers and `as:` names are derived from `(controller, action, rails_path)` by a single function so slice 1's routes.rb and slice 3's rewrites stay paired:
|
|
25
|
+
|
|
26
|
+
| Controller / action / path | `as:` token | Helper |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `(pages, index, "/")` | `:root` | `root_path` |
|
|
29
|
+
| `(accounts, index, _)` | `:accounts` | `accounts_path` |
|
|
30
|
+
| `(accounts, show, _)` | `:account` | `account_path(*params)` |
|
|
31
|
+
| `(accounts, new, _)` | `:new_account` | `new_account_path` |
|
|
32
|
+
| `(accounts, edit, _)` | `:edit_account` | `edit_account_path(*params)` |
|
|
33
|
+
| `(accounts, <other>, _)` | `:accounts_<other>` | `accounts_<other>_path(*params)` |
|
|
34
|
+
|
|
35
|
+
Singularization uses a small new `AST::Inflector.singularize`:
|
|
36
|
+
- `accounts` → `account`, `policies` → `policy`, `boxes` → `box`, `dishes` → `dish`, `houses` → `house`.
|
|
37
|
+
- Irregular plurals (`children`, `people`) are returned as-is — the user can fix the `as:` in routes.rb and re-run translate if needed.
|
|
38
|
+
|
|
39
|
+
## Slice 1 routes.rb change
|
|
40
|
+
|
|
41
|
+
`Emitter.route_line` adds `, as: :<name>` to each route line. This is a **behavior change** for the existing slice 1 output, so the slice 1 specs are updated alongside the new slice 3 specs. `root` route stays as `root to: "pages#index"` (Rails always names that `root`).
|
|
42
|
+
|
|
43
|
+
## Implementation
|
|
44
|
+
|
|
45
|
+
| Path | Status | Scope |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `lib/jsx_rosetta/ast/inflector.rb` | modify | New `.singularize` (simple `ies`/`ses`/`s` rules). |
|
|
48
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | New `Naming` module + `HrefRewriter` class. `Emitter.route_line` adds `as:`. |
|
|
49
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | New `route_table:` initializer kwarg. Thread `tag:` through `format_attributes` → `attribute_value_to_ruby`. Add the rewrite path. |
|
|
50
|
+
| `lib/jsx_rosetta.rb` | modify | Allowlist `route_table:` in the Phlex backend options slice. |
|
|
51
|
+
| `lib/jsx_rosetta/cli.rb` | modify | `resolve_rails_view_route!` also captures the full route table and forwards it via `options[:route_table]`. |
|
|
52
|
+
|
|
53
|
+
## HrefRewriter
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
module JsxRosetta
|
|
57
|
+
module PagesRouting
|
|
58
|
+
class HrefRewriter
|
|
59
|
+
def initialize(routes)
|
|
60
|
+
# Builds an internal index keyed by (segment-shape) → route.
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# value: a String (verbatim path), or { kind: :template, literal_segments: [...], holes: [ruby_expr, ...] }
|
|
64
|
+
# Returns: a Ruby source string (e.g. "account_path(id)") or nil.
|
|
65
|
+
def rewrite(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Matching algorithm:
|
|
73
|
+
- Split both the input path and each route's `rails_path` by `/`.
|
|
74
|
+
- For each route, walk segments together: literal must equal, `:foo`/`(/*foo)`/`*foo` consumes the corresponding input segment(s).
|
|
75
|
+
- A single route must match (no ambiguity) — if multiple match, return nil (bail to verbatim + TODO).
|
|
76
|
+
- For literal input, the consumed segment value becomes a Ruby integer literal if `\A-?\d+\z`, else a Ruby string literal.
|
|
77
|
+
- For template input, the consumed segment value is the corresponding hole's Ruby expression.
|
|
78
|
+
|
|
79
|
+
## Phlex backend wiring
|
|
80
|
+
|
|
81
|
+
`@route_table` and `@href_rewriter` are stored on the backend instance during `initialize`. The `tag:` kwarg is plumbed through `format_attributes` → `append_attribute_part` → `phlex_attribute_part` → `plain_attribute_part` → `attribute_value_to_ruby`.
|
|
82
|
+
|
|
83
|
+
`attribute_value_to_ruby` short-circuits when (a) `@href_rewriter` is set, (b) tag is link-shaped (`a`, `Link`, `NavLink`, `RouterLink`), (c) name is `href` or `to`, (d) the value is a String or IR::Interpolation containing a string/template literal. The rewriter returns Ruby; otherwise we fall through to the existing behavior unchanged.
|
|
84
|
+
|
|
85
|
+
## Specs
|
|
86
|
+
|
|
87
|
+
- `spec/ast/inflector_spec.rb` (or wherever inflector specs live): `.singularize` table.
|
|
88
|
+
- `spec/pages_routing_spec.rb`: `Naming` table + `HrefRewriter` (literal match, template match, ambiguity, external/absent → nil) + slice 1 `Emitter` updates (`as:` lines).
|
|
89
|
+
- `spec/backend/phlex_spec.rb`: end-to-end Phlex `route_table:` test cases.
|
|
90
|
+
- `spec/cli_spec.rb`: `translate --rails-routes` produces a view with rewritten hrefs.
|
|
91
|
+
|
|
92
|
+
## Verification
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
cd /home/sean/code/jsx_rosetta
|
|
96
|
+
bundle exec rake
|
|
97
|
+
|
|
98
|
+
# Round-trip
|
|
99
|
+
ruby -Ilib exe/jsx_rosetta pages-routes tmp/stress/phlex_out/reserv-web/pages \
|
|
100
|
+
--ext .rb -o /tmp/r3/routes.rb
|
|
101
|
+
grep -c 'as: :' /tmp/r3/routes.rb # expect ~82 lines
|
|
102
|
+
|
|
103
|
+
# Synthetic href test
|
|
104
|
+
# (TSX with literal + template hrefs, translate with --rails-routes,
|
|
105
|
+
# inspect the emitted view for `*_path` calls.)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Out of scope
|
|
109
|
+
|
|
110
|
+
- Rewriting `href` on tags other than `a`/`Link`/`NavLink`/`RouterLink`.
|
|
111
|
+
- `<form action="/post">`-style action rewrites.
|
|
112
|
+
- `router.push("/x")` inside event handler bodies — preserved verbatim per project rule.
|
|
113
|
+
- Re-running `pages-routes` automatically when `translate` is invoked — user must keep their routes.rb in sync (the route table is scanned each `translate` call; only the routes.rb file on disk lags if the user doesn't re-run pages-routes).
|
|
114
|
+
- Anchor / query-string fragments. A path like `"/accounts#tab=details"` is left verbatim.
|
|
115
|
+
- Multi-route disambiguation by HTTP method (we only emit GET routes anyway).
|
|
116
|
+
|
|
117
|
+
## Risks
|
|
118
|
+
|
|
119
|
+
- **Slice 1 `as:` change is a breaking diff for any existing routes.rb consumers.** Mitigated by updating the slice 1 specs and including the change in the same commit so the gem's behavior is consistent.
|
|
120
|
+
- **Bad singularization** (e.g. `mice` → `mic`). Mitigated by emitting a TODO comment above the view file the first time a non-trivial helper is used, listing the helper names so the user can compare against their routes.rb.
|
|
121
|
+
- **False matches.** If the user has `/accounts` and `/accounts_list`, a literal `/accounts_list` would not match `/accounts` (segment equality), so this is safe. The bigger risk: a template literal `/foo/${x}` where multiple routes have `/foo/:id`-shapes — slice 3 bails (returns nil) on ambiguity.
|