Skip to content

structpages ​

go
import "github.com/jackielii/structpages"

Package structpages provides a way to define routing using struct tags and methods. It integrates with the [http.ServeMux], allowing you to quickly build up pages and components without too much boilerplate.

Index ​

Variables ​

ErrSkipPageRender is a sentinel error that can be returned from a Props method to indicate that the page rendering should be skipped. This is useful for implementing conditional rendering or redirects within page logic.

go
var ErrSkipPageRender = errors.New("skip page render")

func ID ​

go
func ID(ctx context.Context, v any) (string, error)

ID generates a raw HTML ID for a component method (without "#" prefix). Use this for HTML id attributes.

Parameters:

  • ctx: Context containing parseContext (required for method expressions and Ref)
  • v: One of:
  • Method expression (p.UserList) - generates ID from page and method name
  • Ref type (structpages.Ref("PageName.MethodName")) - looks up page/method dynamically
  • Plain string ("my-custom-id") - returned as-is

Example:

<div id={ structpages.ID(ctx, p.UserList) }>
// → <div id="team-management-view-user-list">  (page-path prefix)

<div id={ structpages.ID(ctx, dashboard.StatsWidget) }>
// → <div id="dashboard-stats-widget">  (PACKAGE prefix — see below)

<div id={ structpages.ID(ctx, "my-custom-id") }>
// → <div id="my-custom-id">

Prefer ID over hardcoding an id string — and that matters most for a standalone (shared) function component. A method id is prefixed by its page's path; a standalone function has no page position, so it is prefixed by its PACKAGE name instead ("dashboard-stats-widget", not "stats-widget"). That package prefix is the point: it makes the id globally unique, so two packages can each expose a "Mount"/"Widget" component without their ids colliding. When you give a shared element a render-target identity this way — e.g. a singleton overlay slot's id = ID(ctx, overlay.Mount) — RenderTarget.Is(overlay.Mount) matches it exactly (see RenderTarget), and the pairing stays correct without anyone depending on the id's internal format. Hardcoding the string forfeits both the uniqueness guarantee and that ID/Is symmetry.

Returns an error if parseContext is not found in the provided context.

func IDTarget ​

go
func IDTarget(ctx context.Context, v any) (string, error)

IDTarget generates a CSS selector (with "#" prefix) for a component method. Use this for HTMX hx-target attributes.

Parameters:

  • ctx: Context containing parseContext (required for method expressions and Ref)
  • v: One of:
  • Method expression (p.UserList) - generates selector from page and method name
  • Ref type (structpages.Ref("PageName.MethodName")) - looks up page/method dynamically
  • string ("body" or "#my-custom-id") - returned as-is

Example:

<button hx-target={ structpages.IDTarget(ctx, p.UserList) }>
// → <button hx-target="#team-management-view-user-list">

<button hx-target={ structpages.IDTarget(ctx, UserStatsWidget) }>
// → <button hx-target="#user-stats-widget"> (no page prefix for standalone functions)

<button hx-target={ structpages.IDTarget(ctx, "body") }>
// → <button hx-target="body">

Returns an error if parseContext is not found in the provided context.

func RenderComponent ​

go
func RenderComponent(comp interface {
    Render(context.Context, io.Writer) error
}, more ...interface {
    Render(context.Context, io.Writer) error
}) error

RenderComponent returns an error that breaks Props' normal flow: instead of the component the request wants, the framework renders comp, followed by more in order, as one response. Return it from Props or an error-returning ServeHTTP:

func (p DashboardPage) Props(t RenderTarget, s *Store) (Stats, error) {
	if t.Is(p.Notes) {
		notes, err := s.Notes()
		if err != nil {
			return Stats{}, RenderComponent(p.NotesUnavailable(err))
		}
	}
	…
}

Several components make one response that updates several regions, for example the main swap plus htmx 4 <hx-partial> blocks:

return Stats{}, RenderComponent(p.Notes(notes), p.NotesCount(len(notes)))

