diff --git a/CLAUDE.md b/CLAUDE.md
index a2efe7f..d11fc4b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,12 +27,12 @@ These are the rules that must not be forgotten or looked up — they're the ones
**Inertia (this project's frontend)**
- Use `render_inertia/2|3`, never `render/3`, for Inertia pages. Don't use LiveView for Inertia pages
-- Page components are **always** `.tsx` (TypeScript), under `assets/js/pages/` in one of two strategy dirs: **`pages/ssr/`** (server-rendered: public / SEO / first-paint pages) or **`pages/client/`** (client-only: authenticated, interactive, heavy pages — kept out of the Node SSR bundle). The strategy dir is stripped from the Inertia page name, so `render_inertia("Dashboard")` maps to `pages/client/Dashboard.tsx`. The `_pages.ts` / `_ssr_pages.ts` registries are auto-generated from these dirs by `build/generate-ssr-pages.js` — never edit them by hand. New public page → `ssr/`; new authed page → `client/`
+- Page components are **always** `.tsx` (TypeScript), under `assets/js/pages/` in one of two strategy dirs: **`pages/ssr/`** (server-rendered: public / SEO / first-paint pages) or **`pages/client/`** (client-only: authenticated, interactive, heavy pages — kept out of the Node SSR bundle). The strategy dir is stripped from the Inertia page name, so `render_inertia("Dashboard")` maps to `pages/client/Dashboard.tsx`. The `_pages.ts` / `_ssr_pages.ts` registries are auto-generated from these dirs by `build/generate-ssr-pages.cjs` — never edit them by hand. New public page → `ssr/`; new authed page → `client/`
- Layout components live in `assets/js/layouts/` (e.g. `AppLayout`, `AuthLayout`)
- 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 application async work — wrap every Promise in `go()` from `assets/js/errgo.ts`** (sync work uses `goSync`). It returns an error-first tuple, `[error, value]`, 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 vendor SDK calls. Raw catches are limited to Errgo's own implementation, React error boundaries, and CommonJS build-tool process boundaries that cannot import the TypeScript utility without a runtime transpiler
-- `assets/js/errgo.ts` is vendored byte-for-byte from `andreogle/errgo` at commit `aaa1d5153a270cde4aa808369bd486ddbe263a38`. Update it by copying that source file, not by installing a package or editing the vendored implementation locally
+- **No raw `try/catch` for application async work — wrap every Promise in `go()` from `assets/js/result.ts`** (sync work uses `goSync`). It returns an error-first tuple, `[error, value]`, 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 vendor SDK calls. Raw catches are limited to `result.ts`'s own implementation, React error boundaries, and CommonJS build-tool process boundaries that cannot import the TypeScript utility without a runtime transpiler
+- `assets/js/result.ts` is vendored byte-for-byte from `andreogle/errgo` at commit `aaa1d5153a270cde4aa808369bd486ddbe263a38` (upstream calls the package `errgo`; only the local filename differs). Update it by copying that source file, not by installing a package or editing the vendored implementation locally
- **Don't inline a multi-line function into `go(...)` / `goSync(...)`** — extract it to a named function above the call and pass it by name (`const [error, value] = await go(renderApp)`), so the call site stays a scannable one-liner. Small one-line callbacks (`go(() => i18n.changeLanguage(locale))`) are fine to inline. Mirrors the Elixir `with`-clause rule below
- **Multi-line arrow/function bodies use explicit braces and `return`** — never a multi-line implicit return (it's too easy to lose track of what's returned, or drop the `return` when editing, and get weird behaviour). When you convert an implicit return to a block body, **keep the `return`** so the returned value is preserved — only drop it when the value is genuinely unused. And if the body fits on one line within the 120-col width, prefer collapsing to a single-line implicit return rather than a block. Single-line implicit returns (`(x) => x.id`) and idiomatic multi-line JSX render-props wrapped in parens (`({ Component }) => ( )`) are fine
- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated
diff --git a/Dockerfile b/Dockerfile
index 0c0345e..c8d9daa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -66,7 +66,7 @@ COPY assets assets
# Build browser bundle + SSR bundle + digest, then compile the app.
#
-# Source maps: assets.deploy runs build/upload-sourcemaps.js, which ships
+# Source maps: assets.deploy runs build/upload-sourcemaps.cjs, which ships
# maps to Sentry only when SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT are
# set in the build env, and otherwise skips (so local/CI builds without
# Sentry creds still succeed). These are declared as ARGs so Render's build
diff --git a/assets/build/compress-assets.js b/assets/build/compress-assets.cjs
similarity index 100%
rename from assets/build/compress-assets.js
rename to assets/build/compress-assets.cjs
diff --git a/assets/build/generate-ssr-pages.js b/assets/build/generate-ssr-pages.cjs
similarity index 98%
rename from assets/build/generate-ssr-pages.js
rename to assets/build/generate-ssr-pages.cjs
index 535baae..a33fe2d 100644
--- a/assets/build/generate-ssr-pages.js
+++ b/assets/build/generate-ssr-pages.cjs
@@ -77,7 +77,7 @@ const serverRenderedSet = serverRendered.length
? `new Set([\n${serverRendered.map((n) => ` '${n}',`).join('\n')}\n])`
: 'new Set()';
-const clientContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually
+const clientContent = `// Auto-generated by build/generate-ssr-pages.cjs — do not edit manually
// Lazy client page loaders keyed by Inertia page name (the ssr/ or client/
// strategy directory is stripped). Lazy import() so esbuild code-splits one
@@ -120,7 +120,7 @@ const clientOnlySet = clientOnly.length
? `new Set([\n${clientOnly.map((n) => ` '${n}',`).join('\n')}\n])`
: 'new Set()';
-const ssrContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually
+const ssrContent = `// Auto-generated by build/generate-ssr-pages.cjs — do not edit manually
${ssrImports}
// Server-rendered pages (those under pages/ssr/), keyed by Inertia page name.
diff --git a/assets/build/upload-sourcemaps.js b/assets/build/upload-sourcemaps.cjs
similarity index 100%
rename from assets/build/upload-sourcemaps.js
rename to assets/build/upload-sourcemaps.cjs
diff --git a/assets/build/watch-routes.js b/assets/build/watch-routes.cjs
similarity index 100%
rename from assets/build/watch-routes.js
rename to assets/build/watch-routes.cjs
diff --git a/assets/build/watch-ssr-pages.js b/assets/build/watch-ssr-pages.cjs
similarity index 91%
rename from assets/build/watch-ssr-pages.js
rename to assets/build/watch-ssr-pages.cjs
index 77bf814..cc013dd 100644
--- a/assets/build/watch-ssr-pages.js
+++ b/assets/build/watch-ssr-pages.cjs
@@ -6,7 +6,7 @@ const path = require('node:path');
const { execFileSync } = require('node:child_process');
const PAGES_DIR = path.join(__dirname, '..', 'js', 'pages');
-const GENERATE = path.join(__dirname, 'generate-ssr-pages.js');
+const GENERATE = path.join(__dirname, 'generate-ssr-pages.cjs');
function regenerate() {
try {
diff --git a/assets/e2e/global-setup.ts b/assets/e2e/global-setup.ts
index 13cb254..bb6088e 100644
--- a/assets/e2e/global-setup.ts
+++ b/assets/e2e/global-setup.ts
@@ -13,6 +13,6 @@ import path from 'node:path';
* (assets/e2e -> repo root).
*/
export default function globalSetup() {
- const projectRoot = path.resolve(__dirname, '..', '..');
+ const projectRoot = path.resolve(import.meta.dirname, '..', '..');
execSync('mix run priv/repo/e2e.exs', { cwd: projectRoot, stdio: 'inherit' });
}
diff --git a/assets/js/a11y-audit.ts b/assets/js/a11y-audit.ts
index c0ef4f9..3a98237 100644
--- a/assets/js/a11y-audit.ts
+++ b/assets/js/a11y-audit.ts
@@ -1,6 +1,6 @@
import { router } from '@inertiajs/react';
import axe from 'axe-core';
-import { go } from './errgo';
+import { go } from './result';
/**
* Development-only accessibility auditing with axe-core.
diff --git a/assets/js/app.tsx b/assets/js/app.tsx
index 0fdad91..ed23c60 100644
--- a/assets/js/app.tsx
+++ b/assets/js/app.tsx
@@ -11,7 +11,7 @@ import ErrorBoundary from './components/ErrorBoundary';
import { syncLocale } from './components/LocaleSync';
import Toaster from './components/Toaster';
import { toast } from './components/toast';
-import { go } from './errgo';
+import { go } from './result';
import { startThemeWatcher } from './theme';
interface Flash {
diff --git a/assets/js/components/LocaleSync.tsx b/assets/js/components/LocaleSync.tsx
index c47dc49..0822d2e 100644
--- a/assets/js/components/LocaleSync.tsx
+++ b/assets/js/components/LocaleSync.tsx
@@ -1,6 +1,6 @@
import { router } from '@inertiajs/react';
-import { go } from '../errgo';
import i18n from '../i18n';
+import { go } from '../result';
/**
* Sets i18next language and `` from initial page props, and keeps
diff --git a/assets/js/i18n/index.ts b/assets/js/i18n/index.ts
index da91887..5eb43cf 100644
--- a/assets/js/i18n/index.ts
+++ b/assets/js/i18n/index.ts
@@ -1,6 +1,6 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
-import { go } from '../errgo';
+import { go } from '../result';
import en from './locales/en';
import es from './locales/es';
diff --git a/assets/js/errgo.test.ts b/assets/js/result.test.ts
similarity index 94%
rename from assets/js/errgo.test.ts
rename to assets/js/result.test.ts
index f7b32f4..8b9b178 100644
--- a/assets/js/errgo.test.ts
+++ b/assets/js/result.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { go, goSync } from './errgo.ts';
+import { go, goSync } from './result.ts';
test('go returns an error-first tuple for resolved and rejected operations', async () => {
const success = await go(() => Promise.resolve('ready'));
diff --git a/assets/js/errgo.ts b/assets/js/result.ts
similarity index 100%
rename from assets/js/errgo.ts
rename to assets/js/result.ts
diff --git a/assets/js/ssr.tsx b/assets/js/ssr.tsx
index 4dbb73c..5436f66 100644
--- a/assets/js/ssr.tsx
+++ b/assets/js/ssr.tsx
@@ -6,8 +6,8 @@ import ReactDOMServer from 'react-dom/server';
import pages, { ssrClientOnly } from './_ssr_pages.ts';
import { AppProviders } from './app-providers';
import Toaster from './components/Toaster';
-import { go } from './errgo';
import i18n from './i18n';
+import { go } from './result';
// Sentry for the SSR Node workers (errors only — no tracing). The DSN is
// inherited from the BEAM's environment; falls back to the frontend DSN
diff --git a/assets/package.json b/assets/package.json
index 9051bb1..bc6868f 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -1,4 +1,5 @@
{
+ "type": "module",
"engines": {
"npm": ">=11.10.0"
},
@@ -10,8 +11,8 @@
"fmt": "npm run lint:fix && biome format --write",
"lint": "biome check build/ css/ e2e/ js/",
"lint:fix": "biome check --write build/ css/ e2e/ js/",
- "test:unit": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test js/errgo.test.ts",
- "typecheck": "node build/generate-ssr-pages.js && tsc -p tsconfig.json && tsc -p tsconfig.e2e.json"
+ "test:unit": "node --test 'js/**/*.test.ts'",
+ "typecheck": "node build/generate-ssr-pages.cjs && tsc -p tsconfig.json && tsc -p tsconfig.e2e.json"
},
"dependencies": {
"@inertiajs/react": "^3.6.1",
diff --git a/config/dev.exs b/config/dev.exs
index 853f777..8930a76 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -37,8 +37,8 @@ watchers =
# (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__)],
+ node: ["build/watch-ssr-pages.cjs", cd: Path.expand("../assets", __DIR__)],
+ node: ["build/watch-routes.cjs", 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/docs/frontend-pages.md b/docs/frontend-pages.md
index 988899d..233d193 100644
--- a/docs/frontend-pages.md
+++ b/docs/frontend-pages.md
@@ -40,7 +40,7 @@ Dockerfile).
## How it works under the hood
-`build/generate-ssr-pages.js` scans both directories and emits two registries
+`build/generate-ssr-pages.cjs` scans both directories and emits two registries
(both gitignored — never edit them by hand):
- **`_pages.ts`** — the **client** manifest. Every page (ssr *and* client) as a
@@ -56,7 +56,7 @@ Dockerfile).
The generator runs before both esbuild bundles in `mix assets.build` /
`assets.deploy` (the client bundle imports `_pages.ts`, the SSR bundle imports
-`_ssr_pages.ts`), and a dev watcher (`build/watch-ssr-pages.js`) regenerates
+`_ssr_pages.ts`), and a dev watcher (`build/watch-ssr-pages.cjs`) regenerates
them whenever a page file is added or removed.
## Adding a page
diff --git a/lib/mix/tasks/routes_gen.ex b/lib/mix/tasks/routes_gen.ex
index c0e0149..64e0733 100644
--- a/lib/mix/tasks/routes_gen.ex
+++ b/lib/mix/tasks/routes_gen.ex
@@ -16,7 +16,7 @@ defmodule Mix.Tasks.Routes.Gen do
`--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.
+ (`assets/build/watch-routes.cjs`), so the file stays in sync automatically.
## Scope
diff --git a/mix.exs b/mix.exs
index 6557d52..268c5eb 100644
--- a/mix.exs
+++ b/mix.exs
@@ -175,7 +175,7 @@ defmodule ElixirReactStarter.MixProject do
# Generate the page registries (_pages.ts + _ssr_pages.ts) before
# 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",
+ "cmd node assets/build/generate-ssr-pages.cjs",
# 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",
@@ -187,20 +187,20 @@ defmodule ElixirReactStarter.MixProject do
"cmd rm -rf priv/static/assets/chunks",
# Generate the page registries before either esbuild run (see
# assets.build above).
- "cmd node assets/build/generate-ssr-pages.js",
+ "cmd node assets/build/generate-ssr-pages.cjs",
# Generate the typed frontend route table (see assets.build above).
"routes.gen",
# External source maps so Sentry can de-minify production stack
- # traces. upload-sourcemaps.js ships them to Sentry and deletes the
+ # traces. upload-sourcemaps.cjs ships them to Sentry and deletes the
# .map files before phx.digest runs, so they're never served.
~s(esbuild elixir_react_starter --minify --sourcemap=external --define:process.env.NODE_ENV='"production"'),
"esbuild elixir_react_starter_ssr",
- "cmd node assets/build/upload-sourcemaps.js",
+ "cmd node assets/build/upload-sourcemaps.cjs",
"phx.digest",
# phx.digest writes `.gz` next to every asset; this step writes the
# brotli sibling so Plug.Static can serve whichever the request
# accepts. Keep it last so it sees both the hashed and plain files.
- "cmd node assets/build/compress-assets.js"
+ "cmd node assets/build/compress-assets.cjs"
],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"ecto.reset": ["ecto.drop", "ecto.setup"],