Skip to content

Commit 41b9b9d

Browse files
authored
Generate typed frontend route table from the Phoenix router (#15)
Add `mix routes.gen`, which introspects ElixirReactStarterWeb.Router and writes assets/js/routes.ts — a typed path-builder module that is the frontend counterpart to the server's ~p sigil, so the two can't drift. - routes.gen runs in assets.build/assets.deploy and a dev watcher (build/watch-routes.js); routes.gen --check in precommit fails on drift - Browser-facing routes only (JSON-only controllers and /dev excluded) - Convert all frontend call sites from hardcoded path strings to routes.*() - biome ignores js/routes.ts (formatter), matching the other generated files
1 parent 0cd6678 commit 41b9b9d

16 files changed

Lines changed: 374 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ These are the rules that must not be forgotten or looked up — they're the ones
3232
- Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders
3333
- Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server
3434
- **No raw `try/catch` for async work — wrap every Promise in `go()` from `@api3/promise-utils`** (sync work uses `goSync`). It returns `{ success, data, error }`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and any vendor SDK call. The only acceptable exception is at top-level error boundaries that genuinely *do* need to catch everything synchronously
35-
- Never edit `assets/js/_pages.ts` or `assets/js/_ssr_pages.ts` — they're auto-generated
35+
- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated
36+
- **Frontend paths come from the `routes` helper, never string literals.** `assets/js/routes.ts` is generated from the Phoenix router by `mix routes.gen` (run in `assets.build`/`assets.deploy` + the dev watcher; `mix routes.gen --check` in `precommit` fails the build on drift). Use `routes.login()`, `routes.settingsEmail()`, etc. for every internal URL on the frontend (`<Link href>`, `useForm().post(...)`, `router.visit/delete(...)`) — the TS path-builder is the frontend counterpart to the server's `~p` sigil and keeps the two in sync. Names are the camelCased path (`/settings/email/apply-change``settingsEmailApplyChange`, `/``root`); `:param` segments become typed args and every builder takes an optional query object (`routes.confirmEmail({ token })`). Adding/removing a route + rebuilding regenerates the file; never hand-edit it
3637
- **Validation errors must redirect, not re-render.** On `{:error, changeset}` always use `conn |> assign_errors(changeset) |> redirect(to: ~p"/current-page")`. Do **not** use `put_status(:unprocessable_entity) |> render_inertia(...)` — Inertia updates the browser URL to the POST/PUT target when you re-render, which is both wrong UX and a regression risk. The Inertia plug flashes errors through the session across the redirect, so the form still shows them
3738
- Use the `~p"/..."` sigil (verified routes) for every internal URL — in controllers, tests, and anywhere else in Elixir. Never write raw route strings; compile-time verification catches typos and broken links
3839
- **JSON serialisation goes through `*_json.ex` view modules**, never ad-hoc serializer modules (no `*Props`, `*Serializer`, or per-controller helpers). One module per resource at `lib/elixir_react_starter_web/controllers/<resource>_json.ex` (e.g. `ElixirReactStarterWeb.PostJSON`) exposes `index/1` and `show/1` (the Phoenix 1.8 equivalents of `render_many`/`render_one`) that both delegate to a single `data/2`. Inertia callers use `MyJSON.data(record, viewer)` directly inside `assign_prop`; JSON API endpoints use `index`/`show` via `render`. This keeps the wire shape for a resource in one place so multiple callers can't drift

assets/biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"!**/build/",
1515
"!**/server/static/**",
1616
"!js/_pages.ts",
17-
"!js/_ssr_pages.ts"
17+
"!js/_ssr_pages.ts",
18+
"!js/routes.ts"
1819
],
1920
"indentStyle": "space",
2021
"indentWidth": 2,

assets/build/watch-routes.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Watches the Phoenix router for changes and regenerates the typed frontend
2+
// route table (assets/js/routes.ts) via `mix routes.gen`. Used as a Phoenix
3+
// dev watcher so routes.ts can't go stale while the dev server is running.
4+
//
5+
// Router edits are rare, so shelling out to `mix` (which boots a second BEAM)
6+
// on change is an acceptable cost — and it reuses the exact same generator
7+
// that assets.build and precommit use, so there's only one source of truth.
8+
9+
const path = require('node:path');
10+
const { execFileSync } = require('node:child_process');
11+
12+
const ROOT = path.join(__dirname, '..', '..');
13+
const ROUTER = path.join(ROOT, 'lib', 'elixir_react_starter_web', 'router.ex');
14+
15+
function regenerate() {
16+
try {
17+
execFileSync('mix', ['routes.gen'], { cwd: ROOT, stdio: 'inherit' });
18+
} catch (_) {
19+
// errors already printed by the child process
20+
}
21+
}
22+
23+
// Initial generation so a freshly-started server is always in sync.
24+
regenerate();
25+
26+
// fs.watch on the single router file is enough — every route lives there.
27+
require('node:fs').watch(ROUTER, () => regenerate());
28+
29+
// Keep process alive
30+
process.stdin.resume();