Each argument is any value with Render(ctx, w): a gsx.Node, a templ.Component, or a page's component called with its arguments, such as p.TodoList(todos). Building them at the call site keeps their arguments type-checked.

func URLFor ​

go
func URLFor(ctx context.Context, page any, args ...any) (string, error)

URLFor returns the URL for a given page type. If args is provided, it'll replace the path segments. Supported format is similar to http.ServeMux.

The page argument accepts:

  • a typed page value: URLFor(ctx, Page{}, ...)
  • a top-level string (sugar for Ref): URLFor(ctx, "Admin.Settings", ...) resolves the same as URLFor(ctx, Ref("Admin.Settings"), ...). Useful at call sites that can't import the target page (cross-package cycle).
  • a Ref: URLFor(ctx, Ref("PageName"), ...)
  • a []any composition (chain + optional URL fragments): see below.
  • a func(*PageNode) bool predicate for unusual lookups.

Type-based lookup is strict: if the same page type is mounted under multiple parents, URLFor returns an error listing every match instead of silently choosing one. Disambiguate with the []any chain form (recommended; type-safe), Ref("Parent.Field") for cross-package callers, or a func(*PageNode) bool predicate.

Path and query parameters are passed via a map[string]any (preferred — explicit and resilient to route changes):

URLFor(ctx, Page{}, map[string]any{"id": 42})
URLFor(ctx, []any{Page{}, "?foo={bar}"}, map[string]any{"bar": "baz"})

Positional and key/value-pairs forms also work (see formatPathSegments in url_for.go for the full detection order) but require the call site to track parameter position or name conventions.

Pass values unescaped: each is escaped for the part of the URL its placeholder sits in (path, query or fragment), so "A&B" in "?q={q}" reads back as "A&B". Template strings are used as is.

You can pass []any as the page to join multiple path segments together — strings are concatenated as-is, which is the form used to append a query-string template to a typed page lookup. You can also pass a func(*PageNode) bool predicate to match a specific page when type-based lookup isn't enough.

func WithArgs ​

go
func WithArgs(args ...any) func(*StructPages)

WithArgs adds global dependency injection arguments that will be available to all page methods (Props, Middlewares, ServeHTTP etc.).

func WithErrorHandler ​

go
func WithErrorHandler(onError func(http.ResponseWriter, *http.Request, error)) func(*StructPages)

WithErrorHandler sets a custom error handler function that will be called when an error occurs during page rendering or request handling. If not set, a default handler returns a generic "Internal Server Error" response.

func WithMaxIDLength ​

go
func WithMaxIDLength(n int) func(*StructPages)

WithMaxIDLength sets the character budget for generated element ids (ID/IDTarget) before they degrade from the readable full-path form (every ancestor field name joined, e.g. "admin-users-user-list") to the compact leaf-only form (e.g. "user-list", plus a short stable hash suffix when the leaf name is not unique in the tree). The default is 40.

A larger value keeps deeper ids fully readable at the cost of length; a smaller value favors shorter ids. The value only affects id generation, never routing.

func WithMiddlewares ​

go
func WithMiddlewares(middlewares ...MiddlewareFunc) func(*StructPages)

WithMiddlewares adds global middleware functions that will be applied to all routes. Middleware is executed in the order provided, with the first middleware being the outermost handler. These global middlewares run before any page-specific middlewares.

func WithRequestArgs ​

go
func WithRequestArgs(providers ...any) func(*StructPages)

WithRequestArgs registers per-request values for the whole mount. Each provider is a func(*http.Request) (T, error) or func(*http.Request, *PageNode) (T, error); its result fills parameters of type T in Props, extended ServeHTTP and component methods, matched by the rules WithArgs uses. A provider runs at most once per request, the first time a parameter needs it, and its error stops the request like an error from Props. Mount fails if a provider has another shape, or if two providers return the same type.

A page's RequestArgs method registers providers for that page and the pages below it; they shadow mount-wide providers of the same type.

