diff --git a/CLAUDE.md b/CLAUDE.md index e9bd45a..8d2f150 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,8 @@ These are the rules that must not be forgotten or looked up — they're the ones - Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders - Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server - **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 -- Never edit `assets/js/_pages.ts` or `assets/js/_ssr_pages.ts` — they're auto-generated +- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated +- **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 (``, `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 - **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 - 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 - **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/_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 diff --git a/assets/biome.json b/assets/biome.json index 26e26b0..0e7f7ed 100644 --- a/assets/biome.json +++ b/assets/biome.json @@ -14,7 +14,8 @@ "!**/build/", "!**/server/static/**", "!js/_pages.ts", - "!js/_ssr_pages.ts" + "!js/_ssr_pages.ts", + "!js/routes.ts" ], "indentStyle": "space", "indentWidth": 2, diff --git a/assets/build/watch-routes.js b/assets/build/watch-routes.js new file mode 100644 index 0000000..cc453c9 --- /dev/null +++ b/assets/build/watch-routes.js @@ -0,0 +1,30 @@ +// Watches the Phoenix router for changes and regenerates the typed frontend +// route table (assets/js/routes.ts) via `mix routes.gen`. Used as a Phoenix +// dev watcher so routes.ts can't go stale while the dev server is running. +// +// Router edits are rare, so shelling out to `mix` (which boots a second BEAM) +// on change is an acceptable cost — and it reuses the exact same generator +// that assets.build and precommit use, so there's only one source of truth. + +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const ROOT = path.join(__dirname, '..', '..'); +const ROUTER = path.join(ROOT, 'lib', 'elixir_react_starter_web', 'router.ex'); + +function regenerate() { + try { + execFileSync('mix', ['routes.gen'], { cwd: ROOT, stdio: 'inherit' }); + } catch (_) { + // errors already printed by the child process + } +} + +// Initial generation so a freshly-started server is always in sync. +regenerate(); + +// fs.watch on the single router file is enough — every route lives there. +require('node:fs').watch(ROUTER, () => regenerate()); + +// Keep process alive +process.stdin.resume(); diff --git a/assets/js/components/LocaleSelector.tsx b/assets/js/components/LocaleSelector.tsx index 5e5f591..ae277e2 100644 --- a/assets/js/components/LocaleSelector.tsx +++ b/assets/js/components/LocaleSelector.tsx @@ -1,5 +1,6 @@ import { router, usePage } from '@inertiajs/react'; import { Check } from 'lucide-react'; +import { routes } from '../routes'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './DropdownMenu'; interface LocaleSelectorProps { @@ -18,7 +19,7 @@ export default function LocaleSelector({ className = '' }: LocaleSelectorProps) function handleSelect(code: string) { if (code !== locale) { router.put( - '/locale', + routes.locale(), { locale: code }, { preserveScroll: true, diff --git a/assets/js/layouts/AppLayout.tsx b/assets/js/layouts/AppLayout.tsx index 1696c24..b0df3f9 100644 --- a/assets/js/layouts/AppLayout.tsx +++ b/assets/js/layouts/AppLayout.tsx @@ -13,6 +13,7 @@ import { import Link from '../components/Link'; import LocaleSelector from '../components/LocaleSelector'; import ThemeToggle from '../components/ThemeToggle'; +import { routes } from '../routes'; import type { CurrentUser } from '../types'; interface AppLayoutProps { @@ -47,7 +48,7 @@ export default function AppLayout({ title, children }: AppLayoutProps) {
- + ElixirReactStarter
- router.visit('/settings')}> + router.visit(routes.settings())}> - router.delete('/logout')}> + router.delete(routes.logout())}> diff --git a/assets/js/pages/client/Settings.tsx b/assets/js/pages/client/Settings.tsx index d511be8..1bd5ec4 100644 --- a/assets/js/pages/client/Settings.tsx +++ b/assets/js/pages/client/Settings.tsx @@ -13,6 +13,7 @@ import { import Button from '../../components/Button'; import { inputClass } from '../../components/ui'; import AppLayout from '../../layouts/AppLayout'; +import { routes } from '../../routes'; export default function Settings() { const { t } = useTranslation(); @@ -48,7 +49,7 @@ function ChangeEmailSection() {
{ e.preventDefault(); - put('/settings/email', { onSuccess: () => reset() }); + put(routes.settingsEmail(), { onSuccess: () => reset() }); }} className="space-y-4" > @@ -108,7 +109,7 @@ function ChangePasswordSection() { { e.preventDefault(); - put('/settings/password', { onSuccess: () => reset() }); + put(routes.settingsPassword(), { onSuccess: () => reset() }); }} className="space-y-4" > @@ -194,7 +195,7 @@ function DeleteAccountSection() { { e.preventDefault(); - destroy('/settings/account'); + destroy(routes.settingsAccount()); }} className="mt-4 space-y-3" > diff --git a/assets/js/pages/ssr/Auth/ForgotPassword.tsx b/assets/js/pages/ssr/Auth/ForgotPassword.tsx index 0fdcdcc..1957207 100644 --- a/assets/js/pages/ssr/Auth/ForgotPassword.tsx +++ b/assets/js/pages/ssr/Auth/ForgotPassword.tsx @@ -4,6 +4,7 @@ import Button from '../../../components/Button'; import Link from '../../../components/Link'; import { inputClass } from '../../../components/ui'; import AuthLayout from '../../../layouts/AuthLayout'; +import { routes } from '../../../routes'; export default function ForgotPassword() { const { t } = useTranslation(); @@ -16,7 +17,7 @@ export default function ForgotPassword() { { e.preventDefault(); - post('/forgot-password'); + post(routes.forgotPassword()); }} className="space-y-4" > @@ -40,7 +41,7 @@ export default function ForgotPassword() {

- + {t('auth.backToLogin')}

diff --git a/assets/js/pages/ssr/Auth/Login.tsx b/assets/js/pages/ssr/Auth/Login.tsx index 1242f5e..02b79f6 100644 --- a/assets/js/pages/ssr/Auth/Login.tsx +++ b/assets/js/pages/ssr/Auth/Login.tsx @@ -4,6 +4,7 @@ import Button from '../../../components/Button'; import Link from '../../../components/Link'; import { inputClass } from '../../../components/ui'; import AuthLayout from '../../../layouts/AuthLayout'; +import { routes } from '../../../routes'; export default function Login() { const { t } = useTranslation(); @@ -17,7 +18,7 @@ export default function Login() { { e.preventDefault(); - post('/login'); + post(routes.login()); }} className="space-y-4" > @@ -57,16 +58,16 @@ export default function Login() {
- + {t('auth.login.createAccount')} - + {t('auth.login.forgotPassword')}

- + {t('auth.login.resendConfirmation')}

diff --git a/assets/js/pages/ssr/Auth/Register.tsx b/assets/js/pages/ssr/Auth/Register.tsx index 8c24057..c2bb330 100644 --- a/assets/js/pages/ssr/Auth/Register.tsx +++ b/assets/js/pages/ssr/Auth/Register.tsx @@ -4,6 +4,7 @@ import Button from '../../../components/Button'; import Link from '../../../components/Link'; import { inputClass } from '../../../components/ui'; import AuthLayout from '../../../layouts/AuthLayout'; +import { routes } from '../../../routes'; export default function Register() { const { t } = useTranslation(); @@ -17,7 +18,7 @@ export default function Register() { { e.preventDefault(); - post('/register'); + post(routes.register()); }} className="space-y-4" > @@ -60,7 +61,7 @@ export default function Register() {

{t('auth.register.alreadyHaveAccount')}{' '} - + {t('auth.register.logIn')}

diff --git a/assets/js/pages/ssr/Auth/ResendConfirmation.tsx b/assets/js/pages/ssr/Auth/ResendConfirmation.tsx index fe8cb03..4c9f5c9 100644 --- a/assets/js/pages/ssr/Auth/ResendConfirmation.tsx +++ b/assets/js/pages/ssr/Auth/ResendConfirmation.tsx @@ -4,6 +4,7 @@ import Button from '../../../components/Button'; import Link from '../../../components/Link'; import { inputClass } from '../../../components/ui'; import AuthLayout from '../../../layouts/AuthLayout'; +import { routes } from '../../../routes'; export default function ResendConfirmation() { const { t } = useTranslation(); @@ -16,7 +17,7 @@ export default function ResendConfirmation() { { e.preventDefault(); - post('/resend-confirmation'); + post(routes.resendConfirmation()); }} className="space-y-4" > @@ -40,7 +41,7 @@ export default function ResendConfirmation() {

- + {t('auth.backToLogin')}

diff --git a/assets/js/pages/ssr/Auth/ResetPassword.tsx b/assets/js/pages/ssr/Auth/ResetPassword.tsx index 98edccc..be1ba3f 100644 --- a/assets/js/pages/ssr/Auth/ResetPassword.tsx +++ b/assets/js/pages/ssr/Auth/ResetPassword.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import Button from '../../../components/Button'; import { inputClass } from '../../../components/ui'; import AuthLayout from '../../../layouts/AuthLayout'; +import { routes } from '../../../routes'; interface ResetPasswordProps { token: string; @@ -20,7 +21,7 @@ export default function ResetPassword({ token }: ResetPasswordProps) { { e.preventDefault(); - post('/reset-password'); + post(routes.resetPassword()); }} className="space-y-4" > diff --git a/assets/js/pages/ssr/Home.tsx b/assets/js/pages/ssr/Home.tsx index 6c039ec..0cd6cda 100644 --- a/assets/js/pages/ssr/Home.tsx +++ b/assets/js/pages/ssr/Home.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import Button from '../../components/Button'; import GuestTopBar from '../../components/GuestTopBar'; import Link from '../../components/Link'; +import { routes } from '../../routes'; import type { CurrentUser } from '../../types'; export default function Home() { @@ -27,12 +28,12 @@ export default function Home() {

{t('home.goToDashboard')} -
@@ -40,13 +41,13 @@ export default function Home() { ) : (
{t('home.login')} {t('home.createAccount')} diff --git a/assets/js/routes.ts b/assets/js/routes.ts new file mode 100644 index 0000000..44efe88 --- /dev/null +++ b/assets/js/routes.ts @@ -0,0 +1,51 @@ +// Auto-generated by `mix routes.gen` — do not edit manually. +// +// Typed path builders mirroring the browser-facing routes in +// ElixirReactStarterWeb.Router. Phoenix 1.7+ has no named route helpers +// (the ~p sigil replaced them), so what is exported is the route *table* +// ~p validates against: one builder per distinct path, with :param +// segments as required typed args and an optional query object. +// +// Regenerated on `mix assets.build` / `assets.deploy` and the dev watcher; +// `mix routes.gen --check` (in `mix precommit`) fails the build if this +// file ever drifts from the router. + +type QueryValue = string | number | boolean | null | undefined; +type QueryParams = Record; + +function query(params?: QueryParams): string { + if (!params) return ''; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item !== null && item !== undefined) search.append(key, String(item)); + } + } else { + search.append(key, String(value)); + } + } + const qs = search.toString(); + return qs ? `?${qs}` : ''; +} + +export const routes = { + confirmEmail: (q?: QueryParams) => `/confirm-email${query(q)}`, + dashboard: (q?: QueryParams) => `/dashboard${query(q)}`, + forgotPassword: (q?: QueryParams) => `/forgot-password${query(q)}`, + locale: (q?: QueryParams) => `/locale${query(q)}`, + login: (q?: QueryParams) => `/login${query(q)}`, + logout: (q?: QueryParams) => `/logout${query(q)}`, + register: (q?: QueryParams) => `/register${query(q)}`, + resendConfirmation: (q?: QueryParams) => `/resend-confirmation${query(q)}`, + resetPassword: (q?: QueryParams) => `/reset-password${query(q)}`, + root: (q?: QueryParams) => `/${query(q)}`, + settings: (q?: QueryParams) => `/settings${query(q)}`, + settingsAccount: (q?: QueryParams) => `/settings/account${query(q)}`, + settingsEmail: (q?: QueryParams) => `/settings/email${query(q)}`, + settingsEmailApplyChange: (q?: QueryParams) => `/settings/email/apply-change${query(q)}`, + settingsPassword: (q?: QueryParams) => `/settings/password${query(q)}`, +} as const; + +export type RouteName = keyof typeof routes; diff --git a/config/dev.exs b/config/dev.exs index 7315f61..853f777 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -32,7 +32,13 @@ watchers = :elixir_react_starter, ~w(--sourcemap=inline --watch --define:process.env.NODE_ENV="development") ]}, + # Two `node:` keys is intentional: the watcher key is the *command* + # to run (node), not a unique label. Phoenix iterates the full list + # (Enum.map) and gives each watcher a unique child id (make_ref), so + # both node processes start independently. One regenerates the page + # registries, the other the typed route table. node: ["build/watch-ssr-pages.js", cd: Path.expand("../assets", __DIR__)], + node: ["build/watch-routes.js", cd: Path.expand("../assets", __DIR__)], esbuild_ssr: {Esbuild, :install_and_run, [:elixir_react_starter_ssr, ~w(--watch)]}, tailwind: {Tailwind, :install_and_run, [:elixir_react_starter, ~w(--watch)]} ] diff --git a/lib/mix/tasks/routes_gen.ex b/lib/mix/tasks/routes_gen.ex new file mode 100644 index 0000000..c0e0149 --- /dev/null +++ b/lib/mix/tasks/routes_gen.ex @@ -0,0 +1,244 @@ +defmodule Mix.Tasks.Routes.Gen do + @shortdoc "Generates the typed frontend route table (assets/js/routes.ts) from the Phoenix router" + @moduledoc """ + Introspects `ElixirReactStarterWeb.Router` and writes a typed TypeScript + path-builder module to `assets/js/routes.ts`, so the frontend can never + drift from the server's routes. + + Phoenix 1.7+ dropped named route helpers in favour of the `~p` sigil, so + `~p` itself can't be exported. What is exported instead is the route + *table* `~p` validates against: one typed builder per distinct + browser-facing path, with `:param` segments becoming required typed + arguments and an optional query object. + + mix routes.gen # regenerate assets/js/routes.ts + mix routes.gen --check # fail (non-zero exit) if the file is stale; writes nothing + + `--check` is wired into `mix precommit`; plain generation is wired into + `mix assets.build` / `mix assets.deploy` and the dev watcher + (`assets/build/watch-routes.js`), so the file stays in sync automatically. + + ## Scope + + Only browser-facing routes reach the React frontend, so JSON-only + endpoints and dev-only tooling are excluded: + + * `@excluded_plugs` — controllers that only speak JSON + * `@excluded_path_prefixes` — dev-only mount points + + Adjust those module attributes if the surface changes. Route *names* are + derived from the static path segments (`/settings/email/apply-change` → + `settingsEmailApplyChange`); if two paths collapse to the same name the + task raises rather than emit a silently-shadowed builder. + """ + + use Mix.Task + + @router ElixirReactStarterWeb.Router + @output "assets/js/routes.ts" + + # JSON-only endpoints — never navigated to from the React app. + @excluded_plugs [ + ElixirReactStarterWeb.HealthController, + ElixirReactStarterWeb.DevE2EController + ] + + # Dev-only tooling (LiveDashboard, Swoosh mailbox) — not part of the app UI. + @excluded_path_prefixes ["/dev"] + + @impl true + def run(args) do + Mix.Task.run("compile") + + content = build_module() + path = Path.join(File.cwd!(), @output) + + if "--check" in args do + run_check(path, content) + else + write(path, content) + end + end + + # ============================================================================= + # Output handling + # ============================================================================= + defp write(path, content) do + if File.exists?(path) and File.read!(path) == content do + Mix.shell().info("routes.gen: #{@output} already up to date") + else + File.write!(path, content) + Mix.shell().info("routes.gen: wrote #{@output}") + end + end + + defp run_check(path, content) do + cond do + not File.exists?(path) -> + Mix.raise( + "routes.gen --check: #{@output} is missing. Run `mix routes.gen` and commit it." + ) + + File.read!(path) == content -> + Mix.shell().info("routes.gen --check: #{@output} is up to date") + + true -> + Mix.raise( + "routes.gen --check: #{@output} is stale. Run `mix routes.gen` and commit the result." + ) + end + end + + # ============================================================================= + # Route extraction + # ============================================================================= + defp build_module do + entries = + @router + |> Phoenix.Router.routes() + |> Enum.filter(&included?/1) + |> Enum.map(& &1.path) + |> Enum.uniq() + |> Enum.map(&route_entry/1) + |> ensure_unique_names!() + |> Enum.sort_by(& &1.name) + + render(entries) + end + + defp included?(%{plug: plug, path: path}) do + plug not in @excluded_plugs and + not Enum.any?(@excluded_path_prefixes, &String.starts_with?(path, &1)) + end + + defp route_entry(path) do + segments = String.split(path, "/", trim: true) + + params = + segments + |> Enum.filter(¶m_segment?/1) + |> Enum.map(&String.trim_leading(&1, ":")) + + %{path: path, name: route_name(segments), params: params} + end + + defp param_segment?(seg), do: String.starts_with?(seg, ":") + + # Name from the static segments; fall back to the param names only if a + # path is entirely dynamic (e.g. "/:id"). Root is "root". + defp route_name([]), do: "root" + + defp route_name(segments) do + static = Enum.reject(segments, ¶m_segment?/1) + source = if static == [], do: segments, else: static + + source + |> Enum.flat_map(&segment_words/1) + |> camelize() + end + + defp ensure_unique_names!(entries) do + dupes = + entries + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, group} -> length(group) > 1 end) + + if dupes != [] do + detail = + Enum.map_join(dupes, "\n", fn {name, group} -> + " #{name}: #{Enum.map_join(group, ", ", & &1.path)}" + end) + + Mix.raise( + "routes.gen: distinct paths collapsed to the same name:\n#{detail}\n" <> + "Disambiguate the paths or extend the naming in Mix.Tasks.Routes.Gen." + ) + end + + entries + end + + # ============================================================================= + # Rendering + # ============================================================================= + defp render(entries) do + builders = Enum.map_join(entries, "\n", &render_entry/1) + + """ + // Auto-generated by `mix routes.gen` — do not edit manually. + // + // Typed path builders mirroring the browser-facing routes in + // ElixirReactStarterWeb.Router. Phoenix 1.7+ has no named route helpers + // (the ~p sigil replaced them), so what is exported is the route *table* + // ~p validates against: one builder per distinct path, with :param + // segments as required typed args and an optional query object. + // + // Regenerated on `mix assets.build` / `assets.deploy` and the dev watcher; + // `mix routes.gen --check` (in `mix precommit`) fails the build if this + // file ever drifts from the router. + + type QueryValue = string | number | boolean | null | undefined; + type QueryParams = Record; + + function query(params?: QueryParams): string { + if (!params) return ''; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item !== null && item !== undefined) search.append(key, String(item)); + } + } else { + search.append(key, String(value)); + } + } + const qs = search.toString(); + return qs ? `?${qs}` : ''; + } + + export const routes = { + #{builders} + } as const; + + export type RouteName = keyof typeof routes; + """ + end + + defp render_entry(%{name: name, path: path, params: []}) do + ~s/ #{name}: (q?: QueryParams) => `#{path}${query(q)}`,/ + end + + defp render_entry(%{name: name, path: path, params: params}) do + type = Enum.map_join(params, "; ", &"#{js_name(&1)}: string | number") + tpath = ts_path(path, params) + ~s/ #{name}: (params: { #{type} }, q?: QueryParams) => `#{tpath}${query(q)}`,/ + end + + # Replace `:param` with `${params.paramName}`. Longest param first so a + # shorter name can't match inside a longer one (`:id` vs `:identifier`). + defp ts_path(path, params) do + params + |> Enum.sort_by(&String.length/1, :desc) + |> Enum.reduce(path, fn p, acc -> + String.replace(acc, ":#{p}", "${params.#{js_name(p)}}") + end) + end + + # ============================================================================= + # Word helpers + # ============================================================================= + defp segment_words(seg) do + seg + |> String.trim_leading(":") + |> String.split(["-", "_"], trim: true) + end + + defp js_name(param), do: param |> segment_words() |> camelize() + + defp camelize([]), do: "route" + + defp camelize([first | rest]) do + String.downcase(first) <> Enum.map_join(rest, &String.capitalize/1) + end +end diff --git a/mix.exs b/mix.exs index 2936d24..96cbb48 100644 --- a/mix.exs +++ b/mix.exs @@ -175,6 +175,9 @@ defmodule ElixirReactStarter.MixProject do # either esbuild run: the client bundle imports _pages.ts and the # SSR bundle imports _ssr_pages.ts. "cmd node assets/build/generate-ssr-pages.js", + # Generate the typed frontend route table (routes.ts) from the router + # so the two can't drift. Guarded by `routes.gen --check` in precommit. + "routes.gen", ~s(esbuild elixir_react_starter --define:process.env.NODE_ENV='"development"'), "esbuild elixir_react_starter_ssr" ], @@ -184,6 +187,8 @@ defmodule ElixirReactStarter.MixProject do # Generate the page registries before either esbuild run (see # assets.build above). "cmd node assets/build/generate-ssr-pages.js", + # Generate the typed frontend route table (see assets.build above). + "routes.gen", ~s(esbuild elixir_react_starter --minify --define:process.env.NODE_ENV='"production"'), "esbuild elixir_react_starter_ssr", "phx.digest", @@ -201,6 +206,9 @@ defmodule ElixirReactStarter.MixProject do "compile --warnings-as-errors", "lint", "i18n.check", + # Fail if assets/js/routes.ts has drifted from the router (someone + # changed a route without regenerating the frontend table). + "routes.gen --check", "deps.unlock --unused", "format", "test",