assets/js/components/LocaleSelector.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { router, usePage } from '@inertiajs/react';
22
import { Check } from 'lucide-react';
3+
import { routes } from '../routes';
34
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './DropdownMenu';
45

56
interface LocaleSelectorProps {
@@ -18,7 +19,7 @@ export default function LocaleSelector({ className = '' }: LocaleSelectorProps)
1819
function handleSelect(code: string) {
1920
if (code !== locale) {
2021
router.put(
21-
'/locale',
22+
routes.locale(),
2223
{ locale: code },
2324
{
2425
preserveScroll: true,

assets/js/layouts/AppLayout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import Link from '../components/Link';
1414
import LocaleSelector from '../components/LocaleSelector';
1515
import ThemeToggle from '../components/ThemeToggle';
16+
import { routes } from '../routes';
1617
import type { CurrentUser } from '../types';
1718

1819
interface AppLayoutProps {
@@ -47,7 +48,7 @@ export default function AppLayout({ title, children }: AppLayoutProps) {
4748
<header className="border-b border-gray-200 dark:border-gray-800">
4849
<div className={`${containerClass} py-3 flex items-center justify-between gap-3`}>
4950
<div className="flex items-center gap-6 min-w-0">
50-
<Link href="/dashboard" className="text-base font-semibold shrink-0">
51+
<Link href={routes.dashboard()} className="text-base font-semibold shrink-0">
5152
ElixirReactStarter
5253
</Link>
5354
<nav aria-label={t('common.mainNav')} className="hidden md:flex items-center gap-1">
@@ -138,12 +139,12 @@ function UserMenu({
138139
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-800" />
139140
</div>
140141

141-
<DropdownMenuItem onSelect={() => router.visit('/settings')}>
142+
<DropdownMenuItem onSelect={() => router.visit(routes.settings())}>
142143
<Settings className="w-4 h-4" aria-hidden="true" />
143144
<span className="flex-1">{t('common.settings')}</span>
144145
</DropdownMenuItem>
145146
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-800" />
146-
<DropdownMenuItem onSelect={() => router.delete('/logout')}>
147+
<DropdownMenuItem onSelect={() => router.delete(routes.logout())}>
147148
<LogOut className="w-4 h-4" aria-hidden="true" />
148149
<span className="flex-1">{t('common.logout')}</span>
149150
</DropdownMenuItem>

assets/js/pages/client/Settings.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import Button from '../../components/Button';
1414
import { inputClass } from '../../components/ui';
1515
import AppLayout from '../../layouts/AppLayout';
16+
import { routes } from '../../routes';
1617

1718
export default function Settings() {
1819
const { t } = useTranslation();
@@ -48,7 +49,7 @@ function ChangeEmailSection() {
4849
<form
4950
onSubmit={(e) => {
5051
e.preventDefault();
51-
put('/settings/email', { onSuccess: () => reset() });
52+
put(routes.settingsEmail(), { onSuccess: () => reset() });
5253
}}
5354
className="space-y-4"
5455
>
@@ -108,7 +109,7 @@ function ChangePasswordSection() {
108109
<form
109110
onSubmit={(e) => {
110111
e.preventDefault();
111-
put('/settings/password', { onSuccess: () => reset() });
112+
put(routes.settingsPassword(), { onSuccess: () => reset() });
112113
}}
113114
className="space-y-4"
114115
>
@@ -194,7 +195,7 @@ function DeleteAccountSection() {
194195
<form
195196
onSubmit={(e) => {
196197
e.preventDefault();
197-
destroy('/settings/account');
198+
destroy(routes.settingsAccount());
198199
}}
199200
className="mt-4 space-y-3"
200201
>

assets/js/pages/ssr/Auth/ForgotPassword.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Button from '../../../components/Button';
44
import Link from '../../../components/Link';
55
import { inputClass } from '../../../components/ui';
66
import AuthLayout from '../../../layouts/AuthLayout';
7+
import { routes } from '../../../routes';
78

89
export default function ForgotPassword() {
910
const { t } = useTranslation();
@@ -16,7 +17,7 @@ export default function ForgotPassword() {
1617
<form
1718
onSubmit={(e) => {
1819
e.preventDefault();
19-
post('/forgot-password');
20+
post(routes.forgotPassword());
2021
}}
2122
className="space-y-4"
2223
>
@@ -40,7 +41,7 @@ export default function ForgotPassword() {
4041
</Button>
4142

4243
<p className="text-sm text-center">
43-
<Link href="/login" className="text-primary hover:underline">
44+
<Link href={routes.login()} className="text-primary hover:underline">
4445
{t('auth.backToLogin')}
4546
</Link>
4647
</p>

assets/js/pages/ssr/Auth/Login.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Button from '../../../components/Button';
44
import Link from '../../../components/Link';
55
import { inputClass } from '../../../components/ui';
66
import AuthLayout from '../../../layouts/AuthLayout';
7+
import { routes } from '../../../routes';
78

89
export default function Login() {
910
const { t } = useTranslation();
@@ -17,7 +18,7 @@ export default function Login() {
1718
<form
1819
onSubmit={(e) => {
1920
e.preventDefault();
20-
post('/login');
21+
post(routes.login());
2122
}}
2223
className="space-y-4"
2324
>
@@ -57,16 +58,16 @@ export default function Login() {
5758
</Button>
5859

5960
<div className="flex justify-between text-sm">
60-
<Link href="/register" className="text-primary hover:underline">
61+
<Link href={routes.register()} className="text-primary hover:underline">
6162
{t('auth.login.createAccount')}
6263
</Link>
63-
<Link href="/forgot-password" className="text-primary hover:underline">
64+
<Link href={routes.forgotPassword()} className="text-primary hover:underline">
6465
{t('auth.login.forgotPassword')}
6566
</Link>
6667
</div>
6768

6869
<p className="text-center text-sm">
69-
<Link href="/resend-confirmation" className="text-primary hover:underline">
70+
<Link href={routes.resendConfirmation()} className="text-primary hover:underline">
7071
{t('auth.login.resendConfirmation')}
7172
</Link>
7273
</p>

assets/js/pages/ssr/Auth/Register.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Button from '../../../components/Button';
44
import Link from '../../../components/Link';
55
import { inputClass } from '../../../components/ui';
66
import AuthLayout from '../../../layouts/AuthLayout';
7+
import { routes } from '../../../routes';
78

89
export default function Register() {
910
const { t } = useTranslation();
@@ -17,7 +18,7 @@ export default function Register() {
1718
<form
1819
onSubmit={(e) => {
1920
e.preventDefault();
20-
post('/register');
21+
post(routes.register());
2122
}}
2223
className="space-y-4"
2324
>
@@ -60,7 +61,7 @@ export default function Register() {
6061

6162
<p className="text-sm text-center">
6263
{t('auth.register.alreadyHaveAccount')}{' '}
63-
<Link href="/login" className="text-primary hover:underline">
64+
<Link href={routes.login()} className="text-primary hover:underline">
6465
{t('auth.register.logIn')}
6566
</Link>
6667
</p>

assets/js/pages/ssr/Auth/ResendConfirmation.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Button from '../../../components/Button';
44
import Link from '../../../components/Link';
55
import { inputClass } from '../../../components/ui';
66
import AuthLayout from '../../../layouts/AuthLayout';
7+
import { routes } from '../../../routes';
78

89
export default function ResendConfirmation() {
910
const { t } = useTranslation();
@@ -16,7 +17,7 @@ export default function ResendConfirmation() {
1617
<form
1718
onSubmit={(e) => {
1819
e.preventDefault();
19-
post('/resend-confirmation');
20+
post(routes.resendConfirmation());
2021
}}
2122
className="space-y-4"
2223
>
@@ -40,7 +41,7 @@ export default function ResendConfirmation() {
4041
</Button>
4142

4243
<p className="text-sm text-center">
43-
<Link href="/login" className="text-primary hover:underline">
44+
<Link href={routes.login()} className="text-primary hover:underline">
4445
{t('auth.backToLogin')}
4546
</Link>
4647
</p>

0 commit comments

Comments
 (0)