sp, err := structpages.Mount(mux, pages{}, "/", "App",
    structpages.WithRequestArgs(currentUser), // func(*http.Request) (*User, error)
)

func (p accountPage) Props(u *User) (accountProps, error) { … }

func WithTargetSelector ​

go
func WithTargetSelector(selector TargetSelector) func(*StructPages)

WithTargetSelector sets the TargetSelector that reads what each request wants. Its RenderTarget is passed to Props, and when Props returns no error the page's component the target Is renders, or Page if none.

Without this option every request wants Page. For htmx, choose the selector for your htmx version:

structpages.WithTargetSelector(structpages.HTMXRenderTarget)   // htmx 1.x and 2.x
structpages.WithTargetSelector(structpages.HTMXv4RenderTarget) // htmx 4

A custom selector returns any RenderTarget; the framework only calls its Is method. For example, a selector reading a query parameter:

structpages.WithTargetSelector(func(r *http.Request, pn *structpages.PageNode) (structpages.RenderTarget, error) {
    return partialParam(r.URL.Query().Get("partial")), nil
})

func WithURLPrefix ​

go
func WithURLPrefix(prefix string) func(*StructPages)

WithURLPrefix tells structpages that it is being served behind a path prefix that has been stripped before requests reach the registered routes (for example by http.StripPrefix or an upstream reverse proxy). The prefix is prepended to every URL returned by URLFor — both the *StructPages.URLFor method and the request-context URLFor function — so generated URLs point at the externally visible path.

This option only affects URL generation. Route registration is still controlled by the route argument to Mount.

Example: app registered at "/" internally, served at "/admin" externally:

inner := http.NewServeMux()
sp, _ := structpages.Mount(inner, pages{}, "/", "App",
    structpages.WithURLPrefix("/admin"))
outer := http.NewServeMux()
outer.Handle("/admin/", http.StripPrefix("/admin", inner))
// sp.URLFor(home{}) now returns "/admin"

func WithWarnEmptyRoute ​

go
func WithWarnEmptyRoute(warnFunc func(*PageNode)) func(*StructPages)

WithWarnEmptyRoute sets a custom warning function for pages that have neither a handler method nor children. These pages are automatically skipped during route registration. If warnFunc is nil, a default warning message is printed to stdout. Set warnFunc to a no-op function to suppress warnings entirely.

Example usage:

// Use default warning (prints to stdout)
sp := structpages.Mount(
	http.NewServeMux(), index{}, "/", "App",
	structpages.WithWarnEmptyRoute(nil),
)

// Custom warning function
sp := structpages.Mount(
	http.NewServeMux(), index{}, "/", "App",
	structpages.WithWarnEmptyRoute(func(pn *PageNode) {
		log.Printf("Skipping empty page: %s", pn.Name)
	}),
)

// Suppress warnings entirely
sp := structpages.Mount(
	http.NewServeMux(), index{}, "/", "App",
	structpages.WithWarnEmptyRoute(func(*PageNode) {}),
)

func Within ​

go
func Within(ctx context.Context, page any) (bool, error)

Within reports whether the page being rendered is page or one of its descendants. It answers the active-navigation question "is the current page under this nav target?" without matching URL prefixes, so a target that is a group (a struct whose fields are routes) matches every page below it, and a target whose route has path parameters needs no args.

page takes the forms URLFor takes for its page argument: a page value, a Ref, a top-level string as Ref sugar, a []any chain of page steps, or a func(*PageNode) bool predicate. A chain may not carry string fragments, since Within compares pages, not URLs.

Pages are compared by node, so an index leaf (`route:"/{$}"`) and the group it sits in stay distinct: Within(ctx, Dashboard{}) holds only on the dashboard, while Within(ctx, Manage{}) holds on every page under Manage.

Outside a page render — no CurrentPage in ctx — it returns false and no error. A page that does not resolve is an error, so a renamed nav target fails the render instead of quietly never matching.

active, err := structpages.Within(ctx, structpages.Ref("Feedback.Manage"))

type MiddlewareFunc ​

