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"],