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
355 changes: 67 additions & 288 deletions docs/advanced.md

Large diffs are not rendered by default.

420 changes: 103 additions & 317 deletions docs/api.md

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Concepts
slug: /concepts
sidebar_position: 3
---

# Concepts & Vocabulary

structpages has its own canonical terms for its recurring patterns. Where a React / Next.js / React Router concept maps cleanly, it's noted as a cross-reference for knowledge transfer — but the structpages term is primary. Two guardrails: Go wins where Go owns the concept (`ServeHTTP` is a **handler method**, not a "server action"), and pure composition isn't named (a layout is just a **component** that takes **children** — there's no "layout route").

## Core nouns

| Term | What it is | Cross-ref |
|---|---|---|
| **page** | a route-tagged struct — a node in the route tree | Next/RR route/page |
| **page group** | a page with no render of its own (no `Page` or `ServeHTTP`), only child pages; served through its `/{$}` page | — (not a "layout route") |
| **component** | a standalone `templ Foo()` block — reusable, mount-independent, package-prefixed id | React component |
| **page component** | a `templ (p Page) Foo()` method — mount-aware, receiver in scope (incl. `Page`, `Content`). Used two ways: **composition** (called inside another page component) and **re-rendering** (returned alone as a partial) | React component (bound) |
| **children** | templ `{ children... }` composition | React children |
| **partial** | a page component returned on its own as an HTMX response to re-render just that region — a *role* a page component plays, not a distinct kind | HTMX |

## The props cluster

| Term | What it is | Cross-ref |
|---|---|---|
| **Props method** | the `Props(...)` method that loads data via DI | *like RR `loader` / Next `getServerSideProps`* |
| **props struct** | the named struct type the Props method returns and page components accept | *like a React props type* |
| **props** | a value of the props struct, in flight into a page component | React props (the value) |

The chain reads: the **Props method** returns a **props struct**; that **props** value is handed to a **page component**.

## Methods on a page

| Term | Method | Job |
|---|---|---|
| **Page method** | `Page(props)` | the main render entry — a page component that composes the full page (layout + content) |
| **Props method** | `Props(...)` | loads data via DI → returns the props struct |
| **handler method** | `ServeHTTP(...)` | imperative entry: mutate / redirect / serve JSON, or render a partial via `RenderComponent` — the Go `http.Handler` shape |
| **Middlewares method** | `Middlewares()` | declares middleware for the page + descendants |

(`Content` is not a framework concept — just a conventional page component name for a layout's main region; the matcher treats it like any other page component.)

The two render entries differ in flavor: the **Page method** renders declaratively (compose page components); the **handler method** renders imperatively (write the response, or hand a page component to `RenderComponent`). Both ultimately render through page components.

## Request lifecycle

For a rendering page: **route match → Props method** (with `RenderTarget` injected to pick the region) **→ page component render** — `Page` for full loads, a partial for HTMX requests targeting that region's id. A handler method (`ServeHTTP`) bypasses this pipeline: it responds imperatively, optionally handing a page component to `RenderComponent`.

## API helpers (literal — these are the public API)

`RenderComponent`, `RenderTarget`, `URLFor`, `ID` / `IDTarget`, `Ref`, `WithArgs` (dependency injection / **args**).

## Loose comparisons (analogies, not structpages terms)

For readers arriving from React/Next — transfer aids, not structpages vocabulary.

| structpages | React/Next analogy | note |
|---|---|---|
| `/{$}` route of a page group | RR **index route** | nothing special — just the group's own page |
| **Page method** vs **handler method** | declarative `page` vs imperative **Route Handler / API route** | two ways to respond within one router — **not** "Page Router vs App Router" |
| **component** composition | Server Component composition | both render on the server |
168 changes: 168 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
---
title: Error Handling
slug: /error-handling
sidebar_position: 9
---

# Error Handling

The error-returning forms of `ServeHTTP` — and **every** Props method — run against a *buffered* response writer. On a non-nil error the buffer is discarded and the error goes to the `WithErrorHandler` callback, which renders a clean error page even if the handler had already written partial output. Everything in this guide follows from that one mechanism.

## The rules

1. **Never call `http.Error` (or write `w`) in an error-returning handler or in Props.** If you write then `return err`, the write is discarded; if you write then `return nil`, you bypass the error handler. Just return the error.
2. **For a specific status code, return a typed error** that the global handler unwraps with `errors.As`. Plain errors fall through to a logged 500.
3. **API/JSON endpoints use the no-error `ServeHTTP` form** — direct `w` writes are correct there because you own the status code and skip the buffering wrapper. Write JSON error bodies, not `http.Error`.
4. **For streaming (SSE), flush with `http.NewResponseController(w)`** — it works from either `ServeHTTP` form and is the only way to guarantee unbuffered delivery through middleware.

## Typed errors for status codes

Define one error type that carries the status and message; the global handler renders it:

```go
type ErrorWithStatus struct {
Status int
Title string
Message string
}

func (e ErrorWithStatus) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Title, e.Message) }
```

```go
func (p detailPage) Props(r *http.Request, store *Store) (DetailProps, error) {
item, err := store.Load(r.Context(), r.PathValue("itemId"))
switch {
case errors.Is(err, ErrNotFound):
return DetailProps{}, ErrorWithStatus{Status: http.StatusNotFound, Title: "Not found", Message: "no such item"}
case err != nil:
return DetailProps{}, fmt.Errorf("detail: load: %w", err) // plain error → logged 500
}
return DetailProps{Item: item}, nil
}
```

`errors.As` unwraps, so `fmt.Errorf("...: %w", ErrorWithStatus{...})` still resolves to its status.

## Redirects: a control-flow signal, not `http.Redirect`

Don't call `http.Redirect` from a handler in an HTMX app — during an HTMX request the XHR follows the 3xx and swaps the redirect *target's* body into the partial's swap target. Return a signal instead; the error handler sends `HX-Location` for HTMX (ajax navigation, like a boosted link) and a 303 otherwise:

```go
// Redirect is control flow, not a real error — it implements error only to
// ride the error-return path, which unwinds the render flow without writing
// the ResponseWriter directly.
type Redirect struct{ To string }

func (Redirect) Error() string { return "redirect" }
```

```go
func (p submitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error {
id, err := store.Save(r.Context(), r.FormValue("name"))
if err != nil {
return err
}
url, err := structpages.URLFor(r.Context(), detailPage{}, map[string]any{"itemId": id})
if err != nil {
return err
}
return Redirect{To: url}
}
```

Use `HX-Redirect` instead of `HX-Location` only when the destination genuinely needs a full browser load — a non-htmx endpoint, or a page with different `<head>` content/scripts.

## The global handler

Wired once at `Mount`, it owns every error response — typed statuses, redirects, cancellations, and the logged-500 fallback:

```go
structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, context.Canceled) || r.Context().Err() != nil {
w.WriteHeader(499) // client closed request — expected, don't log as error
return
}
var redir Redirect
if errors.As(err, &redir) {
if r.Header.Get("HX-Request") == "true" {
// Ajax navigation, like a boosted link. The status must stay 2xx:
// htmx does not process response headers on 3xx responses.
w.Header().Set("HX-Location", redir.To)
return
}
http.Redirect(w, r, redir.To, http.StatusSeeOther)
return
}
status, title, message := http.StatusInternalServerError, "Server error", err.Error()
var se ErrorWithStatus
if errors.As(err, &se) {
status, title, message = se.Status, se.Title, se.Message
} else {
slog.Error("unhandled error rendering page", "error", err, "path", r.URL.Path)
}
// One place that knows how to render: HTMX-aware retarget, full layout vs bare page.
renderHTTPError(w, r, status, title, message)
})
```

## JSON endpoints: the no-error form

For endpoints that serve JSON, use the no-return `ServeHTTP(w, r, deps...)` signature. It is unbuffered, the HTML error handler is never invoked, and you own the response — including errors, which are JSON like everything else. Don't reach for `http.Error`; its `text/plain` body is the wrong shape for an API client:

```go
func (p trackTime) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) {
var body trackTimeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request")
return
}
if err := store.UpdateTime(r.Context(), body); err != nil {
writeJSONError(w, http.StatusInternalServerError, "update failed")
return
}
w.WriteHeader(http.StatusOK)
}

// The API's single error shape, defined once:
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
```

## Streaming (SSE)

Picking the no-return form is not enough to guarantee writes reach the client immediately — `w` may still be wrapped by middleware. Use `http.ResponseController`, which walks the `Unwrap()` chain to find a flusher:

```go
func (p progress) ServeHTTP(w http.ResponseWriter, r *http.Request, jobs *JobService) error {
w.Header().Set("Content-Type", "text/event-stream")
rc := http.NewResponseController(w)
for update := range jobs.Progress(r.Context()) {
fmt.Fprintf(w, "event: progress\ndata: %s\n\n", update)
if err := rc.Flush(); err != nil {
return nil // client gone
}
}
return nil
}
```

This works from *either* `ServeHTTP` form — the buffered wrapper implements `FlushError()` and `Unwrap()`. Once you've started flushing, a non-nil error can no longer produce a clean error page (bytes are on the wire) — send an `event: error` SSE frame instead and `return nil`.

## Which form to use

| Handler does… | `ServeHTTP` signature | Errors via |
|---|---|---|
| Renders HTML / HTMX partial | `(w, r, deps...) error` | `return ErrorWithStatus{…}` / `return err` |
| Redirects | `(w, r, deps...) error` | `return Redirect{To: …}` |
| Serves JSON / API | `(w, r, deps...)` *(no return)* | write `w` directly with a JSON error body |
| Streams (SSE, progress) | either form + `http.NewResponseController` | SSE `event: error` frame, then `return nil` |

Props methods always follow the first row — they are buffered and their errors flow to `WithErrorHandler`.

## ErrSkipPageRender

If a Props method writes the response itself (rare — prefer the `Redirect` signal), return `structpages.ErrSkipPageRender` to skip rendering. Only the Props error path checks this sentinel; returning it from `ServeHTTP` does nothing special.
Loading
Loading