MiddlewareFunc is a function that wraps an http.Handler with additional functionality. It receives both the handler to wrap and the PageNode being handled, allowing middleware to access page metadata like route, title, and other properties.

go
type MiddlewareFunc func(http.Handler, *PageNode) http.Handler

type Mux ​

Mux represents any HTTP router that can register handlers using the Handle method. This interface is satisfied by http.ServeMux and must follow the same pattern support for route registration.

go
type Mux interface {
    Handle(pattern string, handler http.Handler)
}

type Option ​

Option represents a configuration option for StructPages.

go
type Option func(*StructPages)

type PageNode ​

PageNode represents a page in the routing tree. It contains metadata about the page including its route, title, and registered methods. PageNodes form a tree structure with parent-child relationships representing nested routes.

go
type PageNode struct {
    Name   string
    Title  string
    Method string
    Route  string

    Value       reflect.Value
    Props       map[string]reflect.Method
    Components  map[string]reflect.Method
    Middlewares *reflect.Method
    Parent      *PageNode
    Children    []*PageNode
    // contains filtered or unexported fields
}

func CurrentPage ​

go
func CurrentPage(ctx context.Context) *PageNode

CurrentPage returns the PageNode of the route currently being served, or nil when ctx did not originate from a structpages request — for example a bare context, or one wrapped only by StructPages.PageContext in a test.

structpages sets this on the request context before rendering a matched Props/Component page, so a handler, a Props method, or any templ component they render can ask "which page am I?" without threading the node through every call. The node is the matched leaf; walk [PageNode.Parent] to reach its mount ancestors (e.g. shared layout chrome computing active-nav state by testing whether a nav target is an ancestor of the current page).

Pages served by their own ServeHTTP do not set this — the value is for the component/Props render path.

func (*PageNode) All ​

go
func (pn *PageNode) All() iter.Seq[*PageNode]

All returns an iterator that walks through this PageNode and all its descendants in depth-first order. This is useful for traversing the entire page tree.

Example:

for node := range pageNode.All() {
    fmt.Println(node.FullRoute())
}

func (*PageNode) FullRoute ​

go
func (pn *PageNode) FullRoute() string

FullRoute returns the complete route path for this page node, including all parent routes. For example, if a parent has route "/admin" and this node has route "/users", FullRoute returns "/admin/users".

func (PageNode) String ​

go
func (pn PageNode) String() string

String returns a human-readable representation of the PageNode, useful for debugging. It includes all properties and recursively formats child nodes with proper indentation.

type Ref ​

Ref represents a dynamic reference to a page or method by name. Use it when static type references aren't available (e.g., configuration-driven menus, generic components, or code generation scenarios).

For URLFor, the string can be:

  • Page name: Ref("UserManagement")
  • Route path: Ref("/user/management") - must start with /

For IDFor, the string can be:

  • Qualified method: Ref("PageName.MethodName")
  • Simple method: Ref("MethodName") - must be unambiguous across all pages

Both URLFor and IDFor return descriptive errors if the reference is invalid, providing runtime safety for dynamic references.

Example usage:

// Dynamic menu from configuration
menuItems := []struct{ Page Ref; Label string }{
    {Ref("HomePage"), "Home"},
    {Ref("UserManagement"), "Users"},
}
for _, item := range menuItems {
    url, err := URLFor(ctx, item.Page)
    // Handle error if page doesn't exist
}

// Dynamic component reference
targetID, err := IDFor(ctx, Ref("UserManagement.UserList"))
go
type Ref string

type RenderTarget ​

RenderTarget is what a request wants rendered, as read by the mount's TargetSelector. Props can take it as a parameter to load only what the request wants, or to decide to render something else.

When Props returns no error, the framework asks the target Is(m) for each of the page's components m and renders the one it is, or Page when it is none of them. A target that is more than one component is an error.

func (p dashboard) Props(t RenderTarget, s *Store) (Stats, []Note, error) {
    if t.Is(p.Notes) {
        notes, err := s.Notes()
        return Stats{}, notes, err // renders Notes
    }
    …                              // renders Page
}

