diff --git a/docs/advanced.md b/docs/advanced.md
index f13c44f..76d9bc8 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -1,55 +1,14 @@
-# Advanced Features
-
-### Custom Handlers
-
-Structpages supports two types of custom handlers:
-
-#### ServeHTTP with Error Return (Buffered)
-
-When `ServeHTTP` returns an error, structpages uses a buffered writer to capture the response. This allows proper error page rendering if an error occurs:
-
-```go
-type formPage struct{}
-
-// This handler uses a buffered writer
-func (f formPage) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
- if r.Method == "POST" {
- // Process form
- if err := processForm(r); err != nil {
- // Response is buffered, so the error handler can render
- // an error page even though we already wrote partial output
- return err
- }
- http.Redirect(w, r, "/success", http.StatusSeeOther)
- return nil
- }
-
- // Any non-nil return goes to the configured error handler
- // (see WithErrorHandler). The framework does not auto-map error
- // types to status codes — your error handler decides.
- return fmt.Errorf("method not allowed: %s", r.Method)
-}
-```
+---
+title: Advanced Features
+slug: /advanced
+sidebar_position: 11
+---
-#### Standard http.Handler (Direct Write)
-
-Implementing the standard `http.Handler` interface writes directly to the response without buffering:
-
-```go
-type apiEndpoint struct{}
-
-// This handler writes directly to the response
-func (a apiEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{
- "status": "ok",
- })
-}
-```
+# Advanced Features
-### Initialization
+## Initialization
-Use the `Init` method for one-time setup at `Mount` time. `Init` may take an `error` return and may receive injected dependencies (matched by type from `WithArgs`):
+Use the `Init` method for one-time setup at `Mount` time. `Init` may return an `error` and may receive injected dependencies (matched by type from `WithArgs`):
```go
type databasePage struct {
@@ -65,28 +24,14 @@ func (d *databasePage) Init(store *Store) error {
Either value or pointer receiver works; use pointer if `Init` mutates the page (the typical case). Prefer `WithArgs` for runtime dependencies — `Init` is for setup that has to happen exactly once and isn't naturally a method parameter.
-### Dependency Injection
+## Dependency injection
-Structpages supports dependency injection by passing services when mounting pages. These services are then available in page methods:
+Register services once at `Mount`; they're matched by type into method parameters:
```go
-// Define your services
-type Store struct {
- db *sql.DB
-}
-
-type SessionManager struct {
- // session configuration
-}
-
-// Pass services when mounting pages — wrap them in WithArgs(...)
-mux := http.NewServeMux()
-
store := &Store{db: db}
sessionManager := NewSessionManager()
-// Services are registered via the WithArgs option (Mount's variadic
-// param is options ...Option, not raw values)
sp, err := structpages.Mount(mux, pages{}, "/", "My App",
structpages.WithArgs(store, sessionManager, logger),
)
@@ -95,302 +40,136 @@ if err != nil {
}
```
-**Important:** Dependency injection is type-based. Each type can only be registered once. Attempting to register duplicate types will result in an error. If you need to inject multiple values of the same underlying type (e.g., multiple strings), create distinct types:
+**Each type can only be registered once.** To inject multiple values of the same underlying type, create distinct named types:
```go
-// DON'T do this - will return an error for duplicate type
-mux := http.NewServeMux()
+// DON'T — duplicate type string errors at Mount
_, err := structpages.Mount(mux, pages{}, "/", "My App",
- structpages.WithArgs(
- "api-key", // First string
- "db-name", // Second string - will cause error
- ),
+ structpages.WithArgs("api-key", "db-name"),
)
-if err != nil {
- // Error: duplicate type string in args registry
-}
-// DO this instead - create distinct types
+// DO — distinct types
type APIKey string
type DatabaseName string
sp, err := structpages.Mount(mux, pages{}, "/", "My App",
- structpages.WithArgs(
- APIKey("your-api-key"),
- DatabaseName("mydb"),
- ),
+ structpages.WithArgs(APIKey("your-api-key"), DatabaseName("mydb")),
)
-if err != nil {
- log.Fatal(err)
-}
-// Use in your methods
func (p userPage) Props(r *http.Request, apiKey APIKey, dbName DatabaseName) (UserProps, error) {
- // Both values are available with type safety
- client := NewAPIClient(string(apiKey))
- conn := OpenDB(string(dbName))
- // ...
+ // Both available with type safety
}
```
-**Type matching with coercion.** The argument registry coerces between pointer and value forms and falls back to assignability (`args.go`). One concrete consequence: a single `*AppContext` registration can fill a parameter typed as any interface that `*AppContext` implements. So you can register concrete types and have handler methods declare interface parameters (good for testability).
+**Type matching with coercion.** The argument registry coerces between pointer and value forms and falls back to assignability. One concrete consequence: a single `*AppContext` registration can fill a parameter typed as any interface that `*AppContext` implements — register concrete types, declare interface parameters where it helps testability.
-**Generic types and interface types in DI.** Both work — `generics_injection_test.go` covers basic injection, type parameters, slices/maps as deps, type aliases, function types, complex constraints, pointer semantics, and interface injection (12 tests). Anywhere these docs say "type", read it as "any reflect-distinguishable type, including generics and interfaces".
+**Generic types and interface types both work** — type parameters, slices/maps as deps, aliases, function types, complex constraints, pointer semantics, and interface injection are all covered by the library's test matrix. Anywhere these docs say "type", read it as "any reflect-distinguishable type".
-#### Using Injected Services
+`*structpages.PageNode` is always available for injection — the framework adds the current node automatically.
-Services are automatically injected into page methods that declare them as parameters:
+Services are injected into any page method that declares them: `Props`, `ServeHTTP`, `Middlewares`, and `Init`.
-```go
-type userListPage struct{}
+## Dynamic references with Ref
-// Props method receives injected Store
-func (p userListPage) Props(r *http.Request, store *Store) (UserListProps, error) {
- users, err := store.GetUsers()
- if err != nil {
- return UserListProps{}, err
- }
- return UserListProps{Users: users}, nil
-}
+`Ref` (a string type) references pages and page components by field name when static types aren't available — configuration-driven menus, generic components, cross-package call sites:
-// ServeHTTP can also receive injected services
-func (p signOutPage) ServeHTTP(w http.ResponseWriter, r *http.Request, sm *SessionManager) error {
- // Clear user session
- sm.Destroy(r.Context())
- http.Redirect(w, r, "/", http.StatusSeeOther)
- return nil
-}
-
-// Middleware methods can receive services too
-func (p protectedPages) Middlewares(sm *SessionManager) []structpages.MiddlewareFunc {
- return []structpages.MiddlewareFunc{
- func(next http.Handler, pn *structpages.PageNode) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !sm.Exists(r.Context(), "user") {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- next.ServeHTTP(w, r)
- })
- },
- }
-}
-```
-
-### Dynamic References with Ref
+```go
+// Reference by page field name
+url, err := structpages.URLFor(ctx, structpages.Ref("homePage"))
-The `Ref` type enables dynamic references to pages and methods when static type references aren't available. This is useful for configuration-driven menus, generic components, and scenarios where page or method names are determined at runtime.
+// Qualified path, with params
+url, err := structpages.URLFor(ctx, structpages.Ref("Admin.ProductPage"),
+ map[string]any{"productId": "123"})
-#### URLFor with Ref
+// Compose with URL fragments
+url, err := structpages.URLFor(ctx, []any{structpages.Ref("userPage"), "?tab=profile"})
+```
-Use `Ref` to reference pages dynamically:
+For page components, `Ref("PageName.MethodName")` works with `ID`/`IDTarget`:
```go
-// Reference by page name (struct field name)
-url, err := URLFor(ctx, Ref("homePage"))
-// → "/"
-
-// Reference by route path (must start with /)
-url, err := URLFor(ctx, Ref("/user/settings"))
-// → "/user/settings"
-
-// With path parameters
-url, err := URLFor(ctx, Ref("productPage"), "123")
-// → "/product/123"
+id, err := structpages.ID(ctx, structpages.Ref("userPage.UserList"))
+// → "user-page-user-list"
-// Compose with literals
-url, err := URLFor(ctx, []any{Ref("userPage"), "?tab=profile"})
-// → "/user?tab=profile"
+target, err := structpages.IDTarget(ctx, structpages.Ref("userPage.UserList"))
+// → "#user-page-user-list"
```
-**Matching rules for URLFor:**
-- If `Ref` starts with `/`, matches by full route
-- Otherwise, matches by page name (the struct field name)
-- Returns error if no match found
+An unqualified method name (`Ref("UserList")`) resolves only if exactly one page has that method; ambiguity errors with the candidates listed.
-**Example: Dynamic Menu**
+### Example: configuration-driven menu
```go
type MenuItem struct {
- PageRef Ref
+ PageRef structpages.Ref
Label string
}
var menu = []MenuItem{
- {Ref("home"), "Home"},
- {Ref("users"), "Users"},
- {Ref("settings"), "Settings"},
+ {structpages.Ref("home"), "Home"},
+ {structpages.Ref("users"), "Users"},
+ {structpages.Ref("settings"), "Settings"},
}
+```
-templ Navigation(ctx context.Context) {
+```templ
+templ Navigation() {
}
```
-#### ID/IDTarget with Ref
-
-Use `Ref` to reference component methods dynamically:
-
-```go
-// Qualified reference (PageName.MethodName)
-id, err := ID(ctx, Ref("userPage.UserList"))
-// → "user-page-user-list"
-
-idTarget, err := IDTarget(ctx, Ref("userPage.UserList"))
-// → "#user-page-user-list"
-
-// Simple method name (must be unambiguous)
-id, err := ID(ctx, Ref("UserList"))
-// → "user-page-user-list" (if only one page has UserList)
-```
-
-**Matching rules for ID/IDTarget:**
-- If `Ref` contains `.`, splits into `PageName.MethodName` (qualified)
-- Otherwise, searches all pages for the method name
- - If found on one page, returns that page's ID
- - If found on multiple pages, returns error with helpful message
-- Verifies method exists on resolved page
-- Returns error if not found
-
-**Example: Configuration-Driven Form**
-
-```go
-type FormConfig struct {
- ActionPage string // From config file
- TargetComponent string // From config file
-}
-
-templ DynamicForm(ctx context.Context, config FormConfig) {
- @{
- actionURL, _ := URLFor(ctx, Ref(config.ActionPage))
- targetID, _ := IDTarget(ctx, Ref(config.TargetComponent))
- }
-
-}
-```
-
-#### Error Handling
-
-Both URLFor and ID/IDTarget with `Ref` return descriptive errors for runtime safety:
-
-```go
-// Page not found by name
-url, err := URLFor(ctx, Ref("NonExistentPage"))
-// Error: "no page found with name \"NonExistentPage\""
-
-// Page not found by route
-url, err := URLFor(ctx, Ref("/bad/route"))
-// Error: "no page found with route \"/bad/route\""
-
-// Method not found
-id, err := ID(ctx, Ref("NonExistentMethod"))
-// Error: "method \"NonExistentMethod\" not found on any page"
-
-// Ambiguous method (exists on multiple pages)
-id, err := ID(ctx, Ref("UserList"))
-// Error: "method \"UserList\" found on multiple pages: userPage, adminPage.
-// Use qualified name like \"userPage.UserList\""
-
-// Method not on specified page
-id, err := ID(ctx, Ref("userPage.AdminSettings"))
-// Error: "method \"AdminSettings\" not found on page \"userPage\""
-```
-
-**Testing Dynamic References**
-
-Outside a request context, use the methods on the returned `*StructPages` value (which has its own access to the parse context):
+Templ attributes accept `(string, error)`, so no error juggling is needed at the call site. `structpages-lint` validates Ref strings — including ones stored in struct fields like this menu — so a renamed page fails CI, and a boot-time validator catches it in production deploys:
```go
func TestMenuReferences(t *testing.T) {
mux := http.NewServeMux()
- sp, _ := structpages.Mount(mux, &pages{}, "/", "App")
-
- // Verify all menu items reference valid pages
+ sp, err := structpages.Mount(mux, &pages{}, "/", "App")
+ if err != nil {
+ t.Fatal(err)
+ }
for _, item := range menu {
- _, err := sp.URLFor(item.PageRef)
- if err != nil {
- t.Errorf("Invalid menu item %s: %v", item.Label, err)
+ if _, err := sp.URLFor(item.PageRef); err != nil {
+ t.Errorf("invalid menu item %s: %v", item.Label, err)
}
}
}
```
-Within a request handler, use the context-based functions: `structpages.URLFor(r.Context(), ...)`. The framework auto-injects the parse context into the request context via internal middleware.
-
-### Type Aliases and URLFor/IDFor
+Outside a request context (tests, init), use the methods on `*StructPages`; within handlers, use the context-based functions — the framework auto-injects the parse context via internal middleware.
-Go type aliases (`type X = Y`) are identical at runtime. This has implications when the same type (or its alias) is used for multiple routes.
+## Type aliases and URLFor
-#### The Limitation
+Go type aliases (`type X = Y`) are identical at runtime — `reflect.TypeOf` cannot distinguish them. When the same type (or its alias) is mounted on multiple routes, a bare type lookup is ambiguous:
```go
type productPage struct{}
-
-func (productPage) Page() templ.Component { return productTemplate() }
-
-// Type alias - identical to productPage at runtime
-type featuredProduct = productPage
+type featuredProduct = productPage // alias — same reflect.Type
type pages struct {
products productPage `route:"/products Products"`
featured featuredProduct `route:"/featured Featured"`
}
-```
-When using `URLFor` with type references:
-
-```go
-// Both return "/products" - the first matching route
-url1, _ := URLFor(ctx, productPage{}) // → "/products"
-url2, _ := URLFor(ctx, featuredProduct{}) // → "/products" (not "/featured"!)
+// Errors: productPage matches two mounted nodes. Strict URLFor never
+// silently picks one — the error lists both and the disambiguation forms.
+url, err := structpages.URLFor(ctx, featuredProduct{})
```
-This happens because `reflect.TypeOf(featuredProduct{})` returns the same type as `reflect.TypeOf(productPage{})`. Go's reflection cannot distinguish between a type and its alias.
-
-#### The Workaround: Use Ref
-
-Use `Ref("fieldName")` to target routes by their struct field name instead of type:
+Disambiguate with `Ref` by field name:
```go
-// Target specific routes using field names
-url1, _ := URLFor(ctx, Ref("products")) // → "/products"
-url2, _ := URLFor(ctx, Ref("featured")) // → "/featured" ✓
+url, err := structpages.URLFor(ctx, structpages.Ref("products")) // → "/products"
+url, err = structpages.URLFor(ctx, structpages.Ref("featured")) // → "/featured"
-// Same for IDFor/IDTarget
-id1, _ := IDTarget(ctx, Ref("products.Page")) // → "#products-page"
-id2, _ := IDTarget(ctx, Ref("featured.Page")) // → "#featured-page" ✓
+id, err := structpages.IDTarget(ctx, structpages.Ref("featured.Page")) // → "#featured-page"
```
-#### When This Matters
-
-This limitation only affects scenarios where:
-1. The same type (or its alias) is used for multiple routes
-2. You need to reference a specific route using `URLFor` or `IDFor`
-
-If each route uses a unique type, type-based matching works perfectly:
-
-```go
-type productsPage struct{}
-type featuredPage struct{} // Different type, not an alias
-
-type pages struct {
- products productsPage `route:"/products Products"`
- featured featuredPage `route:"/featured Featured"`
-}
-
-// Works correctly - different types
-url1, _ := URLFor(ctx, productsPage{}) // → "/products"
-url2, _ := URLFor(ctx, featuredPage{}) // → "/featured" ✓
-```
+(The `[]any{Parent{}, Leaf{}}` chain form doesn't help here — both mounts share one type — so `Ref` is the tool.) If each route uses a unique type, type-based matching needs no disambiguation at all. Type aliases are fine for routing and rendering; only type-based lookup is affected.
-#### Best Practices
+## Custom target selectors
-1. **Use distinct types** for routes that need individual `URLFor`/`IDFor` references
-2. **Use `Ref("fieldName")`** when you must use the same type for multiple routes
-3. **Type aliases are fine** for routing and rendering - only `URLFor`/`IDFor` type matching is affected
+See [HTMX Integration](./htmx.md#custom-target-selectors) for `WithTargetSelector`, the htmx 4 selector, and content-negotiation patterns.
diff --git a/docs/api.md b/docs/api.md
index 47a67b6..ccd5858 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1,445 +1,238 @@
-# API Reference
+---
+title: API Guide
+slug: /api
+sidebar_position: 12
+---
-## Core Functions
+# API Guide
-### Mount
+Hand-written guide to the public API. For generated godoc, see the [package reference](./reference/package.md).
+
+## Mount
```go
func Mount(mux Mux, page any, route, title string, options ...Option) (*StructPages, error)
```
-Mount is the main entry point for setting up routes. It parses the page structure, registers routes on the provided mux, and returns a `StructPages` instance for URL generation.
-
-**Parameters:**
-- `mux`: HTTP router implementing the `Mux` interface (e.g., `*http.ServeMux`). Pass `nil` to use `http.DefaultServeMux`.
-- `page`: Struct containing route definitions using struct tags
-- `route`: Base route path (e.g., `"/"`)
-- `title`: Page title for the root route
-- `options`: Configuration options (WithArgs, WithErrorHandler, WithMiddlewares, etc.)
+Main entry point. Parses the page tree, registers routes on the mux, and returns a `*StructPages` for URL/id generation.
-**Returns:**
-- `*StructPages`: Instance for generating type-safe URLs via `URLFor`, `ID`, and `IDTarget`
-- `error`: Error if mounting fails
+- `mux`: anything implementing `Handle(pattern string, handler http.Handler)` — e.g. `*http.ServeMux`. Pass `nil` for `http.DefaultServeMux`.
+- `page`: root struct with route tags
+- `route`: base path (usually `"/"`)
+- `title`: root page title
+- `options`: see [Options](#options)
-**Example:**
```go
mux := http.NewServeMux()
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home")
+sp, err := structpages.Mount(mux, &pages{}, "/", "App")
if err != nil {
log.Fatal(err)
}
-
-// Use mux for serving HTTP
-http.ListenAndServe(":8080", mux)
-
-// Use sp for generating URLs
-url, _ := sp.URLFor(productPage{}, "123")
-```
-
-## Mux Interface
-
-```go
-type Mux interface {
- Handle(pattern string, handler http.Handler)
+if err := http.ListenAndServe(":8080", mux); err != nil {
+ log.Fatal(err)
}
```
-The `Mux` interface allows StructPages to work with any HTTP router that follows Go's standard routing pattern. `*http.ServeMux` implements this interface.
-
-## StructPages
+## Parse (no-mux variant)
```go
-type StructPages struct {
- // Internal fields
-}
+func Parse(page any, route, title string, options ...Option) (*StructPages, error)
```
-Returned by `Mount()`, StructPages provides methods for type-safe URL generation.
-
-### Methods
+Builds the page tree without registering routes. Use in tests and tooling that need `URLFor`/`ID`/`IDTarget` against the real page tree but don't want an HTTP server. Accepts the same options as `Mount`; mux-shaped options (middlewares) are inert.
-#### URLFor
+## StructPages methods
```go
func (sp *StructPages) URLFor(page any, args ...any) (string, error)
+func (sp *StructPages) ID(v any) (string, error)
+func (sp *StructPages) IDTarget(v any) (string, error)
+func (sp *StructPages) PageContext(ctx context.Context) context.Context
```
-Generate a URL for a page. Recommended shape: `URLFor(page, params)` with `params` as `map[string]any`.
-
-**Strict.** If the page type is mounted under multiple parents, a bare type lookup errors instead of silently returning the first match. Disambiguate with the `[]any{ParentType{}, LeafType{}}` chain form or `Ref("Parent.Field")`. No opt-out.
-
-**Example:**
-```go
-// Simple path
-url, _ := sp.URLFor(homePage{}) // "/"
+Use the method forms outside request context (initialization, boot-time validation, tests). Within request handlers and templ renders, use the context-based package functions — the framework injects the parse context via internal middleware.
-// With path parameter
-url, _ := sp.URLFor(userPage{}, map[string]any{"id": "123"}) // "/users/123"
+`PageContext` wraps a bare context with `sp`'s page tree so the context-form functions resolve against it. The recommended test pattern: `Parse` once per package, wrap `context.Background()` in `PageContext`, render against the wrapped ctx (see [Templ Patterns](./templ.md#testing-renders-with-a-bare-context)).
-// Multiple parameters
-url, _ := sp.URLFor(postPage{}, map[string]any{
- "year": 2024,
- "slug": "hello",
-}) // "/blog/2024/hello"
-
-// Chain disambiguation when the same leaf type is mounted under multiple parents
-url, _ := sp.URLFor([]any{componentsRoot{}, entryPage{}},
- map[string]any{"slug": "button"}) // "/components/button"
-
-// Composition: chain + literal URL fragment
-url, _ := sp.URLFor([]any{componentsRoot{}, entryPage{}, "?tab={tab}"},
- map[string]any{"slug": "button", "tab": "props"}) // "/components/button?tab=props"
-
-// Ref fallback (cross-package callers that can't import the typed page)
-url, _ := sp.URLFor(structpages.Ref("Components.Detail"),
- map[string]any{"slug": "button"})
-```
-
-See `skills/structpages/SKILL.md` §3 "URL Generation" for the full pattern including the recommended validation strategy (typed helpers + boot-time `validateURLs` + integration test). The `examples/url-validation/` directory ships the end-to-end demo.
-
-#### ID and IDTarget
+## Context functions
```go
-func (sp *StructPages) ID(v any) (string, error)
-func (sp *StructPages) IDTarget(v any) (string, error)
+func URLFor(ctx context.Context, page any, args ...any) (string, error)
+func ID(ctx context.Context, v any) (string, error)
+func IDTarget(ctx context.Context, v any) (string, error)
```
-Generate consistent HTML IDs for component methods.
-- `ID` returns raw ID (for HTML `id` attributes): `"todo-page-todo-list"`
-- `IDTarget` returns CSS selector (for HTMX `hx-target`): `"#todo-page-todo-list"`
-
-**Example:**
-```go
-id, _ := sp.ID((*todoPage).TodoList) // "todo-page-todo-list"
-target, _ := sp.IDTarget((*todoPage).TodoList) // "#todo-page-todo-list"
-```
+Page-argument forms, params formats, strict-mode semantics, and chain composition are covered in [URLFor & ID](./urlfor.md). Id-generation semantics (full field-path ids, multi-mount behavior, length budget) are covered in [HTMX Integration](./htmx.md#how-ids-are-generated).
## Options
-Options are passed to `Mount()` to configure behavior.
-
### WithArgs
```go
-func WithArgs(args ...any) func(*StructPages)
+structpages.WithArgs(store, sessionManager, logger)
```
-Add global dependency injection arguments available to all page methods.
-
-**Example:**
-```go
-type Database struct { /* ... */ }
-type Logger struct { /* ... */ }
-
-db := &Database{}
-logger := &Logger{}
-
-// Pass dependencies using WithArgs
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithArgs(db, logger),
- structpages.WithErrorHandler(errorHandler),
-)
-```
-
-Handler methods can receive injected dependencies:
-
-```go
-func (p productPage) Props(r *http.Request, db *Database, logger *Logger) (ProductProps, error) {
- // Use db and logger
-}
-```
+Register dependency-injection values, matched by type into `Props` / `ServeHTTP` / `Middlewares` / `Init` parameters. Each type registers once; see [Advanced](./advanced.md#dependency-injection) for coercion rules and named-type disambiguation.
### WithErrorHandler
```go
-func WithErrorHandler(handler func(w http.ResponseWriter, r *http.Request, err error)) func(*StructPages)
+structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) { ... })
```
-Set a custom error handler for handling errors during request processing.
-
-**Example:**
-```go
-errorHandler := func(w http.ResponseWriter, r *http.Request, err error) {
- log.Printf("Error: %v", err)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
-}
-
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithErrorHandler(errorHandler),
-)
-```
+The single callback that owns every error response from buffered handlers and Props. See [Error Handling](./error-handling.md#the-global-handler) for the full pattern — typed statuses, the `Redirect` signal, cancellation, logged-500 fallback.
### WithMiddlewares
```go
-func WithMiddlewares(middlewares ...MiddlewareFunc) func(*StructPages)
+structpages.WithMiddlewares(loggingMiddleware, authMiddleware)
```
-Add global middleware that applies to all routes.
-
-**Example:**
-```go
-loggingMiddleware := func(next http.Handler, pn *PageNode) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- log.Printf("%s %s", r.Method, r.URL.Path)
- next.ServeHTTP(w, r)
- })
-}
-
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithMiddlewares(loggingMiddleware),
-)
-```
+Global middleware applied to all routes. `MiddlewareFunc` is `func(next http.Handler, pn *PageNode) http.Handler`. See [Middleware](./middleware.md) for execution order.
### WithTargetSelector
```go
-func WithTargetSelector(selector TargetSelector) func(*StructPages)
+structpages.WithTargetSelector(structpages.HTMXv4RenderTarget)
```
-Set a global target selector function that determines which component to render. The selector returns a `RenderTarget` that is passed to your Props method, enabling conditional data loading and component selection.
-
-The default selector is `HTMXRenderTarget`, which handles HTMX partial rendering automatically.
+Replace the default `HTMXRenderTarget` — e.g. with the htmx 4 variant, or a custom selector for content negotiation. See [HTMX Integration](./htmx.md#custom-target-selectors).
-A custom selector returns any type that implements the `RenderTarget` interface (`Is(method any) bool`). The framework's own `methodRenderTarget` and `functionRenderTarget` constructors are unexported, so a custom selector typically either: (a) delegates to `HTMXRenderTarget` and returns its result for the cases it doesn't want to override, or (b) returns its own type that implements `RenderTarget` (and optionally `Component() component` for direct rendering — see *RenderComponent* below).
-
-**Example — content negotiation that falls back to HTMX:**
+### WithMaxIDLength
```go
-// Custom RenderTarget for JSON responses
-type jsonTarget struct{ data any }
-
-func (t jsonTarget) Is(method any) bool { return false } // never matches normal components
-func (t jsonTarget) Component() component {
- return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
- return json.NewEncoder(w).Encode(t.data)
- })
-}
-
-selector := func(r *http.Request, pn *structpages.PageNode) (structpages.RenderTarget, error) {
- if r.Header.Get("Accept") == "application/json" {
- return jsonTarget{data: loadJSON(r, pn)}, nil
- }
- return structpages.HTMXRenderTarget(r, pn)
-}
-
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithTargetSelector(selector),
-)
+structpages.WithMaxIDLength(60) // default 40
```
-When `Props` calls `RenderComponent(target)` (no args) on a target that implements `Component()`, the framework calls `Component()` to get the component to render — useful for selectors that already know the data.
+Character budget for generated element ids before they degrade from the readable full-path form (`admin-users-user-list`) to the compact leaf-only form (`user-list`, plus a stable hash suffix when the leaf name is not unique). Affects id generation only, never routing.
-### WithWarnEmptyRoute
+### WithURLPrefix
```go
-func WithWarnEmptyRoute(warnFunc func(*PageNode)) func(*StructPages)
+structpages.WithURLPrefix("/app")
```
-Customize or suppress warnings for pages with no handler and no children.
+Prefix prepended to all generated URLs — for apps served under a sub-path.
+
+### WithWarnEmptyRoute
-**Example:**
```go
-// Use default warning (prints to stdout)
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithWarnEmptyRoute(nil),
-)
-
-// Custom warning function
-customWarn := func(pn *PageNode) {
- log.Printf("Skipping empty page: %s", pn.Name)
-}
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithWarnEmptyRoute(customWarn),
-)
-
-// Suppress warnings entirely
-sp, err := structpages.Mount(mux, &pages{}, "/", "Home",
- structpages.WithWarnEmptyRoute(func(*PageNode) {}),
-)
+structpages.WithWarnEmptyRoute(func(pn *structpages.PageNode) {
+ log.Printf("skipping empty page: %s", pn.Name)
+})
```
-## Page Methods
+Customize or suppress (`func(*PageNode) {}`) warnings for pages with no handler and no children.
+
+## Page methods
-Pages can implement several optional methods:
+Pages can implement these optional methods. Parameters on `Props`, `ServeHTTP`, `Middlewares`, and `Init` are matched by **type**, in any order; injectable types are `*http.Request`, `http.ResponseWriter`, `structpages.RenderTarget`, `*structpages.PageNode`, and anything registered via `WithArgs`.
### Page
```go
-func (p PageType) Page() Component
+templ (p myPage) Page(props MyProps) { ... }
```
-Required for pages that render content. Returns the component to render.
+The main render entry — a page component composing the full page. Pages without a `Page` method can still render by returning `RenderComponent(...)` from Props.
### Props
-Optional. Prepare data before rendering. The framework matches each parameter by **type** (not position), so any of these signatures work and parameters can appear in any order:
-
```go
-func (p PageType) Props(r *http.Request) (PropsType, error)
-func (p PageType) Props(r *http.Request, store *Store) (PropsType, error)
-func (p PageType) Props(r *http.Request, w http.ResponseWriter, store *Store) (PropsType, error)
-func (p PageType) Props(r *http.Request, target RenderTarget, store *Store) (PropsType, error)
+func (p myPage) Props(r *http.Request, target structpages.RenderTarget, store *Store) (MyProps, error)
```
-Injectable parameter types: `*http.Request`, `http.ResponseWriter`, `RenderTarget`, `*PageNode`, and any type registered via `WithArgs`. **DI is positional+typed, not variadic** — there is no `deps ...any` form; declare each dep as its own typed parameter.
-
-Use `target.Is(component)` to conditionally load data based on which component is being rendered.
-
-**Example:**
-```go
-func (p DashboardPage) Props(r *http.Request, target RenderTarget, db *Database) (DashboardProps, error) {
- switch {
- case target.Is(p.UserList):
- // Only load user data for partial update
- users := db.LoadUsers()
- return DashboardProps{}, RenderComponent(target, users)
-
- case target.Is(p.Page):
- // Load all data for full page
- return DashboardProps{
- Users: db.LoadUsers(),
- Stats: db.LoadStats(),
- }, nil
- }
- return DashboardProps{}, nil
-}
-```
+Loads data before render; the returned props struct is passed to the selected page component. Only the method literally named `Props` is auto-invoked. Runs against a buffered writer — return errors, never write `w` (see [Error Handling](./error-handling.md)).
### ServeHTTP
-Optional. Handle HTTP requests directly. Four signatures are supported (DI form takes typed params, not variadic `any`):
+Four signatures:
```go
-func (p PageType) ServeHTTP(w http.ResponseWriter, r *http.Request) // standard http.Handler
-func (p PageType) ServeHTTP(w http.ResponseWriter, r *http.Request) error // buffered, error → handler
-func (p PageType) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) // DI, no return
-func (p PageType) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error // DI, buffered
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request) // standard http.Handler, unbuffered
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request) error // buffered; error → WithErrorHandler
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) // DI, no return, unbuffered
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error // DI, buffered
```
-In the DI forms, `RenderTarget` is also injectable (the framework computes one and adds it to the available args), so `ServeHTTP` can decide which partial to render via `target.Is(...)` + `RenderComponent(...)`.
+In the DI forms, `RenderTarget` is also injectable, so a handler method can branch on `target.Is(...)` before responding.
### Middlewares
```go
-func (p PageType) Middlewares() []MiddlewareFunc
-```
-
-Optional. Return page-specific middleware.
-
-## Context Functions
-
-For use within handlers:
-
-### URLFor
-
-```go
-func URLFor(ctx context.Context, page any, args ...any) (string, error)
+func (p T) Middlewares(deps ...) []structpages.MiddlewareFunc
```
-Generate URLs using context (available during request handling). Same `page` and `args` semantics as `(*StructPages).URLFor` above — strict by default, supports `[]any` chain composition, `Ref` qualified paths, and `map[string]any` params.
+Page-specific middleware, also applied to all descendants.
-### ID and IDTarget
+### Init
```go
-func ID(ctx context.Context, v any) (string, error)
-func IDTarget(ctx context.Context, v any) (string, error)
+func (p *T) Init(deps ...) error
```
-Generate IDs using context (available during request handling).
-- `ID` returns raw ID (for HTML `id` attributes)
-- `IDTarget` returns CSS selector (for HTMX `hx-target`)
+One-time setup at `Mount`; errors abort the mount. See [Advanced](./advanced.md#initialization).
## RenderTarget
-The `RenderTarget` interface represents the component that will be rendered for a request. It's passed to your Props method, enabling conditional data loading.
-
-### Interface
-
```go
type RenderTarget interface {
Is(method any) bool
}
```
-### Is Method
+Represents the page component selected for this request. Injected into Props (and DI-form `ServeHTTP`). `Is` accepts page component references (`target.Is(p.UserList)`) and standalone component functions (`target.Is(UserStatsWidget)`).
-```go
-func (target RenderTarget) Is(method any) bool
-```
+## RenderComponent
-Check if the target matches a specific component. Works with:
-- **Page methods**: `target.Is(p.Page)`, `target.Is(p.UserList)`
-- **Standalone functions**: `target.Is(UserStatsWidget)` (templ components that are functions)
-
-**Example:**
```go
-func (p DashboardPage) Props(r *http.Request, target RenderTarget) (DashboardProps, error) {
- // Check against page method
- if target.Is(p.UserList) {
- users := loadUsers()
- return DashboardProps{}, RenderComponent(target, users)
- }
-
- // Check against standalone function component
- if target.Is(UserStatsWidget) {
- stats := loadUserStats()
- return DashboardProps{}, RenderComponent(target, stats)
- }
-
- // Full page
- return loadFullPageData(), nil
-}
+func RenderComponent(targetOrMethod any, args ...any) error
```
-### RenderComponent
+Returns a sentinel error instructing the framework to render a specific component. Honored in the Props error path and the buffered-`ServeHTTP` error path; when returned from Props, the other return values are ignored.
+
+**Direct (preferred — compile-time-checked):**
```go
-func RenderComponent(targetOrMethod any, args ...any) error
+return MyProps{}, structpages.RenderComponent(p.UserList(users)) // same page
+return structpages.RenderComponent(index{}.TodoList(todos)) // another page — zero-value receiver
+return MyProps{}, structpages.RenderComponent(UserStatsWidget(stats)) // standalone component
```
-Override which component to render and pass specific arguments to it. Can be called from Props:
+**Reflective (framework finds the page and applies DI):**
-**Same-page component:**
```go
-// Render the component specified by target with custom args
-return DashboardProps{}, RenderComponent(target, userData)
+return structpages.RenderComponent(MyPage.ItemList) // params DI-injected by the framework
+return structpages.RenderComponent(MyPage.ItemList, items) // explicit args fill non-injected params, checked at runtime
```
-**Cross-page component:**
-```go
-// Render a component from another page using method expression
-return DashboardProps{}, RenderComponent(OtherPage{}.Content, data)
-```
+Reserve the reflective form for components whose parameters the framework should DI-inject. Argument count and assignability are validated before the call — mismatches surface as readable errors, but at runtime, not compile time.
-**Standalone function:**
-```go
-// Render a standalone function component
-return DashboardProps{}, RenderComponent(target, stats)
-```
+A custom `RenderTarget` that also implements `Component() component` can be rendered with `RenderComponent(target)` (no args).
-### HTMXRenderTarget
+## HTMXRenderTarget
```go
func HTMXRenderTarget(r *http.Request, pn *PageNode) (RenderTarget, error)
```
-The default `TargetSelector` that handles HTMX partial rendering. Algorithm:
+The default `TargetSelector`. Non-HTMX requests (no `HX-Request: true`), or HTMX requests with no `HX-Target`, select the `Page` method. Otherwise the `HX-Target` value is matched against the page's components:
+
+- **Pass 0 — authoritative**: compare against each component's *real generated id* — the same value `ID()` emits, including the full field-path prefix and any length-budget compaction. This is the true inverse of `ID`/`IDTarget`.
+- **Pass 1 — exact heuristics**: `-`, then bare ``.
+- **Pass 2 — suffix match (longest wins)**: full id ends with target; target ends with full id; or target ends with `` *only when* target starts with `-` (guards against cross-page false matches).
+
+If no method matches, the raw target is carried as a function target and bound lazily when Props calls `target.Is(SomeFunc)` — this is how standalone component functions become HTMX targets.
+
+### HTMXv4RenderTarget
-1. Non-HTMX requests (no `HX-Request: true` header), or HTMX requests with no `HX-Target` → returns `methodRenderTarget` for the page's `Page()` method.
-2. HTMX request with `HX-Target` → tries to match it against the page's component methods:
- - **Pass 1, exact match**: first against `-`, then against bare ``.
- - **Pass 2, suffix match (longest wins)**: with three rules — full ID ends with target; target ends with full ID; or target ends with `` (only when target also starts with `-`, which guards against cross-page false matches).
-3. If a method matches → returns `methodRenderTarget` for it.
-4. If no method matches → returns `functionRenderTarget` carrying the raw `HX-Target`. The actual function value is bound lazily when `Props` calls `target.Is(SomeFunc)`.
+```go
+structpages.WithTargetSelector(structpages.HTMXv4RenderTarget)
+```
-**Examples** (page named `IndexPage`, components `Content`, `TodoList`):
-- `HX-Target: "content"` → `Content()` (exact match without page prefix)
-- `HX-Target: "index-page-todo-list"` → `TodoList()` (exact match with page prefix)
-- `HX-Target: "todo-list"` → `TodoList()` (exact match without page prefix)
-- `HX-Target: "dashboard-page-user-stats-widget"` (no method by that name) → `functionRenderTarget`; resolved to `UserStatsWidget` standalone function only after Props calls `target.Is(UserStatsWidget)`.
+htmx 4 variant. htmx 4 sends `HX-Target` as `"#"` (or bare `""`) and adds `HX-Request-Type: full|partial`. The v4 selector treats `HX-Request-Type: full` as a hard hint to render `Page`, prefers the id portion of the target, falls back to the tag for id-less targets, and otherwise applies the same matching rules.
-## Error Types
+## Error types
### ErrSkipPageRender
@@ -447,15 +240,8 @@ The default `TargetSelector` that handles HTMX partial rendering. Algorithm:
var ErrSkipPageRender = errors.New("skip page render")
```
-Return this error from `Props` to skip rendering (useful for redirects). **Only the Props error path checks for this sentinel** — returning it from `ServeHTTP` does nothing special.
+Return from `Props` to skip rendering when the response was written directly (rare — prefer the [`Redirect` signal](./error-handling.md#redirects-a-control-flow-signal-not-httpredirect)). Only the Props error path checks this sentinel.
-**Example:**
-```go
-func (p loginPage) Props(r *http.Request, w http.ResponseWriter) (LoginProps, error) {
- if isAuthenticated(r) {
- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
- return LoginProps{}, structpages.ErrSkipPageRender
- }
- return LoginProps{}, nil
-}
-```
+## Buffered response
+
+Error-returning `ServeHTTP` (and every `Props`) runs against a buffered writer: on error the buffer is discarded and `WithErrorHandler` renders instead. The no-return forms are unbuffered. For streaming through either form, `http.NewResponseController(w)` reaches the real flusher via the `Unwrap()` chain. Full rules and patterns: [Error Handling](./error-handling.md).
diff --git a/docs/concepts.md b/docs/concepts.md
new file mode 100644
index 0000000..95a137f
--- /dev/null
+++ b/docs/concepts.md
@@ -0,0 +1,61 @@
+---
+title: Concepts
+slug: /concepts
+sidebar_position: 3
+---
+
+# Concepts & Vocabulary
+
+structpages has its own canonical terms for its recurring patterns. Where a React / Next.js / React Router concept maps cleanly, it's noted as a cross-reference for knowledge transfer — but the structpages term is primary. Two guardrails: Go wins where Go owns the concept (`ServeHTTP` is a **handler method**, not a "server action"), and pure composition isn't named (a layout is just a **component** that takes **children** — there's no "layout route").
+
+## Core nouns
+
+| Term | What it is | Cross-ref |
+|---|---|---|
+| **page** | a route-tagged struct — a node in the route tree | Next/RR route/page |
+| **page group** | a page with no render of its own (no `Page` or `ServeHTTP`), only child pages; served through its `/{$}` page | — (not a "layout route") |
+| **component** | a standalone `templ Foo()` block — reusable, mount-independent, package-prefixed id | React component |
+| **page component** | a `templ (p Page) Foo()` method — mount-aware, receiver in scope (incl. `Page`, `Content`). Used two ways: **composition** (called inside another page component) and **re-rendering** (returned alone as a partial) | React component (bound) |
+| **children** | templ `{ children... }` composition | React children |
+| **partial** | a page component returned on its own as an HTMX response to re-render just that region — a *role* a page component plays, not a distinct kind | HTMX |
+
+## The props cluster
+
+| Term | What it is | Cross-ref |
+|---|---|---|
+| **Props method** | the `Props(...)` method that loads data via DI | *like RR `loader` / Next `getServerSideProps`* |
+| **props struct** | the named struct type the Props method returns and page components accept | *like a React props type* |
+| **props** | a value of the props struct, in flight into a page component | React props (the value) |
+
+The chain reads: the **Props method** returns a **props struct**; that **props** value is handed to a **page component**.
+
+## Methods on a page
+
+| Term | Method | Job |
+|---|---|---|
+| **Page method** | `Page(props)` | the main render entry — a page component that composes the full page (layout + content) |
+| **Props method** | `Props(...)` | loads data via DI → returns the props struct |
+| **handler method** | `ServeHTTP(...)` | imperative entry: mutate / redirect / serve JSON, or render a partial via `RenderComponent` — the Go `http.Handler` shape |
+| **Middlewares method** | `Middlewares()` | declares middleware for the page + descendants |
+
+(`Content` is not a framework concept — just a conventional page component name for a layout's main region; the matcher treats it like any other page component.)
+
+The two render entries differ in flavor: the **Page method** renders declaratively (compose page components); the **handler method** renders imperatively (write the response, or hand a page component to `RenderComponent`). Both ultimately render through page components.
+
+## Request lifecycle
+
+For a rendering page: **route match → Props method** (with `RenderTarget` injected to pick the region) **→ page component render** — `Page` for full loads, a partial for HTMX requests targeting that region's id. A handler method (`ServeHTTP`) bypasses this pipeline: it responds imperatively, optionally handing a page component to `RenderComponent`.
+
+## API helpers (literal — these are the public API)
+
+`RenderComponent`, `RenderTarget`, `URLFor`, `ID` / `IDTarget`, `Ref`, `WithArgs` (dependency injection / **args**).
+
+## Loose comparisons (analogies, not structpages terms)
+
+For readers arriving from React/Next — transfer aids, not structpages vocabulary.
+
+| structpages | React/Next analogy | note |
+|---|---|---|
+| `/{$}` route of a page group | RR **index route** | nothing special — just the group's own page |
+| **Page method** vs **handler method** | declarative `page` vs imperative **Route Handler / API route** | two ways to respond within one router — **not** "Page Router vs App Router" |
+| **component** composition | Server Component composition | both render on the server |
diff --git a/docs/error-handling.md b/docs/error-handling.md
new file mode 100644
index 0000000..0789b7c
--- /dev/null
+++ b/docs/error-handling.md
@@ -0,0 +1,168 @@
+---
+title: Error Handling
+slug: /error-handling
+sidebar_position: 9
+---
+
+# Error Handling
+
+The error-returning forms of `ServeHTTP` — and **every** Props method — run against a *buffered* response writer. On a non-nil error the buffer is discarded and the error goes to the `WithErrorHandler` callback, which renders a clean error page even if the handler had already written partial output. Everything in this guide follows from that one mechanism.
+
+## The rules
+
+1. **Never call `http.Error` (or write `w`) in an error-returning handler or in Props.** If you write then `return err`, the write is discarded; if you write then `return nil`, you bypass the error handler. Just return the error.
+2. **For a specific status code, return a typed error** that the global handler unwraps with `errors.As`. Plain errors fall through to a logged 500.
+3. **API/JSON endpoints use the no-error `ServeHTTP` form** — direct `w` writes are correct there because you own the status code and skip the buffering wrapper. Write JSON error bodies, not `http.Error`.
+4. **For streaming (SSE), flush with `http.NewResponseController(w)`** — it works from either `ServeHTTP` form and is the only way to guarantee unbuffered delivery through middleware.
+
+## Typed errors for status codes
+
+Define one error type that carries the status and message; the global handler renders it:
+
+```go
+type ErrorWithStatus struct {
+ Status int
+ Title string
+ Message string
+}
+
+func (e ErrorWithStatus) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Title, e.Message) }
+```
+
+```go
+func (p detailPage) Props(r *http.Request, store *Store) (DetailProps, error) {
+ item, err := store.Load(r.Context(), r.PathValue("itemId"))
+ switch {
+ case errors.Is(err, ErrNotFound):
+ return DetailProps{}, ErrorWithStatus{Status: http.StatusNotFound, Title: "Not found", Message: "no such item"}
+ case err != nil:
+ return DetailProps{}, fmt.Errorf("detail: load: %w", err) // plain error → logged 500
+ }
+ return DetailProps{Item: item}, nil
+}
+```
+
+`errors.As` unwraps, so `fmt.Errorf("...: %w", ErrorWithStatus{...})` still resolves to its status.
+
+## Redirects: a control-flow signal, not `http.Redirect`
+
+Don't call `http.Redirect` from a handler in an HTMX app — during an HTMX request the XHR follows the 3xx and swaps the redirect *target's* body into the partial's swap target. Return a signal instead; the error handler sends `HX-Location` for HTMX (ajax navigation, like a boosted link) and a 303 otherwise:
+
+```go
+// Redirect is control flow, not a real error — it implements error only to
+// ride the error-return path, which unwinds the render flow without writing
+// the ResponseWriter directly.
+type Redirect struct{ To string }
+
+func (Redirect) Error() string { return "redirect" }
+```
+
+```go
+func (p submitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error {
+ id, err := store.Save(r.Context(), r.FormValue("name"))
+ if err != nil {
+ return err
+ }
+ url, err := structpages.URLFor(r.Context(), detailPage{}, map[string]any{"itemId": id})
+ if err != nil {
+ return err
+ }
+ return Redirect{To: url}
+}
+```
+
+Use `HX-Redirect` instead of `HX-Location` only when the destination genuinely needs a full browser load — a non-htmx endpoint, or a page with different `` content/scripts.
+
+## The global handler
+
+Wired once at `Mount`, it owns every error response — typed statuses, redirects, cancellations, and the logged-500 fallback:
+
+```go
+structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, context.Canceled) || r.Context().Err() != nil {
+ w.WriteHeader(499) // client closed request — expected, don't log as error
+ return
+ }
+ var redir Redirect
+ if errors.As(err, &redir) {
+ if r.Header.Get("HX-Request") == "true" {
+ // Ajax navigation, like a boosted link. The status must stay 2xx:
+ // htmx does not process response headers on 3xx responses.
+ w.Header().Set("HX-Location", redir.To)
+ return
+ }
+ http.Redirect(w, r, redir.To, http.StatusSeeOther)
+ return
+ }
+ status, title, message := http.StatusInternalServerError, "Server error", err.Error()
+ var se ErrorWithStatus
+ if errors.As(err, &se) {
+ status, title, message = se.Status, se.Title, se.Message
+ } else {
+ slog.Error("unhandled error rendering page", "error", err, "path", r.URL.Path)
+ }
+ // One place that knows how to render: HTMX-aware retarget, full layout vs bare page.
+ renderHTTPError(w, r, status, title, message)
+})
+```
+
+## JSON endpoints: the no-error form
+
+For endpoints that serve JSON, use the no-return `ServeHTTP(w, r, deps...)` signature. It is unbuffered, the HTML error handler is never invoked, and you own the response — including errors, which are JSON like everything else. Don't reach for `http.Error`; its `text/plain` body is the wrong shape for an API client:
+
+```go
+func (p trackTime) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) {
+ var body trackTimeRequest
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "invalid request")
+ return
+ }
+ if err := store.UpdateTime(r.Context(), body); err != nil {
+ writeJSONError(w, http.StatusInternalServerError, "update failed")
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// The API's single error shape, defined once:
+func writeJSONError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
+```
+
+## Streaming (SSE)
+
+Picking the no-return form is not enough to guarantee writes reach the client immediately — `w` may still be wrapped by middleware. Use `http.ResponseController`, which walks the `Unwrap()` chain to find a flusher:
+
+```go
+func (p progress) ServeHTTP(w http.ResponseWriter, r *http.Request, jobs *JobService) error {
+ w.Header().Set("Content-Type", "text/event-stream")
+ rc := http.NewResponseController(w)
+ for update := range jobs.Progress(r.Context()) {
+ fmt.Fprintf(w, "event: progress\ndata: %s\n\n", update)
+ if err := rc.Flush(); err != nil {
+ return nil // client gone
+ }
+ }
+ return nil
+}
+```
+
+This works from *either* `ServeHTTP` form — the buffered wrapper implements `FlushError()` and `Unwrap()`. Once you've started flushing, a non-nil error can no longer produce a clean error page (bytes are on the wire) — send an `event: error` SSE frame instead and `return nil`.
+
+## Which form to use
+
+| Handler does… | `ServeHTTP` signature | Errors via |
+|---|---|---|
+| Renders HTML / HTMX partial | `(w, r, deps...) error` | `return ErrorWithStatus{…}` / `return err` |
+| Redirects | `(w, r, deps...) error` | `return Redirect{To: …}` |
+| Serves JSON / API | `(w, r, deps...)` *(no return)* | write `w` directly with a JSON error body |
+| Streams (SSE, progress) | either form + `http.NewResponseController` | SSE `event: error` frame, then `return nil` |
+
+Props methods always follow the first row — they are buffered and their errors flow to `WithErrorHandler`.
+
+## ErrSkipPageRender
+
+If a Props method writes the response itself (rare — prefer the `Redirect` signal), return `structpages.ErrSkipPageRender` to skip rendering. Only the Props error path checks this sentinel; returning it from `ServeHTTP` does nothing special.
diff --git a/docs/htmx.md b/docs/htmx.md
index 080b6aa..6031f5e 100644
--- a/docs/htmx.md
+++ b/docs/htmx.md
@@ -1,411 +1,240 @@
-# HTMX Integration
-
-Structpages has built-in HTMX support enabled by default through `HTMXRenderTarget`. This makes `ID` and `IDTarget` work seamlessly with HTMX partial rendering out of the box.
-
-### How It Works
+---
+title: HTMX Integration
+slug: /htmx
+sidebar_position: 7
+---
-When an HTMX request is detected (via `HX-Request` header), the framework automatically:
+# HTMX Integration
-1. Reads the `HX-Target` header value
-2. Matches it to a component method or standalone function
-3. Renders that specific component instead of the full page
+structpages has built-in HTMX support enabled by default through `HTMXRenderTarget`. All HTMX requests for a page go to the SAME route; the framework picks which page component to render from the `HX-Target` header.
-For example (page named `index`, with `Content` and `TodoList` components):
-- `HX-Target: "content"` → matches `Content()` (exact match, no prefix needed)
-- `HX-Target: "index-todo-list"` → matches `TodoList()` (exact match with page prefix)
-- `HX-Target: "todo-list"` → matches `TodoList()` (exact match without prefix)
-- `HX-Target: "dashboard-page-user-stats-widget"` (no method by that name) → returns a function-target placeholder; the actual `UserStatsWidget` standalone function is bound lazily when Props calls `target.Is(UserStatsWidget)`
-- No `HX-Target` or non-HTMX request → falls back to `Page()` method
+## The central loop
-Detailed matching algorithm and the page-prefix guard for cross-page false matches are described in [api.md → HTMXRenderTarget](./api.md#htmxrendertarget).
+**One method reference — e.g. `index.TodoList` — drives three sites that must agree, and `ID`/`IDTarget` make them agree by construction:**
-This works automatically with `ID` and `IDTarget`:
+1. **Composition site** — where the page component is composed in, wrap it in an element with `id={ structpages.ID(ctx, index.TodoList) }`.
+2. **Trigger site** — the element that fires the update points `hx-target={ structpages.IDTarget(ctx, index.TodoList) }` at the page's own route.
+3. **Server site** — structpages matches the `HX-Target` header back to the page component by id, and the Props method branches on the injected `RenderTarget` with `target.Is(p.TodoList)` to load just that region's data.
-```go
-// In your template
+```templ
+// Site 1 — composition: set the element ID on the component's wrapper
- @p.TodoList()
+ @p.TodoList(props.Todos)
-// In HTMX attributes
-hx-target={ structpages.IDTarget(ctx, index.TodoList) } // Generates "#index-todo-list"
+// Site 2 — trigger: target that id, hit the page's own route
+
```
-The HTMX request will automatically extract the component name from the target ID and render just that component.
-
----
-
-## RenderTarget and Props Integration
-
-The real power of HTMX integration comes from the `RenderTarget` parameter in your `Props` method. RenderTarget tells your Props method **which component will be rendered**, allowing you to:
-
-- ✅ Load only the data needed for that specific component
-- ✅ Optimize database queries for partial updates
-- ✅ Override component selection based on application logic
-- ✅ Maintain type safety throughout the flow
-- ✅ Use standalone function components shared across pages
-
-**Important:** While `HTMXRenderTarget` is configurable (you can customize how components are selected from HTMX requests), `RenderTarget.Is()` works regardless of your configuration. Whatever component selection logic you use, the `RenderTarget` passed to Props will correctly identify which component was selected, making your Props code independent of the selection mechanism.
-
-### How Component Selection Works
-
-When an HTMX request arrives:
-
-```
-1. Request arrives with HX-Target header
- ↓
-2. HTMXRenderTarget extracts target ID (e.g., "index-todo-list")
- ↓
-3. Component is determined (e.g., TodoList method or UserStatsWidget function)
- ↓
-4. RenderTarget is created with that component (lazy evaluation for functions)
- ↓
-5. Props(r, target) is called with the RenderTarget
- ↓
-6. Props loads appropriate data based on target.Is(component)
- ↓
-7. Component renders with the data
-```
-
-### Basic Pattern: Conditional Data Loading
-
-Use `RenderTarget` to load only what you need:
-
```go
-type index struct{}
-
-type IndexProps struct {
- Todos []Todo
- Stats DashboardStats
- UserInfo UserInfo
-}
-
+// Site 3 — server: Props branches on the injected RenderTarget
func (p index) Props(r *http.Request, target structpages.RenderTarget) (IndexProps, error) {
- switch {
- case target.Is(p.TodoList):
- // HTMX is updating just the todo list - only load todos
- return IndexProps{
- Todos: getTodos(),
- }, nil
-
- case target.Is(p.Page):
- // Full page load - load everything
- return IndexProps{
- Todos: getTodos(),
- Stats: getDashboardStats(),
- UserInfo: getCurrentUser(),
- }, nil
-
- default:
- // Fallback
- return IndexProps{}, nil
+ if target.Is(p.TodoList) {
+ todos, err := getActiveTodos()
+ if err != nil {
+ return IndexProps{}, err
+ }
+ return IndexProps{}, structpages.RenderComponent(p.TodoList(todos))
}
+ todos, err := getAllTodos()
+ if err != nil {
+ return IndexProps{}, err
+ }
+ return IndexProps{Todos: todos}, nil
}
+```
-templ (p index) Page(props IndexProps) {
-
-
{ props.UserInfo.Name }
-
{ props.Stats.String() }
+Because all three sites 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.**
-
- @p.TodoList(props.Todos)
-
-
-}
+## How ids are generated
-templ (p index) TodoList(todos []Todo) {
- for _, todo := range todos {
-
{ todo.Text }
- }
-}
-```
+`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:
-**What happens:**
-- Initial page load → `target.Is(index.Page)` is true → loads all data
-- HTMX updates todo list → `target.Is(index.TodoList)` is true → loads only todos
-- Database queries are minimized for partial updates ⚡
+- `ID(ctx, index.TodoList)` → `"index-todo-list"` for a top-level page
+- the same component on a page mounted at `admin.users` → `"admin-users-todo-list"`
+- `IDTarget` prepends `#`
-### Advanced Pattern: RenderComponent Override
+Including the ancestor path guarantees two different mounts of the same struct never collide. If the full id exceeds the length budget (default 40 chars, see `WithMaxIDLength`) it degrades to the compact leaf-only form with a stable hash suffix when the leaf name is shared.
-Sometimes you need to render a different component than what was selected, or you want to pass specific data to a component. Use `RenderComponent` within Props:
+**Components** (standalone templ functions) are prefixed by their package name: `ID(ctx, UserWidget)` → `"-user-widget"`. Plain strings pass through unchanged — `IDTarget("body")` is `"body"`, not `"#body"` (literal CSS selectors are legitimate; literal URL paths are not).
-```go
-type TeamManagementView struct{}
+**Self-render uses the current mount.** When `ID` runs inside a page's own templ, the id derives from *that mount's* field name — the same struct mounted under different parents produces different ids per render context. **Cross-page references with multiple mounts must be unambiguous** — a bare method expression errors with the available mounts listed; disambiguate with the `[]any` chain form, a `Ref`, or a standalone function:
-type TeamManagementProps struct {
- UserPaneProps UserPaneProps
- GroupPaneProps GroupPaneProps
-}
+```go
+// IDTarget(ctx, []any{adminRoot{}, dashboardPage{}, "Header"}) // chain + string
+// IDTarget(ctx, []any{adminRoot{}, dashboardPage.Header}) // chain + method expr
+// IDTarget(ctx, Ref("AdminDash.Header")) // by field name
+// IDTarget(ctx, EntryOverlaySlot) // standalone func: package-prefixed id
+```
-type UserPaneProps struct {
- Users []UserWithGroups
- UserSearchQuery string
-}
+## RenderTarget in Props
-type GroupPaneProps struct {
- Groups []Group
- GroupSearchQuery string
-}
+The `RenderTarget` parameter tells your Props method **which page component will render**, so it can load only that region's data. Whatever selector configuration you use, `target.Is()` works the same — Props code is decoupled from the selection mechanism.
-func (p TeamManagementView) Props(r *http.Request, target structpages.RenderTarget) (TeamManagementProps, error) {
+```go
+func (p TeamManagementView) Props(r *http.Request, target structpages.RenderTarget, store *Store) (TeamManagementProps, error) {
switch {
case target.Is(p.GroupList):
- // Load only group data
- groups, err := loadGroups(r)
+ groups, err := store.SearchGroups(r.Context(), r.FormValue("group-search"))
if err != nil {
return TeamManagementProps{}, err
}
- // Override: render GroupList with just the groups data
- return TeamManagementProps{}, structpages.RenderComponent(target, groups)
+ return TeamManagementProps{}, structpages.RenderComponent(p.GroupList(groups))
case target.Is(p.UserList):
- // Load only user data
- users, err := loadUsers(r)
+ users, err := store.SearchUsers(r.Context(), r.FormValue("user-search"))
if err != nil {
return TeamManagementProps{}, err
}
- // Override: render UserList with just the users data
- return TeamManagementProps{}, structpages.RenderComponent(target, users)
+ return TeamManagementProps{}, structpages.RenderComponent(p.UserList(users))
- case target.Is(p.Page), target.Is(p.Content):
- // Full page - load everything
- users, err := loadUsers(r)
+ default: // full page — load everything
+ users, err := store.SearchUsers(r.Context(), "")
if err != nil {
return TeamManagementProps{}, err
}
-
- groups, err := loadGroups(r)
+ groups, err := store.SearchGroups(r.Context(), "")
if err != nil {
return TeamManagementProps{}, err
}
-
return TeamManagementProps{
- UserPaneProps: UserPaneProps{
- Users: users,
- UserSearchQuery: r.FormValue("user-search"),
- },
- GroupPaneProps: GroupPaneProps{
- Groups: groups,
- GroupSearchQuery: r.FormValue("group-search"),
- },
+ UserPaneProps: UserPaneProps{Users: users},
+ GroupPaneProps: GroupPaneProps{Groups: groups},
}, nil
-
- default:
- // Fallback to full props
- // ... load everything
}
}
templ (p TeamManagementView) Page(props TeamManagementProps) {
-
-
@p.UserList(props.UserPaneProps.Users)
-
-
@p.GroupList(props.GroupPaneProps.Groups)
}
-
-templ (p TeamManagementView) UserList(users []UserWithGroups) {
- for _, user := range users {
-
{ user.Name }
- }
-}
-
-templ (p TeamManagementView) GroupList(groups []Group) {
- for _, group := range groups {
-
{ group.Name }
- }
-}
```
-**Key Points:**
+Each pane updates independently: typing in the user search box re-renders only `UserList`, with only the user query running.
-1. **Props returns full structure** (`TeamManagementProps`) for the Page component
-2. **Individual components** have simpler signatures (`UserList([]UserWithGroups)`)
-3. **RenderComponent override** passes specific data to specific components
-4. **Type safety** is maintained - component signatures enforce correct data types
+### Why `RenderComponent(p.X(args))` and not `RenderComponent(target, args)`
-**When to use RenderComponent in Props:**
-- ✅ Complex pages with multiple independent sections
-- ✅ Different components need different data structures
-- ✅ Want to avoid returning empty/partial complex props
-- ✅ Need to optimize data loading per component
+`p.GroupList(groups)` is a normal Go call — the compiler checks argument types and counts. The reflective forms (`RenderComponent(target, args)`, `RenderComponent(index.TodoList, args)`) defer those checks to runtime. Use the reflective method-expression form only for components whose parameters the framework should DI-inject; for everything else, construct the component.
-### Complete Example: Search with Dynamic Rendering
+### Overriding the selection
-```go
-type search struct {
- query `route:"GET /search"`
-}
+Props can render a different component than the one selected — return any constructed component:
-func (p search) Props(r *http.Request, target structpages.RenderTarget) ([]Result, error) {
+```go
+func (p search) Props(r *http.Request, target structpages.RenderTarget) (SearchProps, error) {
query := r.URL.Query().Get("q")
-
- // Override based on application logic
if query == "" {
- // No search query - show empty state instead of results
- return nil, structpages.RenderComponent(p.EmptyState)
+ return SearchProps{}, structpages.RenderComponent(p.EmptyState())
}
-
- // Check which component was selected
- switch {
- case target.Is(p.Results):
- // Perform search and return results
- return performSearch(query), nil
-
- case target.Is(p.Page):
- // Full page with recent searches
- return performSearch(query), nil
-
- default:
- return nil, nil
+ results, err := performSearch(query)
+ if err != nil {
+ return SearchProps{}, err
}
-}
-
-templ (p search) Page(results []Result) {
-
+ return SearchProps{Results: results}, nil
}
```
-**What happens:**
-- User types → HTMX sends request with HX-Target: "search-results"
-- If query is empty → Props returns `RenderComponent(search.EmptyState)`
-- If query exists → Props loads results and renders Results component
-- Component selection can be overridden based on business logic ✨
-
----
+### Standalone components shared across pages
-## Common Patterns Summary
+A component (standalone templ function) can be an HTMX target without belonging to any page. `target.Is(UserStatsWidget)` matches it, and the package-prefixed id is stable regardless of which pages embed it:
-### Pattern 1: Simple Conditional Loading
```go
-func (p index) Props(r *http.Request, target structpages.RenderTarget) (Props, error) {
- if target.Is(p.Component) {
- return loadMinimalData(), nil
- }
- return loadFullData(), nil
+templ UserStatsWidget(stats UserStats) {
+
{ stats.ActiveUsers } active users
}
-```
-**Use when:** Single props type works for all components, just need to load different amounts of data.
-### Pattern 2: RenderComponent Override
-```go
-func (p index) Props(r *http.Request, target structpages.RenderTarget) (Props, error) {
- if target.Is(p.Component) {
- data := loadSpecificData()
- return Props{}, structpages.RenderComponent(target, data)
+func (p dashboardPage) Props(r *http.Request, target structpages.RenderTarget, store *Store) (DashboardProps, error) {
+ if target.Is(UserStatsWidget) {
+ stats, err := store.LoadUserStats(r.Context())
+ if err != nil {
+ return DashboardProps{}, err
+ }
+ return DashboardProps{}, structpages.RenderComponent(UserStatsWidget(stats))
}
- return loadFullProps(), nil
+ // ... full page
}
```
-**Use when:** Individual components need different data types than the full page props.
-### Pattern 3: Dynamic Component Selection
-```go
-func (p index) Props(r *http.Request, target structpages.RenderTarget) (Props, error) {
- if someCondition {
- return Props{}, structpages.RenderComponent(p.AlternateComponent)
- }
- // Normal flow
- return loadData(), nil
-}
-```
-**Use when:** Need to change which component renders based on request data or application state.
+## Nested swap levels (Page → Content → Detail)
-### Pattern 4: Standalone Function Components
-```go
-// Shared widget component (standalone function)
-templ UserStatsWidget(stats UserStats) {
-
{ stats.ActiveUsers } active users
-}
+A page's page components can be composed into **nested swap levels**, each an independent HTMX target. The levels are *not* a tree the matcher walks — they're sibling page components on one page, each with its own id. Because `HX-Target` selects the page component whose id it matches exactly, targeting a given level re-renders *only* that level, even though `Page` composes `Content` composes `Detail`:
-func (p DashboardPage) Props(r *http.Request, target structpages.RenderTarget) (DashboardProps, error) {
- // Check against standalone function
- if target.Is(UserStatsWidget) {
- stats := loadUserStats()
- return DashboardProps{}, structpages.RenderComponent(target, stats)
- }
- return loadFullData(), nil
-}
+- **`Page`** — the full document. Cold loads and `hx-boost` body swaps. Composes the app layout around `Content`.
+- **`Content`** — the page's main region. Holds the page chrome — heading, back-link, toolbar — around the inner level. Swapped on boosted nav between pages.
+- **`Detail`** (or another inner name) — a region *inside* `Content` that must swap on its own. Holds **none** of the page chrome.
+
+```templ
+templ (d FooDetail) Page(p Props) { @layout(title) { @d.Content(p) } }
+templ (d FooDetail) Content(p Props) {
}
```
-**Use when:** Need to share components across multiple pages without creating wrapper methods.
----
+**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 into the pane. 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.**
-### Custom Target Selector
+The rule generalizes: **one page component per independently-swappable region, outer wraps inner, embed/target the innermost that has no chrome above it.**
-The default `HTMXRenderTarget` works for most use cases. For custom logic, return any value implementing `RenderTarget` (`Is(method any) bool`). The framework's own constructors (`methodRenderTarget`, `functionRenderTarget`) are unexported, so a custom selector typically delegates to `HTMXRenderTarget` for the cases it doesn't override.
+## Custom target selectors
-If your custom target type also implements `Component() component`, then `RenderComponent(target)` (no args) inside Props will call `Component()` directly to get the component to render — handy for selectors that already know the data.
+The default `HTMXRenderTarget` covers HTMX 1.x/2.x. For htmx 4 — which reshaped `HX-Target` to `"#"` and added `HX-Request-Type` — wire the v4 variant:
```go
-type customRenderTarget struct {
- component component
-}
+sp, err := structpages.Mount(mux, pages{}, "/", "App",
+ structpages.WithTargetSelector(structpages.HTMXv4RenderTarget),
+)
+```
-func (c customRenderTarget) Is(method any) bool { return false } // never matches normal components
-func (c customRenderTarget) Component() component { return c.component }
+For fully custom logic, return any value implementing `RenderTarget` (`Is(method any) bool`), typically delegating to `HTMXRenderTarget` for the cases you don't override. If your target also implements `Component() component`, then `RenderComponent(target)` (no args) renders it directly:
-mux := http.NewServeMux()
-sp, err := structpages.Mount(mux, pages{}, "/", "My App",
+```go
+type jsonTarget struct{ data any }
+
+func (t jsonTarget) Is(method any) bool { return false }
+func (t jsonTarget) Component() component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
+ return json.NewEncoder(w).Encode(t.data)
+ })
+}
+
+sp, err := structpages.Mount(mux, pages{}, "/", "App",
structpages.WithTargetSelector(func(r *http.Request, pn *structpages.PageNode) (structpages.RenderTarget, error) {
- if r.Header.Get("X-Custom-Target") == "json" {
- return customRenderTarget{component: jsonComponent(loadData(r, pn))}, nil
+ if r.Header.Get("Accept") == "application/json" {
+ return jsonTarget{data: loadData(r, pn)}, nil
}
- // Fall back to default HTMX behavior
return structpages.HTMXRenderTarget(r, pn)
}),
)
-if err != nil {
- log.Fatal(err)
-}
```
-**Key insight:** No matter how you configure component selection (whether using the default `HTMXRenderTarget` or a custom selector), your Props method receives a `RenderTarget` that correctly identifies the selected component. Your Props code using `target.Is(component)` remains the same and works with any component selection strategy.
-
-This separation of concerns means:
-- ✅ You can change component selection logic without modifying Props
-- ✅ Props code is decoupled from HTMX request details
-- ✅ The pattern works whether requests come from HTMX, regular navigation, or custom clients
+The exact matching algorithm (including the authoritative pass against real generated ids) is documented in the [API reference](./api.md#htmxrendertarget).
-See `examples/htmx/main.go`, `examples/todo/main.go`, and `examples/htmx-render-target/` for complete working examples.
+## See also
+- `examples/htmx`, `examples/todo`, and `examples/htmx-render-target` in the [repository](https://github.com/jackielii/structpages/tree/main/examples) for complete working code.
+- [Error Handling](./error-handling.md) for HTMX-aware redirects (`HX-Location`).
diff --git a/docs/intro.md b/docs/intro.md
index 0f6a6c7..1b92358 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -17,9 +17,9 @@ sidebar_position: 1
If you've built a Go web app with `http.ServeMux`, you've written code like:
```go
-mux.HandleFunc("GET /products/{id}", handleProductGet)
-mux.HandleFunc("POST /products/{id}/comments", handleProductCommentCreate)
-mux.HandleFunc("DELETE /products/{id}/comments/{commentID}", handleProductCommentDelete)
+mux.HandleFunc("GET /products/{productId}", handleProductGet)
+mux.HandleFunc("POST /products/{productId}/comments", handleProductCommentCreate)
+mux.HandleFunc("DELETE /products/{productId}/comments/{commentId}", handleProductCommentDelete)
```
That works, but the route structure lives only in string literals. `structpages` lets you express the same hierarchy as Go structs, so the router and your handler types stay in lockstep:
@@ -30,14 +30,19 @@ type product struct {
}
type productPages struct {
- product `route:"/products/{id} Product"`
+ product `route:"/products/{productId} Product"`
}
```
-Each leaf struct implements a `Page()` method (typically via Templ), or `Props`, or `ServeHTTP` — whichever fits the request shape. URLs are generated by `URLFor(&product{})` rather than string concatenation, and the [`structpages-lint`](https://github.com/jackielii/structpages/tree/main/tools/lint) analyzer catches mismatches at build time.
+Each page renders through a **Props method** (loads data) and a **Page method** (a Templ component), or handles the request imperatively with a **handler method** (`ServeHTTP`) — whichever fits the request shape. URLs are generated by `structpages.URLFor(ctx, product{})` rather than string concatenation, and the [`structpages-lint`](./lint.md) analyzer catches dangling references at build time.
+
+## How a request flows
+
+For a rendering page: **route match → Props method** (with a `RenderTarget` injected to pick the region) **→ page component render** — the full `Page` for normal loads, or a single partial for HTMX requests targeting that region's id. A handler method (`ServeHTTP`) bypasses this pipeline and responds imperatively.
## What's next
- [Quick Start](./quick-start.md) — a minimal working example end-to-end.
+- [Concepts](./concepts.md) — the vocabulary: pages, page components, props, partials.
- [Routing](./routing.md) — full route tag syntax and nesting rules.
-- [Request Flows](./supported-flows.md) — how the dispatcher picks `Page` / `Content` / `Props` / `ServeHTTP`.
+- [Page Response Patterns](./supported-flows.md) — choosing between Props/Page and ServeHTTP shapes.
diff --git a/docs/lint.md b/docs/lint.md
new file mode 100644
index 0000000..e491cab
--- /dev/null
+++ b/docs/lint.md
@@ -0,0 +1,49 @@
+---
+title: Lint
+slug: /lint
+sidebar_position: 13
+---
+
+# structpages-lint
+
+`structpages-lint` is a static analyzer for structpages projects. It is the primary guard behind the rule of thumb: **never write an in-app URL as a string literal** — resolve it by page type so renames break CI instead of drifting.
+
+## Install and run
+
+```bash
+go install github.com/jackielii/structpages/tools/lint/cmd/structpages-lint@latest
+structpages-lint ./...
+```
+
+Wire it into CI alongside `go test`. If your project uses build tags, pass them through with `-tags`.
+
+## What it checks
+
+| Category | What it flags |
+|---|---|
+| `urlfor` | `structpages.URLFor` chain/composition errors — unknown child type, fragment-before-step, ambiguous bare lookups. |
+| `ref` | `structpages.Ref(...)` strings that don't resolve to a page-tree node — including refs stored in struct fields and vars (e.g. a nav table). |
+| `id`, `idtarget` | `structpages.ID` / `IDTarget` method expressions whose receiver is not mounted as a page. |
+| `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. Allows `https://`, `mailto:`, `#`, and protocol-relative `//…` externals. |
+| `route-literal` | `.go` string literals whose value exactly equals a mounted route — e.g. `return "/admin/queues"` — where you should resolve by page type via `URLFor`. Deliberately narrow: exact concrete-route matches only; comparisons (`==`/`switch`) and `Ref(...)` args are skipped; `_test.go` and generated files are skipped. |
+
+## Suppressing a diagnostic
+
+Place the directive on the same line or the line above. Prefer `//`-style in both `.go` and `.templ` — Go-style comments are stripped from generated HTML, while `` HTML comments render into every response:
+
+```go
+//structpages:lint:ignore route-literal
+return "/legacy-path"
+```
+
+```templ
+// structpages:lint:ignore url-attr
+…
+```
+
+Multiple categories are comma-separated; a bare `structpages:lint:ignore` suppresses every category on the targeted line.
+
+## What lint can't see
+
+Static analysis can't follow URLs assembled from runtime data or refs behind dynamic dispatch. For those, add a boot-time validation inventory — see [URLFor & ID → Validation](./urlfor.md#validation-no-dangling-urls-in-production).
diff --git a/docs/middleware.md b/docs/middleware.md
index 50cb3f4..4a156b0 100644
--- a/docs/middleware.md
+++ b/docs/middleware.md
@@ -1,6 +1,18 @@
+---
+title: Middleware
+slug: /middleware
+sidebar_position: 10
+---
+
# Middleware Usage
-### Global Middleware
+`MiddlewareFunc` is standard Go middleware that also receives the route's `*PageNode`, so middleware can inspect page metadata:
+
+```go
+type MiddlewareFunc func(next http.Handler, pn *structpages.PageNode) http.Handler
+```
+
+## Global middleware
Apply middleware to all routes:
@@ -17,25 +29,21 @@ if err != nil {
}
```
-### Page Middlewares
+## Page middlewares
Implement the `Middlewares()` method to add middleware to a specific page; it also applies to all descendant routes:
```go
-type protectedPage struct{
+type protectedPages struct {
// children pages will be protected
}
-func (p protectedPage) Middlewares() []structpages.MiddlewareFunc {
+func (p protectedPages) Middlewares() []structpages.MiddlewareFunc {
return []structpages.MiddlewareFunc{
requireAuth,
checkPermissions,
}
}
-
-templ (p protectedPage) Page() {
- ...
-}
```
`Middlewares()` can take injected dependencies (matched by type from `WithArgs`):
@@ -46,7 +54,7 @@ func (p protectedPages) Middlewares(sm *SessionManager) []structpages.Middleware
func(next http.Handler, pn *structpages.PageNode) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !sm.Exists(r.Context(), "user") {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
+ redirectToLogin(w, r)
return
}
next.ServeHTTP(w, r)
@@ -54,34 +62,40 @@ func (p protectedPages) Middlewares(sm *SessionManager) []structpages.Middleware
},
}
}
+
+// Middleware is outside the error-return path, so do the HTMX check here:
+// a 3xx during an HTMX request would be swapped into the partial's target.
+func redirectToLogin(w http.ResponseWriter, r *http.Request) {
+ loginURL, err := structpages.URLFor(r.Context(), loginPage{})
+ if err != nil {
+ // http.Error is acceptable here only because middleware sits outside
+ // structpages' error handling.
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("HX-Location", loginURL) // ajax navigation; status must stay 2xx
+ return
+ }
+ http.Redirect(w, r, loginURL, http.StatusSeeOther)
+}
```
-Example middleware implementation:
+Note the login URL comes from `URLFor`, not a string literal — when the login route moves, this middleware follows. Handler methods themselves should redirect via the [`Redirect` control-flow signal](./error-handling.md) instead; the inline check is only needed here because middleware runs outside the error-return path.
-```go
-// Authentication middleware that checks for a valid session
-func requireAuth(next http.Handler, pn *structpages.PageNode) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session")
- if session == nil {
- http.Redirect(w, r, "/login", http.StatusSeeOther)
- return
- }
- next.ServeHTTP(w, r)
- })
-}
+Example logging middleware using the `PageNode`:
-// Logging middleware that tracks page access
+```go
func loggingMiddleware(next http.Handler, pn *structpages.PageNode) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
- log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
+ log.Printf("%s %s (%s) took %v", r.Method, r.URL.Path, pn.Title, time.Since(start))
})
}
```
-### Middleware Execution Order
+## Middleware execution order
The framework prepends two implicit middlewares to every route, then layers the user-supplied chain on top. The final order, from outermost (runs first on the request, last on the response) to innermost:
@@ -91,7 +105,6 @@ The framework prepends two implicit middlewares to every route, then layers the
4. **Page-specific middlewares from `Middlewares()`** — accumulate down the page tree (parent's middlewares wrap children's).
5. **The page handler** — innermost.
-Middleware execution forms an "onion": the outermost middleware sees the request first and the response last. The `TestMiddlewareOrder` test in the codebase validates this behavior.
-
-Note: because of the auto-injected middlewares, you don't need to do anything to make `URLFor` and `ID`/`IDTarget` work from inside your handlers — the parse context and current-route params are already in `r.Context()`.
+Middleware execution forms an "onion": the outermost middleware sees the request first and the response last.
+Because of the auto-injected middlewares, you don't need to do anything to make `URLFor` and `ID`/`IDTarget` work from inside your handlers and middleware — the parse context and current-route params are already in `r.Context()`.
diff --git a/docs/quick-start.md b/docs/quick-start.md
index 9d782c5..c0fd89c 100644
--- a/docs/quick-start.md
+++ b/docs/quick-start.md
@@ -30,13 +30,15 @@ Create `pages.templ`:
```templ
package main
+import "github.com/jackielii/structpages"
+
type home struct{}
templ (home) Page() {
}
type pages struct {
- home `route:"/ Home"`
+ home `route:"/{$} Home"`
about `route:"/about About"`
}
```
+Two things to note:
+
+- **`/{$}` means "exactly `/`".** A bare `route:"/"` would be a ServeMux catch-all prefix that swallows every unmatched path; `/{$}` matches only the root.
+- **Links use `URLFor`, not string literals.** Templ attributes accept `(string, error)` directly, so `href={ structpages.URLFor(ctx, about{}) }` works as-is. When a route moves, every link follows — and [`structpages-lint`](./lint.md) flags any hard-coded internal path you write by accident.
+
## 3. Wire up `main.go`
```go
@@ -93,13 +100,14 @@ Open [http://localhost:8080](http://localhost:8080). You should see "Hello, stru
## What just happened
-- `pages` is a struct with two embedded fields, each tagged with a route. Embedding means promoted methods — but the dispatcher *skips* promoted methods, so the `Page()` defined on each inner type is the one that runs.
-- `Mount(mux, pages{}, "/", "Site")` walks the struct, registers each route on `mux`, and treats the outer `pages` struct as a layout with no `Page()` of its own.
-- Each request is dispatched to the matching leaf struct, which renders its `Page()` (a Templ component) into the response.
+- `pages` is a **page group** — a struct whose fields each carry a route tag, with no rendering of its own. Embedding means promoted methods, but the dispatcher *skips* promoted methods, so the `Page()` defined on each inner type is the one that runs.
+- `Mount(mux, pages{}, "/", "Site")` walks the struct and registers each route on `mux`.
+- Each request is dispatched to the matching **page**, which renders its **Page method** (a Templ component) into the response.
## Next steps
+- [Concepts](./concepts.md) for the vocabulary the rest of these docs use.
- [Routing](./routing.md) for the full tag syntax (HTTP methods, path params, titles).
-- [Templ Patterns](./templ.md) for shared layouts and the `Props` pattern.
+- [Templ Patterns](./templ.md) for shared layouts and the Props pattern.
- [HTMX Integration](./htmx.md) for partial rendering driven by `hx-target`.
- [Examples](./examples/index.md) for full working apps in `examples/` you can clone.
diff --git a/docs/routing.md b/docs/routing.md
index 70a8df1..653508d 100644
--- a/docs/routing.md
+++ b/docs/routing.md
@@ -1,65 +1,70 @@
-# Routing Patterns and Struct Tags
+---
+title: Routing
+slug: /routing
+sidebar_position: 4
+---
-### Basic Route Definition
+# Routing Patterns and Struct Tags
-Routes are defined using struct tags with the `route:` prefix. Each struct field with a route tag becomes a route in your application.
+Routes are struct fields with `route:` tags. Format: `route:"[METHOD] /path [Title]"`.
```go
type pages struct {
- home `route:"/ Home"` // ALL / with title "Home"
- about `route:"/about About Us"` // ALL /about with title "About Us"
- contact `route:"/contact"` // ALL /contact without title
+ home `route:"/{$} Home"` // exact root match
+ about `route:"/about About"` // all methods (default)
+ create `route:"POST /create Create"` // POST only
+ detail `route:"/item/{itemId} Item"` // path parameter
+ files `route:"/files/{path...} Files"` // wildcard
}
```
-### Route Tag Format
+## Route tag format
-The route tag supports several formats:
+1. **Path only**: `route:"/path"` — all HTTP methods, no page title.
+2. **Path with title**: `route:"/path Page Title"` — all methods, title "Page Title".
+3. **Method and path**: `route:"POST /path"` — POST only, no title.
+4. **Full format**: `route:"PUT /path Update Page"` — PUT only, title "Update Page".
-1. **Path only**: `route:"/path"`
- - Matches all HTTP methods
- - No page title
+Supported HTTP methods: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`. If no method is given, the route accepts all methods (internally stored as `ALL`).
-2. **Path with title**: `route:"/path Page Title"`
- - Matches all HTTP methods
- - Sets page title to "Page Title"
+Only the `route:` tag is read by the framework — any other tag on a route field is ignored.
-3. **Method and path**: `route:"POST /path"`
- - Matches only specified HTTP method
- - No page title
+## `/{$}` — exact match
-4. **Full format**: `route:"PUT /path Update Page"`
- - Matches only PUT requests
- - Sets page title to "Update Page"
+Go's ServeMux treats a trailing `/` as a prefix match: `route:"/"` would swallow every unmatched path under the mount point. Use `/{$}` for "exactly this path" — most commonly the index page of a [page group](./concepts.md):
-Supported HTTP methods: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`. A special `ALL` method can be used to match all methods.
+```go
+type adminPages struct {
+ dashboard `route:"/{$} Dashboard"` // matches /admin/ exactly
+ users `route:"/users Users"` // matches /admin/users
+}
+```
-### Path Parameters
+## Path parameters
-Path parameters use Go 1.22+ `http.ServeMux` syntax:
+Path parameters use Go 1.22+ `http.ServeMux` syntax. Extract them in the Props method via `r.PathValue` — they are not passed as function arguments:
```go
type pages struct {
- userProfile `route:"/users/{id} User Profile"`
+ userProfile `route:"/users/{userId} User Profile"`
blogPost `route:"/blog/{year}/{month}/{slug}"`
}
-// Access parameters in your Props method:
func (p userProfile) Props(r *http.Request) (UserProfileProps, error) {
- userID := r.PathValue("id") // "123" if URL is /users/123
- // Pass the userID via the props to the Page renderer
+ userID := r.PathValue("userId") // "123" if URL is /users/123
return UserProfileProps{UserID: userID}, nil
}
templ (p userProfile) Page(props UserProfileProps) {
@layout() {
User Profile for { props.UserID }
- // Render user details
}
}
```
-### Nested Routes
+**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}`.
+
+## Nested routes
Create hierarchical URL structures by nesting structs:
@@ -69,9 +74,44 @@ type pages struct {
}
type adminPages struct {
- dashboard `route:"/ Dashboard"` // Becomes /admin/
- users `route:"/users User List"` // Becomes /admin/users
- settings `route:"/settings Settings"` // Becomes /admin/settings
+ dashboard `route:"/{$} Dashboard"` // -> /admin/
+ users `route:"/users User List"` // -> /admin/users
+ settings `route:"/settings Settings"` // -> /admin/settings
}
```
+A struct like `adminPages` that has no render of its own — no `Page` or `ServeHTTP`, only child pages — is a **page group**. It is never served at its bare path; `/admin` 307-redirects to `/admin/`, which its `/{$}` page serves. `URLFor` on a page group returns the index child's URL with the canonical trailing slash (see [URLFor](./urlfor.md)).
+
+Children register before parents on the mux, so nested-route conflicts resolve correctly without you ordering anything by hand.
+
+## Wildcard routes and static assets
+
+Use the wildcard form for prefix subtrees — the framework joins nested paths with `path.Join`, which strips trailing slashes, so `route:"/static/"` would register as an exact match, not a prefix. `{path...}` is the right shape:
+
+```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
+
+type staticFiles struct{}
+
+func (staticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ root, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ http.ServeFileFS(w, r, root, r.PathValue("path"))
+}
+```
+
+This keeps the module self-contained: `/admin` and `/admin/static/*` register together, with no separate `mux.Handle` call to keep in sync.
+
+## Never write an in-app URL as a string literal
+
+Resolve URLs by page type — `structpages.URLFor(ctx, somePage{})` — so a moved route breaks the build (or the boot) instead of silently dangling. The [`structpages-lint`](./lint.md) `route-literal` check flags `.go` string literals that exactly equal a mounted route, and `url-attr` flags hard-coded paths in `.templ` URL attributes.
diff --git a/docs/supported-flows.md b/docs/supported-flows.md
index ff76d93..c6573d9 100644
--- a/docs/supported-flows.md
+++ b/docs/supported-flows.md
@@ -1,70 +1,33 @@
-# Supported Request Flows
-
-This document explains how structpages processes different types of requests and routes them to your components.
-
-## Quick Reference
-
-| Flow | When To Use | Entry Point | Component Selection | Example |
-|------|-------------|-------------|---------------------|---------|
-| **1** | Full control over request/response | `ServeHTTP(w, r)` | N/A | File uploads, WebSockets |
-| **2** | Actions that render components | `ServeHTTP(w, r) error` | Return `RenderComponent(method)` | Add todo → render todo list |
-| **3** | Actions with database/logger | `ServeHTTP(w, r, db, logger) error` (or no error return) | Return `RenderComponent(method)` | CRUD operations |
-| **4** | Standard pages with HTMX | `Props(r, target)` + component methods | Automatic via `HTMXRenderTarget` + `RenderTarget` | **Primary pattern** ⭐ |
-| **5** | Props-only pages | `Props(r, target)` returning `RenderComponent(...)` as error | Manual via `RenderComponent` | Pages without a `Page()` method |
-
-## Flow 4: Component-Based Rendering (Recommended)
-
-This is the primary way to build pages in structpages. It handles both full page loads and HTMX partial updates automatically.
-
-### How It Works
+---
+title: Page Response Patterns
+slug: /supported-flows
+sidebar_position: 5
+---
-```
-1. Request arrives
- ↓
-2. Component selection (HTMXRenderTarget produces a RenderTarget)
- ├─ Regular request → methodRenderTarget for "Page"
- ├─ HTMX request with HX-Target: "content" → methodRenderTarget for "Content"
- ├─ HTMX request with HX-Target: "index-todo-list" → methodRenderTarget for "TodoList"
- └─ HTMX request whose target matches no method → functionRenderTarget (resolved lazily in Props via target.Is(fn))
- ↓
-3. Props runs (with RenderTarget injected)
- - Knows which component will render
- - Returns appropriate data, or returns RenderComponent(...) as error to override
- ↓
-4. Component renders with props data
-```
+# Page Response Patterns
-### Request Type Variations
+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`).
-| Request Type | HX-Request | HX-Target | Selected Component | Props Receives | Use Case |
-|-------------|-----------|-----------|-------------------|----------------|----------|
-| **Browser navigation** | ❌ No | N/A | `Page` | `target.Is(index.Page) == true` | Initial page load |
-| **HTMX boost** | ✅ Yes | ❌ Empty | `Page` | `target.Is(index.Page) == true` | Progressive enhancement |
-| **Simple HTMX target** | ✅ Yes | `"content"` | `Content` | `target.Is(index.Content) == true` | Direct component name |
-| **ID/IDTarget** ⭐ | ✅ Yes | `"index-todo-list"` | `TodoList` | `target.Is(index.TodoList) == true` | **Primary pattern** |
-| **Unknown target** | ✅ Yes | `"nonexistent"` | `Page` (fallback) | `target.Is(index.Page) == true` | Graceful degradation |
+| Shape | Entry | When to use |
+|---|---|---|
+| **Renders a page** | `Props(...)` + `Page(props)` | Standard pages, HTMX partials — the primary pattern |
+| **Returns a partial** | `ServeHTTP(w, r, deps...) error` | Form actions that mutate then refresh a region |
+| **Redirects** | `ServeHTTP(w, r, deps...) error` | Post-action navigation |
+| **Serves JSON** | `ServeHTTP(w, r, deps...)` *(no error return)* | API endpoints |
-### Complete Example: Flow 4 with RenderTarget
+## A page that renders: Props method + Page method
```go
type index struct {
- add `route:"POST /add"`
+ add `route:"POST /add Add"`
}
-// Props knows which component will render via RenderTarget
-// (RenderTarget is an interface, no pointer)
func (p index) Props(r *http.Request, target structpages.RenderTarget) ([]Todo, error) {
switch {
- case target.Is(index.TodoList):
- // Only load active todos for TodoList component
- return getActiveTodos(), nil
-
- case target.Is(index.Page):
- // Load everything for full page
- return getAllTodos(), nil
-
+ case target.Is(p.TodoList):
+ return getActiveTodos(), nil // partial update — load only what it needs
default:
- return nil, nil
+ return getAllTodos(), nil // full page
}
}
@@ -88,24 +51,13 @@ templ (p index) TodoList(todos []Todo) {
}
```
-**What happens:**
-1. **Initial page load**: Browser requests `/` → Props gets `target.Is(index.Page) == true` → loads all todos → renders full page
-2. **Add todo via HTMX**: Form submits → Add handler runs → returns `RenderComponent(index.TodoList)` → Props gets `target.Is(index.TodoList) == true` → loads active todos → renders just TodoList component → HTMX swaps it in
+The Props method runs with a [`RenderTarget`](./htmx.md) injected, so it knows which page component will render and loads only that region's data. Initial loads render `Page`; HTMX requests targeting `index.TodoList`'s id render just the partial.
-**Benefits:**
-- ✅ Props efficiently loads only needed data
-- ✅ No duplicate component selection logic
-- ✅ Type-safe with compile-time checks
-- ✅ Zero configuration - works out of the box
+### Complex props structs
----
-
-### Complex Props Pattern (Real-World Pages)
-
-In real applications, the full page often needs complex props with many fields, while individual components only need a subset. Here's the recommended pattern:
+Real pages often need a props struct with many fields while individual page components take only a subset:
```go
-// Complex props structure for the full page
type IndexProps struct {
Users []User
Picklists []Picklist
@@ -113,264 +65,118 @@ type IndexProps struct {
TotalCount int
}
-type index struct {
- addUser `route:"POST /add-user"`
- searchUser `route:"GET /search"`
-}
-
-// Props returns the full IndexProps structure
-// This matches the Page component signature
func (p index) Props(r *http.Request, target structpages.RenderTarget) (IndexProps, error) {
switch {
- case target.Is(index.Page):
- // Full page load - get everything
+ case target.Is(p.UserList):
+ users, err := searchUsers(r.URL.Query().Get("q"))
+ if err != nil {
+ return IndexProps{}, err
+ }
+ return IndexProps{}, structpages.RenderComponent(p.UserList(users))
+
+ default: // full page
+ users, err := getAllUsers()
+ if err != nil {
+ return IndexProps{}, err
+ }
+ picklists, err := getPicklists()
+ if err != nil {
+ return IndexProps{}, err
+ }
return IndexProps{
- Users: getAllUsers(),
- Picklists: getPicklists(),
+ Users: users,
+ Picklists: picklists,
Search: r.URL.Query().Get("q"),
- TotalCount: getUserCount(),
+ TotalCount: len(users),
}, nil
-
- default:
- // For action handlers, return minimal data
- // The action will use RenderComponent to select what to render
- return IndexProps{}, nil
}
}
-
-// Page component receives the full props
-templ (p index) Page(props IndexProps) {
- @html() {
-
-
- Total: { strconv.Itoa(props.TotalCount) }
-
-
-
- @p.UserList(props.Users)
-
-
-
- @p.PicklistDropdown(props.Picklists)
-
- }
-}
-
-// Individual components receive only what they need
-templ (p index) UserList(users []User) {
- for _, user := range users {
-
{ user.Name }
- }
-}
-
-templ (p index) PicklistDropdown(picklists []Picklist) {
-
-}
-
-// Action handler: extract specific data and render specific component
-func (a addUser) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
- name := r.FormValue("name")
-
- // Add the user
- addUser(name)
-
- // Get fresh user list
- users := getAllUsers()
-
- // Render just the UserList component with only the users data
- return structpages.RenderComponent(index.UserList, users)
-}
-
-// Search handler: dynamically load data and render
-func (s searchUser) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
- query := r.URL.Query().Get("q")
-
- // Search users
- users := searchUsers(query)
-
- // Render UserList with search results
- return structpages.RenderComponent(index.UserList, users)
-}
-```
-
-**Key Points:**
-
-1. **Props returns full structure**: The `Props` method returns `IndexProps` to match the `Page` component signature
-2. **Components receive subsets**: Individual components like `UserList([]User)` receive only what they need
-3. **Action handlers use RenderComponent**: When rendering a specific component, extract the needed data and pass it to `RenderComponent`
-4. **Type safety maintained**: The component signatures enforce what data is needed at compile time
-
-**When to use this pattern:**
-- ✅ Complex pages with multiple sections/components
-- ✅ Different components need different subsets of data
-- ✅ Action handlers that update specific parts of the page
-- ✅ Dynamic data loading (search, filters, pagination)
-
-**Why it works:**
-- Props and Page stay in sync (both use `IndexProps`)
-- Components are reusable with simple signatures
-- Action handlers have full control over what to render
-- No need to return different types from Props for different components
-
----
-
-## Other Flows (Advanced Usage)
-
-### Flow 1: Standard `http.Handler`
-```
-Request → ServeHTTP(w, r) → Page writes response directly
-```
-
-**When to use:** Need complete control over the response (WebSockets, SSE, file downloads)
-
-```go
-type fileUpload struct{}
-
-func (f fileUpload) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- file, header, _ := r.FormFile("upload")
- defer file.Close()
- // Process file...
- fmt.Fprintf(w, "Uploaded: %s", header.Filename)
-}
```
----
+`p.UserList(users)` is a normal Go call — compile-time checked — handed to `RenderComponent` as the response. Partial page components take ONLY their specific data (`UserList([]User)`), never the full props struct.
-### Flow 2: Action Handlers That Render
+## A handler method that returns a partial
-**When to use:** Form submissions, button clicks that need to render a component
+The most common HTMX form action — mutate state, respond with the refreshed region:
```go
type add struct{}
func (a add) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
text := r.FormValue("text")
- if text == "" {
- return fmt.Errorf("text is required")
+ if text != "" {
+ if err := addTodo(text); err != nil {
+ return err
+ }
}
- addTodo(text)
-
- // Tell structpages to render the TodoList component
- return structpages.RenderComponent(index.TodoList)
+ todos, err := getActiveTodos()
+ if err != nil {
+ return err
+ }
+ return structpages.RenderComponent(index{}.TodoList(todos))
}
```
-**Flow:**
-- ✅ Perform action (add todo)
-- ✅ Return `RenderComponent(method)` to render a component
-- ✅ Props runs with `RenderTarget` for that component
-- ✅ Component renders with fresh data
-
----
+**Pass a constructed component.** 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 [HTMX Integration](./htmx.md).
-### Flow 3: Action Handlers With Dependencies
+## A handler method that redirects
-**When to use:** Actions that need database, logger, or other services
+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, 303 otherwise):
```go
-type userManager struct{}
-
-func (u userManager) ServeHTTP(w http.ResponseWriter, r *http.Request,
- db *sql.DB, logger *Logger) error {
- id := r.PathValue("id")
-
- // Use injected dependencies
- user, err := db.QueryUser(id)
+func (p submitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error {
+ id, err := store.Save(r.Context(), r.FormValue("name"))
if err != nil {
- logger.Error("Failed to query user", err)
return err
}
-
- // Render user list component
- return structpages.RenderComponent(userManager.UserList)
-}
-
-// Pass dependencies when mounting pages
-mux := http.NewServeMux()
-sp, err := structpages.Mount(mux, pages{}, "/", "App",
- structpages.WithArgs(db, logger),
-)
-if err != nil {
- log.Fatal(err)
+ url, err := structpages.URLFor(r.Context(), detailPage{}, map[string]any{"itemId": id})
+ if err != nil {
+ return err
+ }
+ return Redirect{To: url}
}
```
-**Same as Flow 2, but with injected dependencies available**
+The `Redirect` type and the error-handler wiring are covered in [Error Handling](./error-handling.md).
----
-
-## Key Takeaways
-
-### RenderTarget Benefits
+## A handler method that serves JSON
-Props receives RenderTarget to know which component will render:
+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:
```go
-func (p index) Props(r *http.Request, target structpages.RenderTarget) (any, error) {
- switch {
- case target.Is(index.TodoList):
- return getTodos(), nil
- case target.Is(index.Page):
- return getAllData(), nil
+type trackTime struct{}
+
+func (p trackTime) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) {
+ var body trackTimeRequest
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "invalid request")
+ return
+ }
+ if err := store.UpdateTime(r.Context(), body); err != nil {
+ writeJSONError(w, http.StatusInternalServerError, "update failed")
+ return
}
- return nil, nil
+ w.WriteHeader(http.StatusOK)
}
```
-**Benefits:**
-- Component selection happens once (before Props)
-- Type-safe method expressions
-- Efficient data loading (load only what's needed)
-- Refactoring-safe (compile-time checks)
+See [Error Handling](./error-handling.md) for `writeJSONError` and why `http.Error` is the wrong tool here.
-### Execution Order
+## ServeHTTP signatures
-Flow 4 executes in this order:
-```
-1. targetSelector(r, pn) → returns a RenderTarget (HTMXRenderTarget by default)
- - For methods: methodRenderTarget captures the method at construction
- - For unmatched HX-Target strings: functionRenderTarget defers matching until target.Is(fn)
-2. Props(r, target, ...) → receives RenderTarget, returns data
- (or returns RenderComponent(...) as error to override)
-3. Component renders with data from Props
-```
-
-**Key insight:** Component selection happens BEFORE Props runs, so Props knows what it's loading data for. For function components, the actual function value is bound when Props calls `target.Is(fn)` — this is why function-target `RenderComponent(target, args...)` requires `Is()` to have been called first.
-
-### Props Override Pattern
-
-Props can override the selected component:
+Four signatures are supported. The DI forms take typed params (matched by type, any order) — there is no variadic `deps ...any`:
```go
-func (p search) Props(r *http.Request, target structpages.RenderTarget) ([]Result, error) {
- query := r.URL.Query().Get("q")
-
- // Override component selection based on logic
- if query == "" {
- return nil, structpages.RenderComponent(search.EmptyState)
- }
-
- // Normal flow uses RenderTarget
- switch {
- case target.Is(search.Results):
- return performSearch(query), nil
- case target.Is(search.Page):
- return performSearch(query), nil
- }
- return nil, nil
-}
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request) // standard http.Handler, unbuffered
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request) error // buffered; error → WithErrorHandler
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) // DI, no return, unbuffered
+func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error // DI, buffered
```
----
+The error-returning forms run against a *buffered* writer so the error handler can discard partial output and render a clean error page. That has consequences — never write `w` then return an error. The full rules are in [Error Handling](./error-handling.md).
-## See Also
+## See also
-- [HTMX Integration](./htmx.md) - RenderTarget and component selection
-- [URLFor & ID generation](./urlfor.md) - Type-safe URL and ID generation
-- [examples/todo](../examples/todo) - Complete working example
+- [HTMX Integration](./htmx.md) — `RenderTarget`, partial selection, the id loop.
+- [Error Handling](./error-handling.md) — buffering rules, typed errors, redirects, JSON.
+- [examples/todo](https://github.com/jackielii/structpages/tree/main/examples/todo) — complete working example.
diff --git a/docs/templ.md b/docs/templ.md
index b3c921a..850f144 100644
--- a/docs/templ.md
+++ b/docs/templ.md
@@ -1,12 +1,18 @@
+---
+title: Templ Patterns
+slug: /templ
+sidebar_position: 6
+---
+
# Templ Patterns
-### Basic Page Pattern
+## Page components and layout composition
+
+A **page component** is a templ method on a page struct. A **component** is a standalone templ block. Layouts are nothing special — just a component that takes `{ children... }`:
```templ
-// Define your page struct
type homePage struct{}
-// Implement the Page method returning a templ component
templ (h homePage) Page() {
@layout() {
Welcome Home
@@ -14,7 +20,7 @@ templ (h homePage) Page() {
}
}
-// Shared layout component
+// Shared layout — a plain component with children
templ layout() {
@@ -28,42 +34,42 @@ templ layout() {
}
```
-### Props Pattern
+## The Props pattern
-Pass data to your components using typed Props:
+The **Props method** loads data; the **props struct** it returns flows into the page components:
```go
type productPage struct{}
-// Define typed props for better type safety
type productPageProps struct {
- Product Product
+ Product Product
RelatedProducts []Product
- IsInStock bool
+ IsInStock bool
}
-// Props method returns typed props and can receive injected dependencies
-// You can also include http.ResponseWriter to set headers, cookies, etc.
+// Parameters are matched by type via DI. http.ResponseWriter is injectable
+// too — useful for setting headers or cookies before the render.
func (p productPage) Props(r *http.Request, w http.ResponseWriter, store *Store) (productPageProps, error) {
- productID := r.PathValue("id")
+ productID := r.PathValue("productId")
product, err := store.LoadProduct(productID)
if err != nil {
return productPageProps{}, err
}
-
- // You can manipulate the response if needed
+
w.Header().Set("X-Product-ID", productID)
-
- related, _ := store.LoadRelatedProducts(productID)
-
+
+ related, err := store.LoadRelatedProducts(productID)
+ if err != nil {
+ return productPageProps{}, err
+ }
+
return productPageProps{
- Product: product,
+ Product: product,
RelatedProducts: related,
- IsInStock: product.Stock > 0,
+ IsInStock: product.Stock > 0,
}, nil
}
-// Page method receives typed props
templ (p productPage) Page(props productPageProps) {
@layout() {
{ props.Product.Name }
@@ -78,92 +84,95 @@ templ (p productPage) Page(props productPageProps) {
}
```
-#### Props Method Resolution
+Never write the response body or call `http.Error` inside Props — it runs against a buffered writer and a returned error discards the buffer. Return the error and let the global handler render it (see [Error Handling](./error-handling.md)).
-Only the method literally named `Props` is auto-invoked by the framework. Methods whose names *end* in `Props` (e.g. `UserListProps`, `PageProps`, `ContentProps`) are stored in the page node but **not** auto-resolved — they are conventional helpers you call yourself from inside `Props`. The earlier per-component-Props auto-resolution (`PageProps()`, `ContentProps()`, etc.) was removed in favor of the simpler `Props` + `RenderComponent` pattern below.
+### Props method resolution
-**Parameter resolution is by type, not position.** All of these signatures work and the framework matches each parameter by its type:
+Only the method literally named `Props` is auto-invoked by the framework. Methods whose names *end* in `Props` (e.g. `UserListProps`, `ContentProps`) are **not** auto-resolved — they are conventional helpers you call yourself from inside `Props`.
+
+**Parameter resolution is by type, not position.** All of these signatures work:
```go
-func (d dashboardPage) Props(r *http.Request, store *Store) (DashboardData, error) { ... }
-func (d dashboardPage) Props(store *Store, r *http.Request) (DashboardData, error) { ... } // any order
-func (d dashboardPage) Props(r *http.Request, w http.ResponseWriter, store *Store) (DashboardData, error) { ... }
-func (d dashboardPage) Props(r *http.Request, target structpages.RenderTarget, store *Store) (DashboardData, error) { ... }
+func (d dashboardPage) Props(r *http.Request, store *Store) (DashboardData, error)
+func (d dashboardPage) Props(store *Store, r *http.Request) (DashboardData, error) // any order
+func (d dashboardPage) Props(r *http.Request, w http.ResponseWriter, store *Store) (DashboardData, error)
+func (d dashboardPage) Props(r *http.Request, target structpages.RenderTarget, store *Store) (DashboardData, error)
```
-The injectable types are: `*http.Request`, `http.ResponseWriter`, `structpages.RenderTarget`, `*structpages.PageNode`, and any type registered via `WithArgs`. Position doesn't matter — the framework fills each parameter by looking up its type.
+The injectable types are: `*http.Request`, `http.ResponseWriter`, `structpages.RenderTarget`, `*structpages.PageNode`, and any type registered via `WithArgs`.
+
+## Partials load only their data
-To run different data-loading paths for different components, use the `RenderTarget` parameter and `RenderComponent`:
+When a page has independently-updatable regions, inject `RenderTarget` and branch with `target.Is`. **Construct the component and hand it to `RenderComponent`** — a normal Go call the compiler checks:
```go
type dashboardPage struct{}
-func (d dashboardPage) Props(r *http.Request, w http.ResponseWriter, target structpages.RenderTarget, store *Store) (DashboardData, error) {
- // Full page or full content — load everything
- if target.Is(d.Page) || target.Is(d.Content) {
- http.SetCookie(w, &http.Cookie{Name: "dashboard_visited", Value: "true"})
- return DashboardData{User: store.GetUser(r), Stats: store.GetStats()}, nil
+func (d dashboardPage) Props(r *http.Request, target structpages.RenderTarget, store *Store) (DashboardData, error) {
+ if target.Is(d.StatsWidget) {
+ stats, err := store.GetStats(r.Context())
+ if err != nil {
+ return DashboardData{}, err
+ }
+ return DashboardData{}, structpages.RenderComponent(d.StatsWidget(stats))
+ }
+ // Full page — load everything
+ user, err := store.GetUser(r)
+ if err != nil {
+ return DashboardData{}, err
}
- // HTMX partial — render just the stats widget
- if target.Is(d.Content) {
- return DashboardData{}, structpages.RenderComponent(d.Content, ContentData{Stats: store.GetStats()})
+ stats, err := store.GetStats(r.Context())
+ if err != nil {
+ return DashboardData{}, err
}
- return DashboardData{}, nil
+ return DashboardData{User: user, Stats: stats}, nil
}
```
-### Cross-Page Component Rendering
+Partial page components take ONLY their specific data (`StatsWidget(stats Stats)`), not the full props struct. The full pattern, including how `HX-Target` selects the partial, is in [HTMX Integration](./htmx.md).
-Structpages provides the ability to render components from different pages using the `RenderComponent` function. This is useful when you want to conditionally redirect rendering to a component from another page, such as error pages or shared components.
+## Cross-page rendering
-#### RenderComponent
-
-The `RenderComponent` function can be used in Props methods to render a component from a different page:
+A handler method that needs to respond with *another* page's component constructs it the same way — page structs are stateless, so a zero-value receiver works:
```go
-type errorPage struct{}
-
-templ (e errorPage) ErrorComponent(message string) {
-
-
Error
-
{ message }
-
-}
-
-type productPage struct{}
-
-func (p productPage) Props(r *http.Request, store *Store) (string, error) {
- productID := r.PathValue("id")
- product, err := store.LoadProduct(productID)
+func (a addTodo) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error {
+ if err := store.Add(r.Context(), r.FormValue("text")); err != nil {
+ return err
+ }
+ todos, err := store.List(r.Context())
if err != nil {
- // Instead of returning an error, render the ErrorComponent from errorPage
- return "", structpages.RenderComponent(errorPage{}.ErrorComponent, "Product not found")
+ return err
}
- return product.Name, nil
-}
-
-templ (p productPage) Page(productName string) {
-
{ productName }
-
Product details...
+ return structpages.RenderComponent(index{}.TodoList(todos))
}
```
-#### Parameters
+The reflective method-expression form — `RenderComponent(index.TodoList)` with no constructed component — exists for page components whose parameters the framework should DI-inject rather than you supplying them. Prefer direct construction whenever you're loading the data yourself anyway.
-- `targetOrMethod`: A method expression (e.g., `errorPage{}.ErrorComponent`) or RenderTarget
-- `args`: Optional arguments to pass to the component method (these replace the original Props return values)
+## Testing renders with a bare context
-#### Behavior
+Unit tests that render templ components directly — without an HTTP server — need a page tree in the context so `URLFor` / `ID` / `IDTarget` resolve. Use `structpages.Parse` (builds the tree, no mux) and `sp.PageContext`:
-When `RenderComponent` is returned as an error from a Props method:
-
-1. The framework resolves the method expression to the component
-2. Calls the component with the provided arguments
-3. Renders the component instead of the original page's component
+```go
+func TestProductPageRenders(t *testing.T) {
+ sp, err := structpages.Parse(pages{}, "/", "App",
+ structpages.WithArgs(fakeStore),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sp.PageContext(context.Background())
-This pattern is particularly useful for:
-- Error handling and displaying error pages
-- Conditional rendering based on authentication or permissions
-- Redirecting to maintenance or unavailable pages
-- Sharing common components across different pages
+ buf := &bytes.Buffer{}
+ props := productPageProps{Product: sampleProduct}
+ if err := (productPage{}).Page(props).Render(ctx, buf); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(buf.String(), sampleProduct.Name) {
+ t.Errorf("rendered page missing product name")
+ }
+}
+```
+`Parse` accepts the same options as `Mount`; mux-shaped options (middlewares) are accepted but inert since no handlers register.
diff --git a/docs/urlfor.md b/docs/urlfor.md
index 99b71dd..3737342 100644
--- a/docs/urlfor.md
+++ b/docs/urlfor.md
@@ -1,123 +1,160 @@
---
title: URLFor & ID
slug: /urlfor
-sidebar_position: 5
+sidebar_position: 8
---
# URLFor & ID Generation
-`structpages` provides four related helpers for generating URLs and DOM identifiers that stay in sync with the route tree:
+structpages provides type-safe helpers for generating URLs and DOM identifiers that stay in sync with the route tree:
-- **`URLFor(target)`** — build a URL for a page from its struct (pointer to a leaf, or `Ref`).
-- **`Ref{...}`** — dynamic reference for cases the static type lookup can't handle.
-- **`ID(target)`** — raw HTML `id` attribute string for a page or component.
-- **`IDTarget(target)`** — `#`-prefixed CSS selector for HTMX `hx-target`.
+- **`URLFor(ctx, page, params)`** — build a URL for a page from its type.
+- **`Ref("Parent.Field")`** — string reference for cases the static type lookup can't handle.
+- **`ID(ctx, Page.Method)`** — raw HTML `id` attribute for a page component.
+- **`IDTarget(ctx, Page.Method)`** — `#`-prefixed CSS selector for HTMX `hx-target`.
-All four are checked at build time by the [`structpages-lint`](../tools/lint) analyzer.
+All are validated by the [`structpages-lint`](./lint.md) analyzer. **The rule of thumb: never write an in-app URL as a string literal** — resolve it by page type so the literal can't drift when routes move.
## URLFor
-Generate a URL by passing a pointer to the target page struct:
+`structpages.URLFor(ctx, page, args...)` returns `(string, error)`. Templ attribute values accept `(string, error)` directly:
-```go
-href := structpages.URLFor(ctx, &productPage{})
-// → "/products"
+```templ
+Link
+Detail
+