# Show error pages

Browsers get a styled HTML page for 404s, 500s and other errors, and a handler
panic becomes a 500 page instead of a dropped connection. Handlers keep
reporting errors with `http.Error` or `response.Error`.

## Generated projects

`startproject` wires this already. `project/errors.go` installs the middleware
and `project/errors.templ` holds the page:

```go
func GlobalMiddleware(logs *slog.Logger) []cicadel.Middleware {
    return []cicadel.Middleware{
        observability.Middleware(logs, nil),
        errorpages.Middleware(errorpages.Options{Page: ErrorPage, Logger: logs}),
    }
}

func ErrorPage(r *http.Request, e errorpages.Error) ui.Content {
    stylesheet, err := static.URL(r.Context(), "project", "app.css")
    if err != nil {
        stylesheet = ""
    }
    return ui.Fragment(errorPage(stylesheet, e))
}
```

Edit `project/errors.templ` to change the design. Run `go tool cicadel ui build`
afterwards, as for any component.

## Add it to an existing project

Pass the middleware to `cicadel.New`, after `observability.Middleware` so pages
and logs carry the request ID:

```go
app, err := cicadel.New(s, apps,
    observability.Middleware(logs, nil),
    errorpages.Middleware(errorpages.Options{Page: ErrorPage, Logger: logs}),
)
```

Without `Page`, the middleware uses `errorpages.Default`: the minimal
`ui/layout` document with the status, a title, a description and a link home.

## What gets a page

The middleware replaces a response when all of these hold:

- the status is 400 or higher;
- its `Content-Type` is `text/plain`, which is what `http.Error` writes;
- the client explicitly accepts `text/html`, as browser navigations and htmx
  requests do.

That covers the router's own 404 and 405 responses, `http.Error` in your
handlers, CSRF failures and `MaxBytesError` handling. Other headers, such as
`Allow` on a 405, are kept.

These pass through unchanged:

- responses you render yourself, in HTML, JSON or any other type;
- API clients: no `Accept` header, `*/*` (curl, `fetch()`) or other types such
  as `application/json`;
- successful and redirect responses.

> [!NOTE]
> Pages show fixed copy for the status. The text a handler passed to
> `http.Error` is never displayed, so a database error message cannot leak
> into a page.

## The page data

`Page` receives an `errorpages.Error`:

| Field | Meaning |
| --- | --- |
| `Status` | The HTTP status. `Code()` returns it as text for templates. |
| `Title`, `Description` | Fixed copy, such as "Page not found". |
| `Reason` | `errorpages.ReasonCSRF` for a failed CSRF check, otherwise empty. |
| `RequestID` | The `observability` request ID, when that middleware runs outside. |

`errorpages.New(status, reason)` builds the same value, for tests or for pages
you render yourself. A CSRF failure has the title "Form expired" and tells the
user to reload the page.

## Panics

A panic in a handler or inner middleware:

1. is logged with its stack and the request ID;
2. is passed to `Options.Report`, if set, for an error tracker;
3. becomes a 500 page, with any headers the handler set for its own
   representation (`Content-Type`, `Content-Disposition`) removed.

If the handler had already sent part of its response, the page cannot replace
it. The middleware then aborts the connection with `http.ErrAbortHandler`, so
the client never mistakes a truncated response for a complete one.

```go
errorpages.Options{
    Page:   ErrorPage,
    Logger: logs,
    Report: func(r *http.Request, recovered any, stack []byte) {
        sentry.CurrentHub().Recover(recovered) // any tracker
    },
}
```

## htmx requests

An htmx partial request never receives a full page, because htmx would swap it
into the target element. Instead the response keeps its status and carries:

- `HX-Reswap: none`, so the page stays as it is;
- `HX-Trigger: {"cicadel:error":{"status":403,"title":"Access denied"}}`.

Listen for the event in a static script to show a message. Inline handlers such
as `hx-on` need `unsafe-eval`, which the default content security policy blocks.

```js
document.addEventListener("cicadel:error", (event) => {
  showToast(event.detail.title); // your own notification code
});
```

Full-page htmx navigations, such as boosted links and history restores, get the
page.

## Test it

```go
app := apptest.New(t, s, apps, GlobalMiddleware(slog.New(slog.DiscardHandler))...)
apptest.NewClient(app).
    Request(t, "GET", "/missing", nil, http.Header{"Accept": {"text/html"}}).
    Status(t, 404).
    Contains(t, "Page not found")
```

## Limits

- Requests rejected before your middleware runs keep plain text: a host not in
  `AllowedHosts` (400) and the framework's last-resort panic recovery.
- A page-specific `Content-Security-Policy` set by the failing handler still
  applies to the error page.
- A panic recovered here is not seen by `observability.Middleware`, so its
  `Event.Panicked` is false. The request is still logged with status 500, and the
  panic is logged here with its stack.