ID/Is symmetry: an element rendered with id ID(ctx, X) produces a request whose target Is(X), where X is the served page's component, another page's component, or a standalone function. For a standalone function this is the package-qualified id ("overlay-mount" for overlay.Mount), so Is(overlay.Mount) doesn't match a same-named Mount from another package.

go
type RenderTarget interface {
    // Is reports whether the request wants component: a method expression
    // (Page.Content), a bound method value (p.Content) or a standalone
    // function component. It has no side effects.
    Is(component any) bool
}

func HTMXRenderTarget ​

go
func HTMXRenderTarget(r *http.Request, pn *PageNode) (RenderTarget, error)

HTMXRenderTarget is the TargetSelector for htmx 1.x and 2.x. Opt in with WithTargetSelector(HTMXRenderTarget).

For an htmx request (HX-Request: true) with an HX-Target, it matches the target id against the served page's components:

  • the id the page generated for a component, via ID or the id filter ("index-todo-list" → TodoList);
  • the component's name with or without the page prefix ("content", "index-content" → Content), or a suffix of it ("load-more" → EventListLoadMore).

The target Is the matched component. When nothing matches, the target Is none of the page's components, but still answers Is for a standalone function or another page's component carrying that id, so Props can recognise a region it shares. Any other request wants Page.

For htmx 4, use HTMXv4RenderTarget instead.

func HTMXv4RenderTarget ​

go
func HTMXv4RenderTarget(r *http.Request, pn *PageNode) (RenderTarget, error)

HTMXv4RenderTarget is the htmx 4 variant of HTMXRenderTarget.

htmx 4 reshaped two request headers we care about:

  • HX-Target now carries "<tag>#<id>" (or just "<tag>" for unidentified elements) instead of a bare id.
  • HX-Request-Type was added: "full" when the swap target is <body> or hx-select is in play, "partial" otherwise. The server is expected to honor it.

This selector treats HX-Request-Type=full as a hard hint to render the Page component. Otherwise it picks the matching key from HX-Target — preferring the id portion of "<tag>#<id>" when present, falling back to the tag for id-less targets — and applies the same matching rules as HTMXRenderTarget. A component named e.g. Form matches both `hx-target="#some-form"` (sent as "form#some-form") and `hx-target="form"` (sent as "form").

