From 150d1e2214133285a19353c77fb347b380a710f0 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 20:57:15 +0100 Subject: [PATCH 01/10] docs(skill): canonical vocabulary, trim SKILL.md, emphasize the partial loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish canonical terms for recurring patterns (page, page group, component, page component, partial, Props method / props struct, handler method) with React/Next as cross-reference only, and apply them consistently: - Add Vocabulary and Request Lifecycle sections; retire Pattern A-D letters in favor of named shapes - Open §4 with the central partial-rendering loop: one method reference drives composition id={ID(…)}, trigger hx-target={IDTarget(…)}, and server sel.Is(…) (new Key Rule 14) - Add §5c nested swap levels (Page → Content → Detail), upstreamed and generalized; corrected to exact id match, not deepest-method - Trim SKILL.md 612→486 lines: URL-validation listings move to examples.md §14, lint details defer to reference.md §Lint Tool, drop redundant code blocks, tersen Key Rules 5/10/11 - De-emphasize Content (naming convention, not framework concept); drop local wrapper suggestion --- skills/structpages/SKILL.md | 281 +++++++++++++------------------- skills/structpages/examples.md | 81 +++++++++ skills/structpages/reference.md | 2 +- 3 files changed, 198 insertions(+), 166 deletions(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 05abff3..367b49f 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 @@ -46,32 +103,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 +140,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{} @@ -119,7 +159,7 @@ func (a AddTodo) ServeHTTP(w http.ResponseWriter, r *http.Request) error { `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. -**Pattern C: ServeHTTP for redirects (no HTML response)** +**A handler method that redirects (no HTML response)** ```go type SubmitForm struct{} @@ -131,7 +171,7 @@ func (p SubmitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *Ap } ``` -**Pattern D: ServeHTTP for API/JSON endpoints (no error return)** +**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: @@ -160,7 +200,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** — there `http.Error` and direct `w` writes are correct, because you own the status code and skip the buffering wrapper. - **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. @@ -198,26 +238,9 @@ url, err := structpages.URLFor(ctx, **Always strict.** A bare type that matches multiple nodes errors instead of silently picking one. The error lists every match and recommends the chain form. There is no opt-out — silent first-match is always wrong, so disambiguating at the call site is mandatory. -**Container pages resolve to their index.** A subtree container — a page struct with no render logic of its own, only child routes — is never served at its bare path: ServeMux matches only its subtree, and the bare path 307-redirects to add the trailing slash. So `URLFor` on a container returns its index child's URL (the `/{$}` route), carrying the canonical trailing slash: `URLFor(ctx, Section{})` → `/section/`, not `/section`. Leaf pages return their own bare path unchanged. Link a container by its type and the URL serves a 200 directly, with no redirect hop — don't hand-append a trailing slash, and don't link to the slashless form. - -**Chain semantics.** Inside `[]any{...}`, leading typed values form a chain through the page tree: the first resolves to a node via the normal lookup; each subsequent typed value descends into a child of that type (must be unique among siblings, else error). Once a string appears, no more typed values are allowed; remaining strings concat literally to the pattern. This is the same as the existing composition slice — the new bit is that *multiple* typed values form a chain. - -**Wrong shape — interleaving fails.** The slice is positional: all chain steps first, then all URL fragments. Mixing them rejects at runtime: +**Page groups resolve to their index.** A page group — a page with no render of its own, only child pages — is never served at its bare path: ServeMux matches only its subtree, and the bare path 307-redirects to add the trailing slash. So `URLFor` on a page group returns its index child's URL (the `/{$}` route), carrying the canonical trailing slash: `URLFor(ctx, Section{})` → `/section/`, not `/section`. Leaf pages return their own bare path unchanged. Link a page group by its type and the URL serves a 200 directly, with no redirect hop — don't hand-append a trailing slash, and don't link to the slashless form. -```go -// Wrong — typed value after a string fragment: -url, _ := structpages.URLFor(ctx, - []any{componentsRoot{}, "?tab={tab}", entryPage{}}, - map[string]any{"slug": "x", "tab": "props"}) -// → error: URLFor: typed value at slice position 2 follows a string -// fragment; chain steps must all come before any string fragment - -// Right — chain first, fragments after: -url, _ := structpages.URLFor(ctx, - []any{componentsRoot{}, entryPage{}, "?tab={tab}"}, - map[string]any{"slug": "x", "tab": "props"}) -// → "/components/x?tab=props" -``` +**Chain semantics.** Inside `[]any{...}`, leading typed values form a chain through the page tree: the first resolves to a node via the normal lookup; each subsequent typed value descends into a child of that type (must be unique among siblings, else error). Once a string appears, no more typed values are allowed; remaining strings concat literally to the pattern. The slice is positional — all chain steps first, then all URL fragments; a typed value after a string fragment errors at runtime with the offending position. ```go type root struct { @@ -239,116 +262,25 @@ url, err := structpages.URLFor(ctx, #### Validating URLs (no dangling URLs in production) -Both the chain form (field-name strings show up as type identity once compiled — but page names, route strings, and Ref strings remain stringly typed) and `Ref` carry strings somewhere. Strings are fine — they just need to be validated. Two complementary guards: +Page names, route strings, and Ref strings stay stringly typed even in the chain form. Strings are fine — they just need to be validated. Two complementary guards (full listings in examples.md §14): -**1. Init-time validator — fails the boot, not the first request.** +1. **Init-time validator** — a `validateURLs(sp)` inventory of `sp.URLFor` calls run at boot, so a renamed field, moved route, or broken Ref kills the startup with the list of what's dangling, instead of failing on first request. +2. **Typed URL helpers + an integration test** — one helper per URL family (the only strings live there), plus a test that mounts the tree, renders real pages, and asserts the expected `href`s appear in the body. -```go -// validate.go -func validateURLs(sp *structpages.StructPages) error { - var errs []error - check := func(label string, gen func() (string, error)) { - if _, err := gen(); err != nil { - errs = append(errs, fmt.Errorf("%s: %w", label, err)) - } - } - check("home", func() (string, error) { return sp.URLFor(homePage{}) }) - check("components detail", func() (string, error) { - return sp.URLFor([]any{componentsRoot{}, entryPage{}}, - map[string]any{"slug": "sample"}) - }) - // For Refs (cross-package, where importing would cycle): - check("admin settings", func() (string, error) { - return sp.URLFor(structpages.Ref("Admin.Settings")) - }) - return errors.Join(errs...) -} - -// main.go -sp, err := structpages.Mount(mux, &root{}, "/", "App") -if err != nil { log.Fatal(err) } -if err := validateURLs(sp); err != nil { - log.Fatalf("URL validation failed:\n%v", err) -} -``` - -A renamed field, moved route, or broken Ref now kills the boot with the inventory of what's dangling. Same dynamic as a database migration check: refuse to start serving if the world doesn't look right. - -**2. Wrap URL generation in typed helpers + an integration test.** - -```go -// urls.go — one helper per URL family. The only strings live here. -func urlForGroupIndex(ctx context.Context, group string) (string, error) { - parent, ok := groupParent(group) - if !ok { return "", fmt.Errorf("unknown group %q", group) } - return structpages.URLFor(ctx, []any{parent, groupIndex{}}) -} - -// integration_test.go — mount, render, assert URLs in the body. -func TestRenderedURLsResolve(t *testing.T) { - mux := http.NewServeMux() - structpages.Mount(mux, &root{}, "/", "App") - cases := []struct{ path string; wantContains []string }{ - {"/", []string{`href="/foundations/"`, `href="/components/"`}}, - {"/components/", []string{`href="/components/button"`}}, - } - for _, tc := range cases { - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil)) - for _, want := range tc.wantContains { - if !strings.Contains(rec.Body.String(), want) { - t.Errorf("%s body missing %q", tc.path, want) - } - } - } -} -``` - -The helper layer narrows the surface of refactorable strings; the integration test catches drift in helpers, parents, fields, routes, or accidental ambiguity, exercised end-to-end through the real renderer. - -**Why both?** The validator runs at boot — safety net for production deploys, even if CI was skipped. The integration test runs in CI — fast feedback during development, with a clearer diff when something breaks. A `TestValidateURLs` in your test file that just calls the validator gives you the validator's coverage in CI too, for one extra line. - -What this catches: -- **Renamed field** in a parent struct → chain step errors with the parent's available children listed. -- **Renamed route or page** referenced by `Ref` → `no page found with route/name "..."`. -- **New page type introducing strict-mode ambiguity** → URLFor errors at the bare lookup. -- **Call site bypassing helpers** → the rendered body lacks the URL the test asserts. - -See `examples/url-validation/` for the full pattern: `urls.go` (helpers), `validate.go` (init-time inventory), `integration_test.go` (end-to-end). The library's `chain_test.go` covers the URL-shape mechanics at the unit level. +Between them this catches renamed fields (chain step errors), renamed routes/pages (`Ref` resolution errors), new strict-mode ambiguity, and call sites bypassing the helpers. See `examples/url-validation/` in the repo for the runnable version. #### Lint your templates and URL calls -`structpages-lint ./...` catches four classes of bug in CI: - -- Dangling `URLFor` / `Ref` calls — renamed routes, ambiguous lookups, wrong params (`urlfor`, `ref`, `params`). -- Bad `ID` / `IDTarget` method expressions — receiver not mounted as a page (`id`, `idtarget`). -- **URL-bearing HTML attributes** in `.templ` files (`href`, `action`, `formaction`, `hx-{get,post,put,patch,delete}`, `hx-{push,replace}-url`) whose values are hard-coded internal paths, string concats, or `fmt.Sprint*` — i.e. cases where you should have called `structpages.URLFor` (`url-attr`). Allows `https://`, `mailto:`, `#`, `//cdn.example.com/...`. -- **Route string literals** in `.go` files whose value exactly equals a mounted route — e.g. `return "/admin/queues"` or `http.Redirect(w, r, "/orders", …)` — where you should resolve the URL by page type via `structpages.URLFor(ctx, SomePage{})` so renames are caught here instead of drifting (`route-literal`). Deliberately narrow: only an exact concrete-route match counts (param/`{$}` routes, trailing-slash and query variants, and the bare `/` never match); literals in `==`/`switch` comparisons and `Ref("…")` args are skipped (they read a route, they don't generate a URL); `_test.go` and generated files are skipped. - **Rule of thumb: never write an in-app URL as a string literal.** Resolve it by page type — `structpages.URLFor(ctx, SomePage{})` — so the literal can't drift when routes move; the typed call breaks the build instead. When an import cycle blocks naming the page type (a shared chrome package that its own leaf pages import), register a URL resolver from the package that *can* see the types, rather than reaching for a hard-coded route string. -Install once, then wire into CI alongside `go test`: +`structpages-lint` enforces this in CI. Install once, then wire in alongside `go test`: ```shell go install github.com/jackielii/structpages/tools/lint/cmd/structpages-lint@latest structpages-lint ./... ``` -Suppress a single diagnostic with a comment. **Prefer `//` in both `.go` and `.templ`** — Go-style comments are stripped from the generated HTML, while `` HTML comments render into every response. - -```go -//structpages:lint:ignore url-attr -url := structpages.URLFor(...) // in .go files -``` - -```templ -// structpages:lint:ignore url-attr - // in .templ files (preferred) -``` - -`` also works in `.templ` for the rare case where you actually want the directive visible to anyone viewing source, but the `//` form is the default. - -The directive applies to its own line and the line immediately below, so placing it above an element works the same as inline. Multiple categories are comma-separated; bare `structpages:lint:ignore` suppresses every category on that line. +It catches four classes of bug: dangling `URLFor`/`Ref` calls (`urlfor`, `ref`, `params`), unmounted `ID`/`IDTarget` receivers (`id`, `idtarget`), hard-coded URLs in `.templ` URL-bearing attributes (`url-attr`), and `.go` string literals that equal a mounted route (`route-literal`). See reference.md §Lint Tool for the full category table and the `structpages:lint:ignore` suppression syntax (prefer `//`-style directives in both `.go` and `.templ` — HTML comments render into every response). When you need a plain string (not in a templ attribute that handles errors), wrap with a small `must` helper: @@ -361,24 +293,17 @@ func must[T any](v T, err error) T { myURL := must(structpages.URLFor(ctx, MyPage{})) ``` -**Optional convenience wrappers.** Some apps define short local wrappers like `urlFor`, `idFor`, `idForTarget` — e.g. to return `templ.URL` or to shorten the package qualifier. These are app-level conveniences, not framework functions: - -```go -// in your app — purely optional -func urlFor(ctx context.Context, page any, args ...any) (string, error) { - return structpages.URLFor(ctx, page, args...) -} -func idFor(ctx context.Context, v any) (string, error) { return structpages.ID(ctx, v) } -func idTarget(ctx context.Context, v any) (string, error) { return structpages.IDTarget(ctx, v) } -``` +### 4. HTMX Partial Rendering -The rest of this guide uses the framework names (`structpages.URLFor`, `structpages.ID`, `structpages.IDTarget`) directly. +This is the framework's central loop. **One method reference — e.g. `MyPage.UserList` — drives three sites that must agree, and `ID`/`IDTarget` make them agree by construction:** -### 4. HTMX Partial Rendering +1. **Composition site** — where the page component is composed in, wrap it in an element with `id={ structpages.ID(ctx, MyPage.UserList) }`. +2. **Trigger site** — the element that fires the update points `hx-target={ structpages.IDTarget(ctx, MyPage.UserList) }` at the page's own route (`hx-get={ structpages.URLFor(ctx, MyPage{}) }`). +3. **Server site** — all HTMX requests for a page go to the SAME route; structpages matches the `HX-Target` header back to the page component by id, and the Props method branches on the injected `RenderTarget` with `sel.Is(p.UserList)` to load just that region's data and render it (§5). -All HTMX requests for a page go to the SAME route. structpages picks which component to render from the `HX-Target` header by matching element IDs against component method names. +Because all three derive from the same method reference, renaming the method or moving the mount can't desynchronize them — there is no string id to drift. Never hand-write the id at one site and generate it at another. -`structpages.ID` / `structpages.IDTarget` generate deterministic element IDs from method references. The id is the page's **full field-name path from the root** joined with the method (`ID` returns `"my-page-user-list"` for a top-level page, `"admin-users-user-list"` when nested; `IDTarget` prepends `#`). Including the ancestor path guarantees two different mounts never collide. If the full id exceeds the length budget (default 40 chars, see `WithMaxIDLength`) it degrades to the compact leaf-only form (`"user-list"`) with a stable hash suffix when the leaf name is shared. Standalone-function components are prefixed by their package name (`ID(ctx, UserWidget)` → `"-user-widget"`). For plain string arguments both functions return the string unchanged — `IDTarget("body")` is `"body"`, not `"#body"`. +`structpages.ID` / `structpages.IDTarget` generate deterministic element IDs from method references. The id is the page's **full field-name path from the root** joined with the method (`ID` returns `"my-page-user-list"` for a top-level page, `"admin-users-user-list"` when nested; `IDTarget` prepends `#`). Including the ancestor path guarantees two different mounts never collide. If the full id exceeds the length budget (default 40 chars, see `WithMaxIDLength`) it degrades to the compact leaf-only form (`"user-list"`) with a stable hash suffix when the leaf name is shared. Components (standalone `templ` blocks) are prefixed by their package name (`ID(ctx, UserWidget)` → `"-user-widget"`). For plain string arguments both functions return the string unchanged — `IDTarget("body")` is `"body"`, not `"#body"`. ```templ // Set element ID on the component's wrapper @@ -443,7 +368,7 @@ Why this form: `p.UserList(users)` is a normal Go call, so the compiler checks a Note: only methods named `Props` are auto-invoked. `*Props`-suffixed helpers (e.g. `userListData` above; some codebases call them `UserListProps`) are *just regular methods* the user calls from inside `Props` — there's no priority resolution. -Standalone function components work the same way — just call the function: +Components (standalone `templ` blocks) work the same way — just call the function: ```go case sel.Is(UserStatsWidget): @@ -464,6 +389,31 @@ func (p MyDelete) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppC This is the one case where the reflection path earns its keep: you can't construct `MyPage.ItemList(items)` directly because you don't have a `MyPage` instance, and the method may rely on DI-injected dependencies the framework will fill in. +### 5c. Nested swap levels (Page → Content → Detail) + +A page's page components can be composed into **nested swap levels**, each an independent HTMX target. The outer level wraps the next in its templ; the levels are *not* a tree the matcher walks — they're sibling page components on one page, each with its own id (§4). Because `HX-Target` selects the page component whose id it matches exactly, an `HX-Target` of a given level's id re-renders *only* that level, even though `Page` composes `Content` composes `Detail`. Compose one level per region you need to swap on its own: + +- **`Page`** (the Page method) — the full document. Rendered on a cold load / `hx-boost` body swap. Composes the app layout around `Content`. +- **`Content`** — the page's main region (a naming convention, not a framework concept). Holds the page chrome — heading, back-link, toolbar — around the inner level. Rendered when only the main content swaps (boosted nav between pages). +- **`Detail`** (or another inner name) — a region *inside* `Content` that must swap on its own, independently of the chrome. Holds **none** of the page chrome. + +```templ +templ (d FooDetail) Page(p Props) { @ui.Layout(title) {
@d.Content(p)
} } +templ (d FooDetail) Content(p Props) {
+ ← Foos // standalone-page chrome + @d.Detail(p) +
} +templ (FooDetail) Detail(p Props) {
+ … fields, lifecycle actions, dialog mount … // NO back-link, NO header +
} +``` + +**Why three levels, not two.** The trap is reusing `Content` as the swap fragment for an embedded region — e.g. a master-detail inspector pane hosting the *standalone detail page's* `Content`. That drags the page chrome (back-link, page header, outer container) into the pane, where it's wrong. Splitting out `Detail` gives the embedded region a chrome-less partial while `Content` keeps the standalone-page chrome. **The level you embed/swap is the one with no chrome of its own.** + +**Master-detail rule of thumb.** The list page renders a detail *mount* whose id is `ID(ctx, FooDetail.Detail)`; rows `hx-get` the detail route with `hx-target = IDTarget(ctx, FooDetail.Detail)`. Lifecycle actions and dialog handlers that re-render the detail also target — and `RenderComponent` — `FooDetail.Detail`, never `.Content`. The standalone detail page (deep-link / no-JS) is the only thing that renders `Content` (chrome + `Detail`). + +Add a fourth level whenever a sub-region needs to swap independently again — the rule generalizes: **one page component per independently-swappable region, outer wraps inner, embed/target the innermost that has no chrome above it.** + ### 6. Middleware Global middleware via `WithMiddlewares`. Page-specific via `Middlewares()` method (also applies to all descendants): @@ -522,14 +472,15 @@ This is the recommended fix for two patterns that fail under bare-context render 1. **Props methods extract path params** via `r.PathValue("param")`, not function arguments. 2. **Never hardcode URLs** — always use `structpages.URLFor`. -3. **Partial templ methods** take ONLY their specific data, not the full props struct. +3. **Partials take ONLY their specific data**, not the full props struct. 4. **`RenderComponent` is returned as an error** — when returned, the Props return values (other than the error) are ignored. -5. **Prefer `RenderComponent(p.X(args))` to `RenderComponent(MyPage.X, args)` or `RenderComponent(target, args)`** when the receiver is in scope — direct construction is compile-time-checked; the reflective forms defer arg/type checks to runtime. The reflective forms are still correct, just slower and more error-prone; reserve them for cross-page renders where you don't have the receiver. +5. **Prefer `RenderComponent(p.X(args))`** — compile-time-checked. Reserve the reflective forms (`RenderComponent(MyPage.X, args)`) for cross-page renders where you don't have the receiver (§5/§5b). 6. **Children are registered before parents** on the mux (so nested-route conflicts resolve correctly). 7. **Promoted (embedded) methods are skipped** — only methods defined directly on the struct count. 8. **URL params auto-fill from current request's route only** — sibling routes with different param names do not auto-fill. 9. **`ErrSkipPageRender` is only honored from `Props`** (e.g. after writing a redirect). Returning it from `ServeHTTP` does nothing special. -10. **Disambiguation primitives:** when the same page type is mounted under multiple parents, use the `[]any{ParentPage{}, LeafPage{}}` chain form — strict `URLFor` (the default) errors on bare lookups. When a package needs to URL-to a page it can't import (importing would cycle), pass a string as the page arg — `URLFor(ctx, "Parent.Field", ...)` — or use the explicit `Ref("Parent.Field")` form; both resolve the same way. Ref also handles Go type aliases that collapse to one `reflect.Type`. Ref strings are validated at startup via the init-time validator pattern (see §3 "Validating URLs") and by `structpages-lint` (which also validates string args to `URLFor`). -11. **Plain strings pass through `ID` and `IDTarget` unchanged** — `IDTarget("body")` returns `"body"`, not `"#body"`. This is intentionally asymmetric to `URLFor`, where a top-level string is auto-`Ref`; literal CSS selectors are legitimate, literal URL paths are anti-pattern. **For an id that doesn't depend on a page's mount position** (e.g. a slot rendered the same way regardless of which section root mounts the page), define the slot as a standalone function: `func EntryOverlaySlot() templ.Component { ... }` then `IDTarget(ctx, EntryOverlaySlot)` returns `"#-entry-overlay-slot"` — prefixed by the function's package name (so same-named slots in different packages stay distinct), with no dependence on any page's mount path. This is the preferred shape for cross-package slot targeting. +10. **Disambiguation primitives:** type mounted under multiple parents → the `[]any{ParentPage{}, LeafPage{}}` chain form (strict `URLFor` errors on bare lookups). Can't import the page type (cycle) → string page arg / `Ref("Parent.Field")`; validate Ref strings at boot (§3) and with `structpages-lint`. +11. **Plain strings pass through `ID` and `IDTarget` unchanged** — `IDTarget("body")` is `"body"`, not `"#body"` (asymmetric to `URLFor` on purpose: literal CSS selectors are legitimate, literal URL paths are anti-pattern). For an id independent of mount position, define the slot as a component (standalone function) — `IDTarget(ctx, EntryOverlaySlot)` → `"#-entry-overlay-slot"`, package-prefixed, no mount-path dependence. Preferred shape for cross-package slot targeting (§4). 12. **The `form:` struct tag is not read by the framework** — only `route:` is. Anything else on a route field is ignored. 13. **Never write `w` (e.g. `http.Error`) in `Props` or an error-returning `ServeHTTP`** — they are buffered; return the error instead. Use a typed error like `ErrorWithStatus` for a specific status code. API/JSON endpoints use the no-error `ServeHTTP(w, r, deps...)` form, where direct writes are correct (see examples.md §13). +14. **Never hand-write a partial's element id** — derive all three sites (composition `id={ID(…)}`, trigger `hx-target={IDTarget(…)}`, server `sel.Is(…)`) from the same method reference (§4). diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index 8de19af..e0cb09a 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -958,3 +958,84 @@ Once you've started flushing a stream, returning a non-nil error can no longer p | Streams (SSE, progress) | either form, flush via `http.NewResponseController(w)` | SSE `event: error` frame, then `return nil` | `Props` methods always follow the first row — they are buffered and their error flows to `WithErrorHandler`, so return `ErrorWithStatus{…}` for status-coded failures, never write `w`. + +## 14. Validating URLs (no dangling URLs in production) + +Both the chain form (page names, route strings, and Ref strings remain stringly typed) and `Ref` carry strings somewhere. Strings are fine — they just need to be validated. Two complementary guards: + +### Guard 1: Init-time validator — fails the boot, not the first request + +```go +// validate.go +func validateURLs(sp *structpages.StructPages) error { + var errs []error + check := func(label string, gen func() (string, error)) { + if _, err := gen(); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", label, err)) + } + } + check("home", func() (string, error) { return sp.URLFor(homePage{}) }) + check("components detail", func() (string, error) { + return sp.URLFor([]any{componentsRoot{}, entryPage{}}, + map[string]any{"slug": "sample"}) + }) + // For Refs (cross-package, where importing would cycle): + check("admin settings", func() (string, error) { + return sp.URLFor(structpages.Ref("Admin.Settings")) + }) + return errors.Join(errs...) +} + +// main.go +sp, err := structpages.Mount(mux, &root{}, "/", "App") +if err != nil { log.Fatal(err) } +if err := validateURLs(sp); err != nil { + log.Fatalf("URL validation failed:\n%v", err) +} +``` + +A renamed field, moved route, or broken Ref now kills the boot with the inventory of what's dangling. Same dynamic as a database migration check: refuse to start serving if the world doesn't look right. + +### Guard 2: Typed URL helpers + an integration test + +```go +// urls.go — one helper per URL family. The only strings live here. +func urlForGroupIndex(ctx context.Context, group string) (string, error) { + parent, ok := groupParent(group) + if !ok { return "", fmt.Errorf("unknown group %q", group) } + return structpages.URLFor(ctx, []any{parent, groupIndex{}}) +} + +// integration_test.go — mount, render, assert URLs in the body. +func TestRenderedURLsResolve(t *testing.T) { + mux := http.NewServeMux() + structpages.Mount(mux, &root{}, "/", "App") + cases := []struct{ path string; wantContains []string }{ + {"/", []string{`href="/foundations/"`, `href="/components/"`}}, + {"/components/", []string{`href="/components/button"`}}, + } + for _, tc := range cases { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil)) + for _, want := range tc.wantContains { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("%s body missing %q", tc.path, want) + } + } + } +} +``` + +The helper layer narrows the surface of refactorable strings; the integration test catches drift in helpers, parents, fields, routes, or accidental ambiguity, exercised end-to-end through the real renderer. + +### Why both? + +The validator runs at boot — safety net for production deploys, even if CI was skipped. The integration test runs in CI — fast feedback during development, with a clearer diff when something breaks. A `TestValidateURLs` in your test file that just calls the validator gives you the validator's coverage in CI too, for one extra line. + +What this catches: +- **Renamed field** in a parent page → chain step errors with the parent's available children listed. +- **Renamed route or page** referenced by `Ref` → `no page found with route/name "..."`. +- **New page type introducing strict-mode ambiguity** → URLFor errors at the bare lookup. +- **Call site bypassing helpers** → the rendered body lacks the URL the test asserts. + +See `examples/url-validation/` in the repo for the full runnable pattern: `urls.go` (helpers), `validate.go` (init-time inventory), `integration_test.go` (end-to-end). The library's `chain_test.go` covers the URL-shape mechanics at the unit level. diff --git a/skills/structpages/reference.md b/skills/structpages/reference.md index f0c5548..d1338b8 100644 --- a/skills/structpages/reference.md +++ b/skills/structpages/reference.md @@ -385,7 +385,7 @@ Diagnostic categories: | `ref` | `structpages.Ref(...)` strings that don't resolve to a page tree node. | | `id`, `idtarget` | `structpages.ID` / `IDTarget` method expressions whose receiver is not mounted. | | `params` | `URLFor` params that don't appear in the route pattern. | -| `url-attr` | URL-bearing HTML attributes in `.templ` files (`href`, `action`, `formaction`, `hx-{get,post,put,patch,delete}`, `hx-{push,replace}-url`) whose values are hard-coded internal paths, string concats, or `fmt.Sprint*` calls. | +| `url-attr` | URL-bearing HTML attributes in `.templ` files (`href`, `action`, `formaction`, `hx-{get,post,put,patch,delete}`, `hx-{push,replace}-url`) whose values are hard-coded internal paths, string concats, or `fmt.Sprint*` calls. Allows `https://`, `mailto:`, `#`, and protocol-relative `//…` externals. | | `route-literal` | `.go` string literals whose value exactly equals a mounted route — resolve by page type via `URLFor` instead. Narrow: exact concrete-route match only (param/`{$}` routes, trailing-slash/query variants, and bare `/` never match); literals in `==`/`switch` comparisons and `Ref(...)` args are skipped; `_test.go` and generated files are skipped. | Suppression syntax (place above the call/element, or on the same line): From f2520224199cb231f4d8995b554a035a3054a467 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:01:17 +0100 Subject: [PATCH 02/10] docs(skill): position structpages-lint as the primary URL guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the Validating URLs section to two sentences: lint catches the static cases in CI; the boot-time validator and integration test in examples.md §14 are for what static analysis can't see. --- skills/structpages/SKILL.md | 7 +------ skills/structpages/examples.md | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 367b49f..75af1a6 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -262,12 +262,7 @@ url, err := structpages.URLFor(ctx, #### Validating URLs (no dangling URLs in production) -Page names, route strings, and Ref strings stay stringly typed even in the chain form. Strings are fine — they just need to be validated. Two complementary guards (full listings in examples.md §14): - -1. **Init-time validator** — a `validateURLs(sp)` inventory of `sp.URLFor` calls run at boot, so a renamed field, moved route, or broken Ref kills the startup with the list of what's dangling, instead of failing on first request. -2. **Typed URL helpers + an integration test** — one helper per URL family (the only strings live there), plus a test that mounts the tree, renders real pages, and asserts the expected `href`s appear in the body. - -Between them this catches renamed fields (chain step errors), renamed routes/pages (`Ref` resolution errors), new strict-mode ambiguity, and call sites bypassing the helpers. See `examples/url-validation/` in the repo for the runnable version. +Page names, route strings, and Ref strings stay stringly typed even in the chain form. `structpages-lint` (below) is the primary guard — it validates them statically in CI. For URLs the linter can't see (built from runtime data, or behind dynamic dispatch), examples.md §14 shows a boot-time `validateURLs(sp)` inventory and an integration test that asserts rendered `href`s. #### Lint your templates and URL calls diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index e0cb09a..4c29787 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -961,7 +961,7 @@ Once you've started flushing a stream, returning a non-nil error can no longer p ## 14. Validating URLs (no dangling URLs in production) -Both the chain form (page names, route strings, and Ref strings remain stringly typed) and `Ref` carry strings somewhere. Strings are fine — they just need to be validated. Two complementary guards: +`structpages-lint` is the primary guard — it statically validates `URLFor`/`Ref` calls, params, and hard-coded routes in CI (see SKILL.md §3). The patterns below are for what static analysis can't see: URLs assembled from runtime data, refs behind dynamic dispatch, or a deploy that skipped CI. Two complementary guards: ### Guard 1: Init-time validator — fails the boot, not the first request From 7c881bc27fc9ebd18ef7af4742b48503f8917c85 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:08:34 +0100 Subject: [PATCH 03/10] docs(skill): htmx-aware redirects, prefer constructed components, must placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pattern corrections from real-world usage: - Redirects: never http.Redirect from a handler — an HTMX XHR follows the 3xx and swaps the target page's body into the partial's swap target. Return a Redirect control-flow signal; WithErrorHandler sends HX-Redirect for HTMX, 303 otherwise (full wiring in examples.md §13; middleware does the check inline) - RenderComponent: pass a constructed component result, zero-value receiver for sibling pages (pages are stateless); the reflective method-expression form is reserved for DI-injected params, not 'when you don't have the receiver' - must(): only for plain-string contexts inside templ such as templ.Attributes values — attribute expressions take (string, error) - §4 loop example now shows all three sites in code, including the server-side sel.Is branch --- skills/structpages/SKILL.md | 56 +++++++++++++++++++++++---------- skills/structpages/examples.md | 55 +++++++++++++++++++++++++------- skills/structpages/reference.md | 2 +- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 75af1a6..9ee9c87 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -152,25 +152,32 @@ 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. **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-Redirect`/`HX-Location` for HTMX, 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{"id": id}) + if err != nil { return err } + return Redirect{To: url} } ``` +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: @@ -277,15 +284,19 @@ structpages-lint ./... It catches four classes of bug: dangling `URLFor`/`Ref` calls (`urlfor`, `ref`, `params`), unmounted `ID`/`IDTarget` receivers (`id`, `idtarget`), hard-coded URLs in `.templ` URL-bearing attributes (`url-attr`), and `.go` string literals that equal a mounted route (`route-literal`). See reference.md §Lint Tool for the full category table and the `structpages:lint:ignore` suppression syntax (prefer `//`-style directives in both `.go` and `.templ` — HTML comments render into every response). -When you need a plain string (not in a templ attribute that handles errors), wrap with a small `must` helper: +Templ attribute expressions take `(string, error)` directly — no wrapper needed there. The exception, still inside templ, is a context that needs a plain string, like `templ.Attributes` map values; use a small `must` helper for those (and only those): ```go func must[T any](v T, err error) T { if err != nil { panic(err) } return v } +``` -myURL := must(structpages.URLFor(ctx, MyPage{})) +```templ +@PrimaryButton(templ.Attributes{ + "hx-get": must(structpages.URLFor(ctx, UserNewModal{})), +}) { + New User } ``` ### 4. HTMX Partial Rendering @@ -301,17 +312,27 @@ Because all three derive from the same method reference, renaming the method or `structpages.ID` / `structpages.IDTarget` generate deterministic element IDs from method references. The id is the page's **full field-name path from the root** joined with the method (`ID` returns `"my-page-user-list"` for a top-level page, `"admin-users-user-list"` when nested; `IDTarget` prepends `#`). Including the ancestor path guarantees two different mounts never collide. If the full id exceeds the length budget (default 40 chars, see `WithMaxIDLength`) it degrades to the compact leaf-only form (`"user-list"`) with a stable hash suffix when the leaf name is shared. Components (standalone `templ` blocks) are prefixed by their package name (`ID(ctx, UserWidget)` → `"-user-widget"`). For plain string arguments both functions return the string unchanged — `IDTarget("body")` is `"body"`, not `"#body"`. ```templ -// Set element ID on the component's wrapper +// Site 1 — composition: set the element ID on the component's wrapper
@p.UserList(props.Users)
-// HTMX targeting +// Site 2 — trigger: target that id, hit the page's own route ``` +```go +// Site 3 — server: Props branches on the injected RenderTarget +func (p MyPage) Props(r *http.Request, sel structpages.RenderTarget) (MyPageProps, error) { + if sel.Is(p.UserList) { + return MyPageProps{}, structpages.RenderComponent(p.UserList(loadUsers(r))) + } + return MyPageProps{Users: loadUsers(r) /* … everything for the full page */}, nil +} +``` + **Self-render uses the current mount.** When `ID` / `IDTarget` runs inside a page's own templ, the id derives from *that mount's* field name — so the same struct type mounted under different parents produces different ids per render context: ```go @@ -359,7 +380,7 @@ func (p MyPage) Props(r *http.Request, appCtx *AppContext, sel structpages.Rende } ``` -Why this form: `p.UserList(users)` is a normal Go call, so the compiler checks arg types and counts. The alternative — `RenderComponent(MyPage.UserList, users)` or `RenderComponent(sel, users)` — goes through reflection inside the framework, which defers those checks to runtime. Use the reflective forms only when you genuinely don't have the receiver in scope (see §5b). +Why this form: `p.UserList(users)` is a normal Go call, so the compiler checks arg types and counts. The alternative — `RenderComponent(MyPage.UserList, users)` or `RenderComponent(sel, users)` — goes through reflection inside the framework, which defers those checks to runtime. Use the reflective forms only when the method's params should be DI-injected by the framework (see §5b). Note: only methods named `Props` are auto-invoked. `*Props`-suffixed helpers (e.g. `userListData` above; some codebases call them `UserListProps`) are *just regular methods* the user calls from inside `Props` — there's no priority resolution. @@ -370,19 +391,20 @@ case sel.Is(UserStatsWidget): return MyPageProps{}, structpages.RenderComponent(UserStatsWidget(loadStats())) ``` -### 5b. Cross-page RenderComponent (method expression) +### 5b. RenderComponent by method expression (DI-injected params) -When `ServeHTTP` (or another handler) needs to render a component owned by a *different* page, you don't have that page's receiver. Pass a method expression — the framework finds the page, applies DI, and invokes the method: +Page structs are stateless, so even a *different* page's component is normally constructed directly with a zero-value receiver — `RenderComponent(MyPage{}.ItemList(items))` — and that stays the preferred form. The reflective method-expression form is for components whose parameters the framework should DI-inject rather than you supplying them: ```go +// ItemList takes DI-injectable params (e.g. *http.Request, *AppContext) — +// the framework finds the mounted page, fills them, and invokes the method: func (p MyDelete) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppContext) error { if err := store.Delete(...); err != nil { return err } - items, _ := loadItems(r, appCtx) - return structpages.RenderComponent(MyPage.ItemList, items) + return structpages.RenderComponent(MyPage.ItemList) } ``` -This is the one case where the reflection path earns its keep: you can't construct `MyPage.ItemList(items)` directly because you don't have a `MyPage` instance, and the method may rely on DI-injected dependencies the framework will fill in. +Explicit args are matched into the non-injected parameters (`RenderComponent(MyPage.ItemList, items)`), validated by reflection before the call — readable errors, but at runtime, not compile time. If you're loading the data yourself anyway, construct the component instead. ### 5c. Nested swap levels (Page → Content → Detail) @@ -469,7 +491,7 @@ This is the recommended fix for two patterns that fail under bare-context render 2. **Never hardcode URLs** — always use `structpages.URLFor`. 3. **Partials take ONLY their specific data**, not the full props struct. 4. **`RenderComponent` is returned as an error** — when returned, the Props return values (other than the error) are ignored. -5. **Prefer `RenderComponent(p.X(args))`** — compile-time-checked. Reserve the reflective forms (`RenderComponent(MyPage.X, args)`) for cross-page renders where you don't have the receiver (§5/§5b). +5. **Prefer `RenderComponent(p.X(args))` / `RenderComponent(MyPage{}.X(args))`** — constructed components are compile-time-checked; zero-value receivers make this work cross-page too. Reserve the reflective method-expression form for components whose params the framework should DI-inject (§5b). 6. **Children are registered before parents** on the mux (so nested-route conflicts resolve correctly). 7. **Promoted (embedded) methods are skipped** — only methods defined directly on the struct count. 8. **URL params auto-fill from current request's route only** — sibling routes with different param names do not auto-fill. diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index 4c29787..4ead722 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -342,6 +342,8 @@ templ (p EntityDetailPage) Content(props EntityDetailProps) { ### Delete Handler (no HTML, redirect) +Redirects go through the `Redirect` control-flow signal (see §13), never `http.Redirect` — an HTMX XHR follows a 3xx and swaps the target page's body into the partial's swap target: + ```go func (p EntityDeletePage) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppContext) error { id := r.PathValue("entity_id") @@ -350,8 +352,7 @@ func (p EntityDeletePage) ServeHTTP(w http.ResponseWriter, r *http.Request, appC } listURL, err := structpages.URLFor(r.Context(), EntityListPage{}) if err != nil { return err } - http.Redirect(w, r, listURL, http.StatusSeeOther) - return nil + return Redirect{To: listURL} } ``` @@ -410,7 +411,14 @@ func (RequiresAuth) Middlewares(appCtx *AppContext) []structpages.MiddlewareFunc func(next http.Handler, pn *structpages.PageNode) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !isAuthenticated(r) { - http.Redirect(w, r, "/login", http.StatusSeeOther) + // Middleware is outside the error-return path, so do the + // HTMX check here: a 3xx would be swapped into the partial. + loginURL := must(structpages.URLFor(r.Context(), LoginPage{})) + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", loginURL) // full browser navigation + return + } + http.Redirect(w, r, loginURL, http.StatusSeeOther) return } next.ServeHTTP(w, r) @@ -459,7 +467,7 @@ func (RequiresAuth) Middlewares(appCtx *AppContext) []structpages.MiddlewareFunc ## 9. RenderComponent Variants -`RenderComponent` accepts several shapes. They fall into two groups: **direct construction** (no reflection, compile-time-checked) and **reflective dispatch** (framework looks up the method and applies DI). Prefer direct construction when you have the receiver in scope; reach for reflective dispatch only when you don't. +`RenderComponent` accepts several shapes. They fall into two groups: **direct construction** (no reflection, compile-time-checked) and **reflective dispatch** (framework looks up the method and applies DI). Prefer direct construction — page structs are stateless, so a zero-value receiver constructs another page's component too. Reach for reflective dispatch only when the method's parameters should be DI-injected by the framework. ### Preferred: direct construction @@ -467,6 +475,9 @@ func (RequiresAuth) Middlewares(appCtx *AppContext) []structpages.MiddlewareFunc // Same-page method — receiver is in scope, just call it. return MyPageProps{}, structpages.RenderComponent(p.UserList(users)) +// Another page's method — zero-value receiver works; pages are stateless. +return structpages.RenderComponent(MyPage{}.ItemList(items)) + // Standalone function component — call it directly. return MyPageProps{}, structpages.RenderComponent(UserStatsWidget(stats)) @@ -478,11 +489,12 @@ return nil, structpages.RenderComponent(comp) return structpages.RenderComponent(templ.NopComponent) ``` -### Reflective dispatch (when you don't have the receiver) +### Reflective dispatch (when params need framework DI) ```go -// Cross-page method expression — framework finds the page and applies DI. -// Use this from ServeHTTP handlers that re-render a sibling page's component. +// Method expression — framework finds the mounted page, DI-injects the +// method's params (e.g. *http.Request, *AppContext), and invokes it. +// Explicit args fill the non-injected params, checked at runtime. return structpages.RenderComponent(MyPage.ItemList, items) // Bound method expression — equivalent to the unbound form; useful when the @@ -856,12 +868,22 @@ func (Submit) ServeHTTP(w http.ResponseWriter, r *http.Request, svc *Service) er case err != nil: return fmt.Errorf("scheduling.book: GetPatientByMRN: %w", err) // plain error -> 500 } - // ... success path writes normally; the buffer flushes when we return nil - http.Redirect(w, r, detailURL, http.StatusSeeOther) - return nil + // ... success: redirect to the detail page via the control-flow signal + return Redirect{To: detailURL} } ``` +Redirects ride the same error-return path as a control-flow signal — **never call `http.Redirect` from a handler**: during an HTMX request the XHR follows the 3xx and swaps the redirect target's body into the partial's swap target. The signal type: + +```go +// Redirect is control flow, not a real error — it implements error only to +// ride the error-return path, which is what unwinds the render flow without +// writing the ResponseWriter directly. +type Redirect struct{ To string } + +func (Redirect) Error() string { return "redirect" } +``` + The matching global handler, wired once at `Mount`: ```go @@ -870,6 +892,15 @@ structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err er 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" { + w.Header().Set("HX-Redirect", redir.To) // full browser navigation + 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) { @@ -882,6 +913,8 @@ structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err er }) ``` +(Use `HX-Location` instead of `HX-Redirect` if you want an ajax-style navigation that swaps without a full page load.) + `errors.As` unwraps, so `fmt.Errorf("...: %w", ErrorWithStatus{...})` still resolves to its status. A plain `error` (a wrapped DB failure, say) falls through to a logged 500 — exactly what you want for an unexpected fault. ### Rule 3 — API endpoints use the *no-error* `ServeHTTP` form @@ -953,7 +986,7 @@ Once you've started flushing a stream, returning a non-nil error can no longer p | Handler does… | `ServeHTTP` signature | Errors via | |-------------------------------------------------|------------------------------------|-------------------------------------| -| Renders HTML / HTMX partial, may redirect | `(w, r, deps...) error` | `return ErrorWithStatus{…}` / `return err` | +| Renders HTML / HTMX partial, may redirect | `(w, r, deps...) error` | `return ErrorWithStatus{…}` / `return err`; redirects via `return Redirect{To: …}` | | Serves JSON / API (one-shot response) | `(w, r, deps...)` *(no return)* | `http.Error` / JSON body, write `w` directly | | Streams (SSE, progress) | either form, flush via `http.NewResponseController(w)` | SSE `event: error` frame, then `return nil` | diff --git a/skills/structpages/reference.md b/skills/structpages/reference.md index d1338b8..99939f3 100644 --- a/skills/structpages/reference.md +++ b/skills/structpages/reference.md @@ -274,7 +274,7 @@ Patterns, split by whether they go through reflection: **Reflective dispatch (framework looks up the method and applies DI)** -3. **Method expression** (cross-page or same-page): `RenderComponent(MyPage.ItemList, items)` — framework finds the page that owns the method, looks up the component, calls it with `items`, filling any DI-injected parameters. Necessary when the caller doesn't have the target page's receiver in scope (typical `ServeHTTP` handlers re-rendering a sibling page's partial). +3. **Method expression** (cross-page or same-page): `RenderComponent(MyPage.ItemList, items)` — framework finds the page that owns the method, looks up the component, calls it with `items`, filling any DI-injected parameters. Use when the method's params should be framework-injected; for plain data params, prefer direct construction with a zero-value receiver — `RenderComponent(MyPage{}.ItemList(items))` — since pages are stateless. 4. **Bound method value**: `RenderComponent(p.EditSection, props)` — same as #3 with the receiver already bound. Equivalent to direct form #1 (`RenderComponent(p.EditSection(props))`), but goes through reflection; prefer the direct form when `p` is in scope. 5. **Via target**: `RenderComponent(target, args...)` after `target.Is()` matched (required for function targets — `Is()` stores the function pointer). Works for method targets too, but if the receiver is in scope, `RenderComponent(p.X(args))` is clearer and faster. From 3cbb692ee8c6ff2e4b172cf4dd64c86bb41dd79a Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:09:59 +0100 Subject: [PATCH 04/10] =?UTF-8?q?docs(skill):=20handle=20every=20error=20i?= =?UTF-8?q?n=20examples=20=E2=80=94=20no=20must()=20in=20Go=20code,=20no?= =?UTF-8?q?=20=5F=20discards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit must() stays only in its sanctioned spot: templ.Attributes values inside templ files. The middleware example handles the URLFor error properly, and the Props/template.Clone examples propagate errors instead of discarding them. --- skills/structpages/examples.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index 4ead722..3a9b4cf 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -186,8 +186,10 @@ func (p TeamManagementView) Props(r *http.Request, appCtx *AppContext, sel struc return TeamManagementProps{}, structpages.RenderComponent(p.UserList(users)) case sel.Is(p.Page), sel.Is(p.Content): - users, _ := p.userListData(r, appCtx) - groups, _ := p.groupListData(r, appCtx) + users, err := p.userListData(r, appCtx) + if err != nil { return TeamManagementProps{}, err } + groups, err := p.groupListData(r, appCtx) + if err != nil { return TeamManagementProps{}, err } return TeamManagementProps{ UserPaneProps: UserPaneProps{Users: users}, GroupPaneProps: GroupPaneProps{Groups: groups}, @@ -413,7 +415,11 @@ func (RequiresAuth) Middlewares(appCtx *AppContext) []structpages.MiddlewareFunc if !isAuthenticated(r) { // Middleware is outside the error-return path, so do the // HTMX check here: a 3xx would be swapped into the partial. - loginURL := must(structpages.URLFor(r.Context(), LoginPage{})) + loginURL, err := structpages.URLFor(r.Context(), LoginPage{}) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", loginURL) // full browser navigation return @@ -618,7 +624,8 @@ Trade-off: `sp.URLFor` doesn't have access to per-request URL params extracted b ```go func (p tpl) Render(ctx context.Context, w io.Writer) error { base := pageTmpls[p.page] - t, _ := base.Clone() + t, err := base.Clone() + if err != nil { return err } t.Funcs(template.FuncMap{ "urlFor": func(name string, a ...any) (string, error) { return structpages.URLFor(ctx, structpages.Ref(name), a...) From d7ffbde32735be06792d9af35a7e6fa84da0254d Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:12:48 +0100 Subject: [PATCH 05/10] . --- skills/structpages/examples.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index 3a9b4cf..7945aab 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -417,6 +417,8 @@ func (RequiresAuth) Middlewares(appCtx *AppContext) []structpages.MiddlewareFunc // HTMX check here: a 3xx would be swapped into the partial. loginURL, err := structpages.URLFor(r.Context(), LoginPage{}) if err != nil { + // http.Error is ok here because it's outside of structpages' error handling. + // This is a fallback for framework-level errors. http.Error(w, "internal error", http.StatusInternalServerError) return } From 151f4756885da2fc262733215dfbe9164bbea21a Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:14:13 +0100 Subject: [PATCH 06/10] =?UTF-8?q?docs(skill):=20stop=20endorsing=20http.Er?= =?UTF-8?q?ror=20=E2=80=94=20JSON=20APIs=20write=20JSON=20error=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.Error now appears only as the documented anti-pattern (Rule 1) and one annotated middleware escape hatch outside structpages' error handling. The no-return API form shows a writeJSONError helper instead: text/plain fits neither an API client nor an HTMX swap. --- skills/structpages/SKILL.md | 17 ++++++++++++----- skills/structpages/examples.md | 17 +++++++++++------ skills/structpages/reference.md | 2 +- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 9ee9c87..3e2a617 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -180,7 +180,7 @@ The `WithErrorHandler` branch that turns `Redirect` into the response is in exam **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{} @@ -188,15 +188,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. @@ -207,7 +214,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 the no-error handler-method form** — 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. @@ -499,5 +506,5 @@ This is the recommended fix for two patterns that fail under bare-context render 10. **Disambiguation primitives:** type mounted under multiple parents → the `[]any{ParentPage{}, LeafPage{}}` chain form (strict `URLFor` errors on bare lookups). Can't import the page type (cycle) → string page arg / `Ref("Parent.Field")`; validate Ref strings at boot (§3) and with `structpages-lint`. 11. **Plain strings pass through `ID` and `IDTarget` unchanged** — `IDTarget("body")` is `"body"`, not `"#body"` (asymmetric to `URLFor` on purpose: literal CSS selectors are legitimate, literal URL paths are anti-pattern). For an id independent of mount position, define the slot as a component (standalone function) — `IDTarget(ctx, EntryOverlaySlot)` → `"#-entry-overlay-slot"`, package-prefixed, no mount-path dependence. Preferred shape for cross-package slot targeting (§4). 12. **The `form:` struct tag is not read by the framework** — only `route:` is. Anything else on a route field is ignored. -13. **Never write `w` (e.g. `http.Error`) in `Props` or an error-returning `ServeHTTP`** — they are buffered; return the error instead. Use a typed error like `ErrorWithStatus` for a specific status code. API/JSON endpoints use the no-error `ServeHTTP(w, r, deps...)` form, where direct writes are correct (see examples.md §13). +13. **Never write `w` (e.g. `http.Error`) in `Props` or an error-returning `ServeHTTP`** — they are buffered; return the error instead. Use a typed error like `ErrorWithStatus` for a specific status code. API/JSON endpoints use the no-error `ServeHTTP(w, r, deps...)` form, where direct writes are correct — JSON error bodies there, never `http.Error` (see examples.md §13). 14. **Never hand-write a partial's element id** — derive all three sites (composition `id={ID(…)}`, trigger `hx-target={IDTarget(…)}`, server `sel.Is(…)`) from the same method reference (§4). diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index 7945aab..7cd9ecd 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -933,7 +933,7 @@ For endpoints that serve JSON (or any non-HTML response), do **not** use the err 1. The error-returning form buffers the whole response in memory before anything reaches the client. 2. `WithErrorHandler` renders an **HTML** error page. An API client expects a JSON body or a bare status code, not an AppShell document. -Use signature #3 — `ServeHTTP(w, r, deps...)` with **no return value**. The framework hands it the raw `w` (no structpages buffering wrapper), and because no error flows back, you own status codes yourself. Here `http.Error` *is* the right tool — the Rule 1 prohibition only applies to the buffered, error-returning form. +Use signature #3 — `ServeHTTP(w, r, deps...)` with **no return value**. The framework hands it the raw `w` (no structpages buffering wrapper), and because no error flows back, you own status codes yourself — and the error *bodies*: a JSON API returns JSON errors. Don't reach for `http.Error`; its `text/plain` body is the wrong shape for an API client (the Rule 1 prohibition covers the buffered forms; here it's wrong for content-type reasons instead). ```go type TrackTime struct{} @@ -945,18 +945,23 @@ func (TrackTime) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppCo TimeSpent int32 `json:"time_spent"` } if err := json.UnmarshalRead(r.Body, &body); err != nil { - http.Error(w, "invalid request: "+err.Error(), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request: "+err.Error()) return } if err := appCtx.Store.UpdateTimeSpent(r.Context(), body.ViewID, body.TimeSpent); err != nil { - http.Error(w, "update failed", http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "update failed") return } w.WriteHeader(http.StatusOK) } -``` -For a JSON error body instead of `http.Error`'s plain text, set the header and encode your own error shape — still in the no-return form. +// 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}) +} +``` ### Rule 4 — for streaming (SSE), flush with `http.ResponseController` @@ -996,7 +1001,7 @@ Once you've started flushing a stream, returning a non-nil error can no longer p | Handler does… | `ServeHTTP` signature | Errors via | |-------------------------------------------------|------------------------------------|-------------------------------------| | Renders HTML / HTMX partial, may redirect | `(w, r, deps...) error` | `return ErrorWithStatus{…}` / `return err`; redirects via `return Redirect{To: …}` | -| Serves JSON / API (one-shot response) | `(w, r, deps...)` *(no return)* | `http.Error` / JSON body, write `w` directly | +| Serves JSON / API (one-shot response) | `(w, r, deps...)` *(no return)* | write `w` directly with a JSON error body (`writeJSONError`) | | Streams (SSE, progress) | either form, flush via `http.NewResponseController(w)` | SSE `event: error` frame, then `return nil` | `Props` methods always follow the first row — they are buffered and their error flows to `WithErrorHandler`, so return `ErrorWithStatus{…}` for status-coded failures, never write `w`. diff --git a/skills/structpages/reference.md b/skills/structpages/reference.md index 99939f3..368100b 100644 --- a/skills/structpages/reference.md +++ b/skills/structpages/reference.md @@ -208,7 +208,7 @@ func (p IndexPage) ServeHTTP(w http.ResponseWriter, r *http.Request, target stru **Choosing a form, and the `http.Error` anti-pattern.** The buffered (error-returning) forms exist so that on error the framework can discard a partial response and render through `WithErrorHandler` instead. Therefore: - In signatures 2 and 4 (and in any `Props` method) **never write `w` directly** — no `http.Error`, no `w.WriteHeader`. Writing then `return err` discards the write when the buffer resets; writing then `return nil` bypasses the error handler. Return the error and let `WithErrorHandler` render it. For a specific status code, return a typed error (e.g. `ErrorWithStatus{Status, Title, Message}`) that the handler unwraps via `errors.As`. -- For endpoints that serve JSON / non-HTML / streamed responses, use signature **3** (`ServeHTTP(w, r, deps...)`, no return). It is unbuffered, so writes go straight to the client and the HTML error handler is never invoked. There `http.Error` and direct `w` writes are the correct tools — you own the status code. +- For endpoints that serve JSON / non-HTML / streamed responses, use signature **3** (`ServeHTTP(w, r, deps...)`, no return). It is unbuffered, so writes go straight to the client and the HTML error handler is never invoked. Direct `w` writes are the correct tool there — you own the status code. Match the error body to the content type (JSON errors for a JSON API); avoid `http.Error`, whose `text/plain` body fits neither an API client nor an HTMX swap. See examples.md §13 for the full worked pattern. From a2b053f62276217d4b6fe35a195ac8ea363d5ea2 Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:16:00 +0100 Subject: [PATCH 07/10] docs(skill): redirect via HX-Location, not HX-Redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the htmx 4 reference: HX-Location is ajax navigation (like a boosted link) and the right default within an htmx app; HX-Redirect forces a full browser load and is only for non-htmx endpoints or pages with different content. Also document the 2xx requirement — htmx ignores response headers on 3xx. --- skills/structpages/SKILL.md | 2 +- skills/structpages/examples.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 3e2a617..76d68b7 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -161,7 +161,7 @@ This is the canonical pattern for POST/DELETE handlers that update state and ret **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-Redirect`/`HX-Location` for HTMX, 303 otherwise): +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; 303 otherwise): ```go // Control-flow signal, not a real error — rides the error-return path. diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index 7cd9ecd..fa910db 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -423,7 +423,7 @@ func (RequiresAuth) Middlewares(appCtx *AppContext) []structpages.MiddlewareFunc return } if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", loginURL) // full browser navigation + w.Header().Set("HX-Location", loginURL) // ajax navigation; status must stay 2xx return } http.Redirect(w, r, loginURL, http.StatusSeeOther) @@ -904,7 +904,9 @@ structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err er var redir Redirect if errors.As(err, &redir) { if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", redir.To) // full browser navigation + // 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) @@ -922,7 +924,7 @@ structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err er }) ``` -(Use `HX-Location` instead of `HX-Redirect` if you want an ajax-style navigation that swaps without a full page load.) +(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 `` content/scripts. `HX-Location` also accepts a JSON object — `{"path": "...", "target": "..."}` — for finer swap control.) `errors.As` unwraps, so `fmt.Errorf("...: %w", ErrorWithStatus{...})` still resolves to its status. A plain `error` (a wrapped DB failure, say) falls through to a logged 500 — exactly what you want for an unexpected fault. From dae136ec9f22174414b82875a5a01b54d5940f6a Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:16:31 +0100 Subject: [PATCH 08/10] docs(skill): keep a brief HX-Redirect mention in SKILL.md redirect shape --- skills/structpages/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 76d68b7..1d4c9aa 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -161,7 +161,7 @@ This is the canonical pattern for POST/DELETE handlers that update state and ret **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; 303 otherwise): +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 // Control-flow signal, not a real error — rides the error-return path. From b2deb6a3e54ba0447187fa2a77811166445a6abb Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:19:03 +0100 Subject: [PATCH 09/10] =?UTF-8?q?docs(skill):=20trim=20examples.md=20?= =?UTF-8?q?=C2=A714=20=E2=80=94=20lint=20is=20primary,=20one=20compact=20r?= =?UTF-8?q?untime=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/structpages/examples.md | 63 ++-------------------------------- 1 file changed, 3 insertions(+), 60 deletions(-) diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index fa910db..b0fd66c 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -1010,12 +1010,9 @@ Once you've started flushing a stream, returning a non-nil error can no longer p ## 14. Validating URLs (no dangling URLs in production) -`structpages-lint` is the primary guard — it statically validates `URLFor`/`Ref` calls, params, and hard-coded routes in CI (see SKILL.md §3). The patterns below are for what static analysis can't see: URLs assembled from runtime data, refs behind dynamic dispatch, or a deploy that skipped CI. Two complementary guards: - -### Guard 1: Init-time validator — fails the boot, not the first request +`structpages-lint` is the primary guard — it statically validates `URLFor`/`Ref` calls, params, and hard-coded routes in CI (see SKILL.md §3). For what static analysis can't see (URLs assembled from runtime data, refs behind dynamic dispatch), a boot-time inventory of `URLFor` calls kills the startup with the list of what's dangling — same dynamic as a database migration check: ```go -// validate.go func validateURLs(sp *structpages.StructPages) error { var errs []error check := func(label string, gen func() (string, error)) { @@ -1023,68 +1020,14 @@ func validateURLs(sp *structpages.StructPages) error { errs = append(errs, fmt.Errorf("%s: %w", label, err)) } } - check("home", func() (string, error) { return sp.URLFor(homePage{}) }) check("components detail", func() (string, error) { - return sp.URLFor([]any{componentsRoot{}, entryPage{}}, - map[string]any{"slug": "sample"}) + return sp.URLFor([]any{componentsRoot{}, entryPage{}}, map[string]any{"slug": "sample"}) }) - // For Refs (cross-package, where importing would cycle): check("admin settings", func() (string, error) { return sp.URLFor(structpages.Ref("Admin.Settings")) }) return errors.Join(errs...) } - -// main.go -sp, err := structpages.Mount(mux, &root{}, "/", "App") -if err != nil { log.Fatal(err) } -if err := validateURLs(sp); err != nil { - log.Fatalf("URL validation failed:\n%v", err) -} -``` - -A renamed field, moved route, or broken Ref now kills the boot with the inventory of what's dangling. Same dynamic as a database migration check: refuse to start serving if the world doesn't look right. - -### Guard 2: Typed URL helpers + an integration test - -```go -// urls.go — one helper per URL family. The only strings live here. -func urlForGroupIndex(ctx context.Context, group string) (string, error) { - parent, ok := groupParent(group) - if !ok { return "", fmt.Errorf("unknown group %q", group) } - return structpages.URLFor(ctx, []any{parent, groupIndex{}}) -} - -// integration_test.go — mount, render, assert URLs in the body. -func TestRenderedURLsResolve(t *testing.T) { - mux := http.NewServeMux() - structpages.Mount(mux, &root{}, "/", "App") - cases := []struct{ path string; wantContains []string }{ - {"/", []string{`href="/foundations/"`, `href="/components/"`}}, - {"/components/", []string{`href="/components/button"`}}, - } - for _, tc := range cases { - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil)) - for _, want := range tc.wantContains { - if !strings.Contains(rec.Body.String(), want) { - t.Errorf("%s body missing %q", tc.path, want) - } - } - } -} ``` -The helper layer narrows the surface of refactorable strings; the integration test catches drift in helpers, parents, fields, routes, or accidental ambiguity, exercised end-to-end through the real renderer. - -### Why both? - -The validator runs at boot — safety net for production deploys, even if CI was skipped. The integration test runs in CI — fast feedback during development, with a clearer diff when something breaks. A `TestValidateURLs` in your test file that just calls the validator gives you the validator's coverage in CI too, for one extra line. - -What this catches: -- **Renamed field** in a parent page → chain step errors with the parent's available children listed. -- **Renamed route or page** referenced by `Ref` → `no page found with route/name "..."`. -- **New page type introducing strict-mode ambiguity** → URLFor errors at the bare lookup. -- **Call site bypassing helpers** → the rendered body lacks the URL the test asserts. - -See `examples/url-validation/` in the repo for the full runnable pattern: `urls.go` (helpers), `validate.go` (init-time inventory), `integration_test.go` (end-to-end). The library's `chain_test.go` covers the URL-shape mechanics at the unit level. +Call it from `main` after `Mount` (fail the boot) and from a one-line test (coverage in CI). For end-to-end assurance, an integration test that mounts the tree, renders real pages, and asserts expected `href`s in the body also catches call sites that bypass your helpers. Full runnable pattern: `examples/url-validation/` in the repo. From 551bea1f281e080e4cadf6015b42b95175c78a5a Mon Sep 17 00:00:00 2001 From: Jackie Li Date: Wed, 10 Jun 2026 21:22:35 +0100 Subject: [PATCH 10/10] =?UTF-8?q?docs(skill):=20prefer=20specific=20path?= =?UTF-8?q?=20param=20names=20=E2=80=94=20{itemId},=20not=20{id}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested routes compose into one ServeMux pattern: duplicate wildcard names panic at mount, and URLFor's map params couldn't distinguish two {id}s. Examples across all three files now model specific names. --- skills/structpages/SKILL.md | 10 ++++++---- skills/structpages/examples.md | 4 ++-- skills/structpages/reference.md | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/skills/structpages/SKILL.md b/skills/structpages/SKILL.md index 1d4c9aa..d5ca405 100644 --- a/skills/structpages/SKILL.md +++ b/skills/structpages/SKILL.md @@ -84,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 @@ -170,7 +172,7 @@ func (Redirect) Error() string { return "redirect" } func (p SubmitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, appCtx *AppContext) error { // perform action... - url, err := structpages.URLFor(r.Context(), DetailPage{}, map[string]any{"id": id}) + url, err := structpages.URLFor(r.Context(), DetailPage{}, map[string]any{"itemId": id}) if err != nil { return err } return Redirect{To: url} } @@ -225,8 +227,8 @@ See examples.md §13 for the full pattern, including the `WithErrorHandler` wiri ```templ Link -Detail -
+Detail + ``` **Prefer `map[string]any` for path parameters.** It's explicit at the call site, survives route changes, and reads as a single value rather than a sequence of positional or alternating args. Positional and key/value-pair forms also work (see reference.md §URLFor Args Formats) but are easier to misalign during refactors. diff --git a/skills/structpages/examples.md b/skills/structpages/examples.md index b0fd66c..86f9faf 100644 --- a/skills/structpages/examples.md +++ b/skills/structpages/examples.md @@ -621,7 +621,7 @@ func main() { } ``` -Trade-off: `sp.URLFor` doesn't have access to per-request URL params extracted by structpages middleware, so this pattern works for routes whose URLs don't need request-bound params (top-level nav). For `/users/{id}`-style routes that need to generate URLs from the *current* request's path params, switch to ctx-bound funcs by Cloning inside `Render`: +Trade-off: `sp.URLFor` doesn't have access to per-request URL params extracted by structpages middleware, so this pattern works for routes whose URLs don't need request-bound params (top-level nav). For `/users/{userId}`-style routes that need to generate URLs from the *current* request's path params, switch to ctx-bound funcs by Cloning inside `Render`: ```go func (p tpl) Render(ctx context.Context, w io.Writer) error { @@ -715,7 +715,7 @@ The `URLFor` argument forms (in order of detection): - **Map** (recommended): a single `map[string]any` first arg. Refactor-safe and self-documenting. - **Positional**: arg count exactly matches placeholder count. Brittle if placeholders are added or reordered. -- **Key-value pairs**: even arg count, all even-indexed args are strings, AND at least one matches a placeholder name. (E.g. `"id", 123, "slug", "x"`.) Equivalent to the map form but spread across positional args. +- **Key-value pairs**: even arg count, all even-indexed args are strings, AND at least one matches a placeholder name. (E.g. `"userId", 123, "slug", "x"`.) Equivalent to the map form but spread across positional args. - **Auto-fill from request**: any unfilled placeholders that match the *current request's* path params get filled automatically. --- diff --git a/skills/structpages/reference.md b/skills/structpages/reference.md index 368100b..f8d6dea 100644 --- a/skills/structpages/reference.md +++ b/skills/structpages/reference.md @@ -59,14 +59,14 @@ Recommended call shape: `URLFor(ctx, page, params)` where `params` is a `map[str **Recommended: `map[string]any`** — explicit, position-independent, refactor-safe. ```go -URLFor(ctx, page{}, map[string]any{"id": 123, "slug": "hello"}) +URLFor(ctx, page{}, map[string]any{"userId": 123, "slug": "hello"}) ``` The other forms are also supported. Order of detection inside `formatPathSegments`: - **Map**: a single `map[string]any` first arg. Recommended; values are looked up by placeholder name. - **Positional**: arg count exactly matches placeholder count → `URLFor(ctx, page{}, "val1", "val2")` fills left to right. Brittle if placeholders are reordered. -- **Key-value pairs**: even arg count, every even-indexed arg is a string, AND at least one of those strings matches a placeholder name → `URLFor(ctx, page{}, "id", 123, "slug", "hello")`. Equivalent to map form but spread across positional args; harder to scan. +- **Key-value pairs**: even arg count, every even-indexed arg is a string, AND at least one of those strings matches a placeholder name → `URLFor(ctx, page{}, "userId", 123, "slug", "hello")`. Equivalent to map form but spread across positional args; harder to scan. - **Auto-fill from request**: unfilled placeholders that match path params from the *current request's route* are filled automatically. Other routes' params do not auto-fill. ### ID / IDTarget Input Types