Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ( <Foo /> )`) are fine
- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const serverRenderedSet = serverRendered.length
? `new Set<string>([\n${serverRendered.map((n) => ` '${n}',`).join('\n')}\n])`
: 'new Set<string>()';

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
Expand Down Expand Up @@ -120,7 +120,7 @@ const clientOnlySet = clientOnly.length
? `new Set<string>([\n${clientOnly.map((n) => ` '${n}',`).join('\n')}\n])`
: 'new Set<string>()';

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.
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion assets/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
2 changes: 1 addition & 1 deletion assets/js/a11y-audit.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion assets/js/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/components/LocaleSync.tsx
Original file line number Diff line number Diff line change
@@ -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 `<html lang>` from initial page props, and keeps
Expand Down
2 changes: 1 addition & 1 deletion assets/js/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion assets/js/errgo.test.ts → assets/js/result.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion assets/js/ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions assets/package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"type": "module",
"engines": {
"npm": ">=11.10.0"
},
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]}
]
Expand Down
4 changes: 2 additions & 2 deletions docs/frontend-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/mix/tasks/routes_gen.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"],
Expand Down