HX-Source (htmx 4's HX-Trigger replacement) identifies the trigger element rather than the swap target, so it is not used for component routing. Read it from the request directly if you need it.

See https://four.htmx.org/reference/#headers.

Wire it via WithTargetSelector when serving an htmx 4 frontend:

sp, err := structpages.Mount(mux, root{}, "/", "App",
    structpages.WithTargetSelector(structpages.HTMXv4RenderTarget))

type StructPages ​

StructPages holds the parsed page tree context for URL generation. It is returned by Mount and provides URLFor and IDFor methods.

go
type StructPages struct {
    // contains filtered or unexported fields
}

func Mount ​

go
func Mount(mux Mux, page any, route, title string, options ...Option) (*StructPages, error)

Mount parses the page tree and registers all routes onto the provided mux. If mux is nil, routes are registered on http.DefaultServeMux. Returns a StructPages that provides URLFor and IDFor methods.

Parameters:

  • mux: Any router satisfying the Mux interface (e.g., http.ServeMux). If nil, uses http.DefaultServeMux.
  • page: A struct instance with route-tagged fields
  • route: The base route path for this page tree (e.g., "/" or "/admin")
  • title: The title for the root page
  • options: Optional configuration (WithErrorHandler, WithMiddlewares, etc.) and dependency injection args

Example with custom mux:

mux := http.NewServeMux()
sp, err := structpages.Mount(mux, index{}, "/", "My App",
    structpages.WithErrorHandler(customHandler))
sp.URLFor(index.Page)
http.ListenAndServe(":8080", mux)

Example with DefaultServeMux:

sp, err := structpages.Mount(nil, index{}, "/", "My App")
http.ListenAndServe(":8080", nil)

func Parse ​

go
func Parse(page any, route, title string, options ...Option) (*StructPages, error)

Parse builds a page tree from page without registering any HTTP routes. The returned *StructPages exposes URLFor, ID, IDTarget, and PageContext, but never touches a mux.

Use Parse for tests and tooling that need URL/ID resolution against the real page tree but don't want to spin up a server. For production use, prefer Mount, which performs the same parse step and additionally registers handlers.

Options behave identically to Mount: WithArgs, WithURLPrefix, WithErrorHandler, etc. all apply. WithMiddlewares and other mux-affecting options are accepted but never observed (no handlers are wired).

Example (test):

sp, err := structpages.Parse(webPages{}, "/", "App")
if err != nil { t.Fatal(err) }
ctx := sp.PageContext(context.Background())
body := renderTo(ctx, List{}.Page(props))

func (*StructPages) ID ​

go
func (sp *StructPages) ID(v any) (string, error)

ID generates a raw HTML ID for a component method (without "#" prefix). Use this for HTML id attributes. It works without context by using the structpages's parseContext directly.

Example:

sp.ID(p.UserList)
// → "team-management-view-user-list"

sp.ID(UserStatsWidget)
// → "user-stats-widget" (no page prefix for standalone functions)

func (*StructPages) IDTarget ​

go
func (sp *StructPages) IDTarget(v any) (string, error)

IDTarget generates a CSS selector (with "#" prefix) for a component method. Use this for HTMX hx-target attributes. It works without context by using the structpages's parseContext directly.

Example:

sp.IDTarget(p.UserList)
// → "#team-management-view-user-list"

sp.IDTarget(UserStatsWidget)
// → "#user-stats-widget" (no page prefix for standalone functions)

func (*StructPages) PageContext ​

go
func (sp *StructPages) PageContext(ctx context.Context) context.Context

PageContext returns a context derived from ctx with sp's page tree attached. URLFor, ID, and IDTarget invoked with the returned context resolve against sp instead of failing with "parse context not found in context".

Use this in tests that render templ components with context.Background(): wrap the bare context once via PageContext and the renders behave as if they ran under a real request.

sp, _ := structpages.Parse(webPages{}, "/", "App")
ctx := sp.PageContext(context.Background())
html := mustRender(ctx, MyPage{}.Page(props))

func (*StructPages) URLFor ​

go
func (sp *StructPages) URLFor(page any, args ...any) (string, error)

URLFor returns the URL for a given page type. If args is provided, it'll replace the path segments. Supported format is similar to http.ServeMux.

Unlike the context-based URLFor function, this method doesn't have access to pre-extracted URL parameters from the current request, so all required parameters must be provided as args.

Type-based lookup is strict: if the same page type is mounted under multiple parents, URLFor returns an error listing every match instead of silently choosing one. Disambiguate with the []any chain form (recommended; type-safe), Ref("Parent.Field") for cross-package callers, or a func(*PageNode) bool predicate.

Path and query parameters are passed via a map[string]any (preferred — explicit and resilient to route changes):

sp.URLFor(Page{}, map[string]any{"id": 42})
sp.URLFor([]any{Page{}, "?foo={bar}"}, map[string]any{"bar": "baz"})

Positional and key/value-pairs forms also work (see formatPathSegments in url_for.go for the full detection order) but require the call site to track parameter position or name conventions.

You can pass []any as the page to join multiple path segments together — strings are concatenated as-is. You can also pass a func(*PageNode) bool predicate to match a specific page when type-based lookup isn't enough.

type TargetSelector ​

TargetSelector reads what a request wants. It returns the RenderTarget passed to Props and used to select the component to render.

Without WithTargetSelector, every request wants Page. For htmx, use HTMXRenderTarget (htmx 1.x and 2.x) or HTMXv4RenderTarget (htmx 4).

go
type TargetSelector func(r *http.Request, pn *PageNode) (RenderTarget, error)

Generated by gomarkdoc