diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 05abff3..d5ca405 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -2,9 +2,10 @@ name: structpages description: > Guide for building Go web applications with the structpages framework (struct-based routing + templ + HTMX). - Use when writing routes, page handlers, Props methods, templ templates, HTMX partial rendering, - URL generation (URLFor/ID/IDTarget), RenderTarget/RenderComponent patterns, or middleware with structpages. - Also use when the user asks about structpages patterns, conventions, or debugging structpages issues. + Use when writing routes, pages, page groups, Props methods, handler methods (ServeHTTP), page components, + partials, HTMX partial rendering and nested swap levels, URL generation (URLFor/ID/IDTarget), + RenderTarget/RenderComponent patterns, or middleware with structpages. + Also use when the user asks about structpages patterns, conventions, vocabulary, or debugging structpages issues. --- # structpages Framework Guide @@ -16,6 +17,62 @@ structpages provides struct-based routing for Go web apps, integrating with `htt For detailed API docs, see [reference.md](reference.md). For real-world patterns and examples, see [examples.md](examples.md). +## 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. + +### 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 | + +## 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`. + ## Core Concepts ### 1. Route Definition @@ -27,13 +84,15 @@ type pages struct { home `route:"/{$} Home"` // exact root match about `route:"/about About"` // all methods (default) create `route:"POST /create Create"` // POST only - detail `route:"/item/{id} Item"` // path parameter + detail `route:"/item/{itemId} Item"` // path parameter files `route:"/files/{path...} Files"` // wildcard } ``` If no method is given, the route accepts all methods (internally stored as `"ALL"`). +**Name path params specifically — `{itemId}`, not `{id}`.** Nested routes compose into a single pattern, so two levels each declaring `{id}` collide: ServeMux rejects duplicate wildcard names in a pattern (`/order/{id}/item/{id}` panics at mount), and `URLFor`'s `map[string]any` params couldn't tell them apart anyway. Specific names compose cleanly: `/order/{orderId}/item/{itemId}`. + Nesting creates URL hierarchies: ```go @@ -46,32 +105,13 @@ type adminPages struct { } ``` -**Mounting a module's static-asset subtree alongside its pages.** Use the wildcard form for prefix subtrees — `path.Join` strips trailing slashes when computing the full route, so `route:"/static/"` registers as an exact `GET /admin/static` (no prefix match). Use `{path...}` instead: - -```go -type adminPages struct { - dashboard `route:"/{$} Dashboard"` - users `route:"/users Users"` - Assets staticFiles `route:"GET /static/{path...} Assets"` -} - -//go:embed all:static -var staticFS embed.FS -var staticRoot = must(fs.Sub(staticFS, "static")) - -type staticFiles struct{} -func (staticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) { - http.ServeFileFS(w, r, staticRoot, r.PathValue("path")) -} -``` - -This keeps the module self-contained: `/admin` and `/admin/static/*` register together, with no separate `pub.Handle("/admin/static/", …)` call to keep in sync. See examples.md §12 for the full pattern, including middleware and link-side considerations. +**Mounting a module's static-asset subtree alongside its pages.** Use the wildcard form for prefix subtrees — `path.Join` strips trailing slashes when computing the full route, so `route:"/static/"` registers as an exact `GET /admin/static` (no prefix match). Mount `route:"GET /static/{path...} Assets"` on a small `ServeHTTP` page serving an embedded FS instead. This keeps the module self-contained: `/admin` and `/admin/static/*` register together, with no separate `pub.Handle(…)` call to keep in sync. Full pattern (embed, middleware, link-side considerations): examples.md §12. -### 2. Page Handler Patterns +### 2. Page Response Patterns -There are three main patterns — choose based on what the page does. +There are four main shapes — choose based on what the page does. The first renders declaratively (Props method + Page method); the other three are handler methods (`ServeHTTP`). -**Pattern A: Props + Page/Content (renders HTML)** +**A page that renders: Props method + Page method** ```go type MyPage struct{} @@ -102,7 +142,9 @@ templ (p MyPage) Content(props MyPageProps) { } ``` -**Pattern B: ServeHTTP that writes, then re-renders a sibling component (most common HTMX form action)** +For regions inside `Content` that must swap independently (master-detail panes, dialogs), add inner levels — see §5c. + +**A handler method that returns a partial (most common HTMX form action)** ```go type AddTodo struct{} @@ -112,28 +154,35 @@ func (a AddTodo) ServeHTTP(w http.ResponseWriter, r *http.Request) error { if text != "" { store.Add(text) } - // Render the sibling page's TodoList component as the response - return structpages.RenderComponent(Index.TodoList) + // Construct the refreshed partial and return it as the response + return structpages.RenderComponent(Index{}.TodoList(store.List())) } ``` -`RenderComponent(SomePage.SomeMethod)` is a method-expression: the framework finds that page, applies DI, and invokes the method. This is the canonical pattern for POST/DELETE handlers that update state and return a refreshed partial *belonging to another page*. When you're rendering a component on the *same* page (you have its receiver in scope), prefer constructing the component directly — see §5. +This is the canonical pattern for POST/DELETE handlers that update state and return a refreshed partial. **Pass a constructed component** — a normal Go call the compiler checks. Page structs are stateless, so a zero-value receiver (`Index{}`) constructs a *sibling* page's component just as well as your own. The reflective method-expression form (`RenderComponent(Index.TodoList)`) is reserved for components whose parameters the framework should DI-inject — see §5b. -**Pattern C: ServeHTTP for redirects (no HTML response)** +**A handler method that redirects (no HTML response)** + +Don't call `http.Redirect` directly 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 control-flow signal instead and let the global error handler send the right mechanism per request kind: `HX-Location` for HTMX (ajax navigation, like a boosted link; `HX-Redirect` instead when the destination needs a full browser load), 303 otherwise. ```go -type SubmitForm struct{} +// Control-flow signal, not a real error — rides the error-return path. +type Redirect struct{ To string } +func (Redirect) Error() string { return "redirect" } func (p SubmitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppContext) error { // perform action... - http.Redirect(w, r, "/somewhere", http.StatusSeeOther) - return nil + url, err := structpages.URLFor(r.Context(), DetailPage{}, map[string]any{"itemId": id}) + if err != nil { return err } + return Redirect{To: url} } ``` -**Pattern D: ServeHTTP for API/JSON endpoints (no error return)** +The `WithErrorHandler` branch that turns `Redirect` into the response is in examples.md §13. The URL comes from `URLFor`, never a string literal (`route-literal` lint). + +**A handler method that serves JSON (API endpoint, no error return)** -API endpoints use the **no-error** form so writes go straight to the wire (unbuffered) and the framework's HTML error handler stays out of it: +API endpoints use the **no-error** form so writes go straight to the wire (unbuffered) and the framework's HTML error handler stays out of it. You own the response — including errors, which are JSON like everything else (no `http.Error`; its `text/plain` body is not an API response): ```go type TrackTime struct{} @@ -141,15 +190,22 @@ type TrackTime struct{} func (p TrackTime) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppContext) { var body trackTimeRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request") return } if err := appCtx.Store.UpdateTime(r.Context(), body); err != nil { - http.Error(w, "update failed", http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "update failed") return } w.WriteHeader(http.StatusOK) } + +// One small app-level helper — the API's single error shape: +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}) +} ``` `ServeHTTP` supports four signatures (see reference.md for details). The DI form (extra arg types beyond `w, r`) buffers the response only when the method has a return value. @@ -160,7 +216,7 @@ The error-returning forms of `ServeHTTP` — and **every** `Props` method — ru - **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. - **For a specific status code, return a typed error** (e.g. `ErrorWithStatus{Status, Title, Message}`) that the global handler unwraps with `errors.As`. Plain errors fall through to a logged 500. -- **API/JSON endpoints use Pattern D** (no error return) — there `http.Error` and direct `w` writes are correct, because you own the status code and skip the buffering wrapper. +- **API/JSON endpoints use the no-error handler-method 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`. - **For streaming (SSE), flush with `http.NewResponseController(w)`** — it works from either `ServeHTTP` form (the buffered wrapper implements `FlushError()`/`Unwrap()`) and is the only way to *guarantee* unbuffered delivery through other middleware. See examples.md §13 for the full pattern, including the `WithErrorHandler` wiring. @@ -171,8 +227,8 @@ See examples.md §13 for the full pattern, including the `WithErrorHandler` wiri ```templ Link -Detail -