docs: add guide covering routing, state, HTML DSL, pubsub, and project structure
This commit is contained in:
222
docs/routing-and-navigation.md
Normal file
222
docs/routing-and-navigation.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Routing and Navigation
|
||||
|
||||
Multi-page app structure, middleware, and Via's SPA navigation system.
|
||||
|
||||
## Pages
|
||||
|
||||
Register a page with a route pattern and an init function:
|
||||
|
||||
```go
|
||||
v.Page("/", func(c *via.Context) {
|
||||
c.View(func() h.H {
|
||||
return h.H1(h.Text("Home"))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Routes use Go's standard `net/http.ServeMux` patterns. Via registers each page as a `GET` handler.
|
||||
|
||||
> **Gotcha:** Via runs every page init function at registration time (in a `defer/recover` block) to catch panics early. If your init function panics — e.g. by forgetting `c.View()` — the app crashes at startup, not at request time.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
Use `{param}` syntax in route patterns:
|
||||
|
||||
```go
|
||||
v.Page("/users/{id}/posts/{post_id}", func(c *via.Context) {
|
||||
userID := c.GetPathParam("id")
|
||||
postID := c.GetPathParam("post_id")
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.P(h.Textf("User %s, Post %s", userID, postID))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
`GetPathParam` returns an empty string if the parameter doesn't exist.
|
||||
|
||||
## Route Groups
|
||||
|
||||
Group pages under a shared prefix with shared middleware:
|
||||
|
||||
```go
|
||||
admin := v.Group("/admin", authRequired)
|
||||
admin.Page("/dashboard", dashboardHandler) // route: /admin/dashboard
|
||||
admin.Page("/settings", settingsHandler) // route: /admin/settings
|
||||
```
|
||||
|
||||
### Nesting
|
||||
|
||||
Groups nest — the child inherits the parent's prefix and middleware:
|
||||
|
||||
```go
|
||||
admin := v.Group("/admin", authRequired)
|
||||
admin.Use(auditLog) // add middleware after creation
|
||||
|
||||
superAdmin := admin.Group("/super", superAdminOnly)
|
||||
superAdmin.Page("/nuke", nukeHandler) // route: /admin/super/nuke
|
||||
// middleware order: global → authRequired → auditLog → superAdminOnly → handler
|
||||
```
|
||||
|
||||
### Empty prefix
|
||||
|
||||
Use an empty prefix when you need shared middleware without a path prefix:
|
||||
|
||||
```go
|
||||
protected := v.Group("", authRequired)
|
||||
protected.Page("/dashboard", dashboardHandler) // route: /dashboard
|
||||
protected.Page("/profile", profileHandler) // route: /profile
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
```go
|
||||
type Middleware func(c *Context, next func())
|
||||
```
|
||||
|
||||
Call `next()` to continue the chain. Return without calling `next()` to abort — but set a view first.
|
||||
|
||||
```go
|
||||
func authRequired(c *via.Context, next func()) {
|
||||
if c.Session().GetString("username") == "" {
|
||||
c.Session().Set("flash", "Please log in")
|
||||
c.RedirectView("/login")
|
||||
return // don't call next — chain is aborted
|
||||
}
|
||||
next()
|
||||
}
|
||||
```
|
||||
|
||||
> **Gotcha:** Use `c.RedirectView()` in middleware, not `c.Redirect()`. The SSE connection isn't open yet during the initial page load, so `Redirect()` (which sends a patch over SSE) won't work. `RedirectView()` sets the view to one that triggers a redirect once SSE connects.
|
||||
|
||||
### Three levels
|
||||
|
||||
| Level | Registration | Scope |
|
||||
|-------|-------------|-------|
|
||||
| Global | `v.Use(mw...)` | Every page |
|
||||
| Group | `v.Group(prefix, mw...)` or `g.Use(mw...)` | Pages in the group |
|
||||
| Action | `c.Action(fn, via.WithMiddleware(mw...))` | A single action endpoint |
|
||||
|
||||
### Execution order
|
||||
|
||||
Middleware runs in registration order: global first, then group, then the handler.
|
||||
|
||||
```go
|
||||
v.Use(logger) // 1st
|
||||
admin := v.Group("/admin", auth) // 2nd
|
||||
admin.Use(audit) // 3rd
|
||||
admin.Page("/x", handler) // 4th
|
||||
// execution: logger → auth → audit → handler
|
||||
```
|
||||
|
||||
Action-level middleware runs after CSRF validation and rate limiting, when the action endpoint is invoked.
|
||||
|
||||
## SPA Navigation
|
||||
|
||||
Via intercepts same-origin link clicks and navigates without a full page reload. The SSE connection persists, and the new page's view is morphed into the DOM with a view transition.
|
||||
|
||||
### How it works
|
||||
|
||||
1. `navigate.js` (embedded in every page) intercepts clicks on `<a>` elements.
|
||||
2. For same-origin links, it POSTs to `/_navigate` with the context ID, CSRF token, and target URL.
|
||||
3. The server calls `c.Navigate()`, which:
|
||||
- Resets page state (stops intervals, unsubscribes PubSub, clears signals/actions/fields)
|
||||
- Runs the target page's init function (with middleware) on the **same context**
|
||||
- Pushes the new view via SSE with a view transition
|
||||
- Updates the browser URL via `history.pushState()`
|
||||
|
||||
### What gets cleaned up on navigate
|
||||
|
||||
- Intervals stop (via `pageStopChan`)
|
||||
- PubSub subscriptions are unsubscribed
|
||||
- Signals, actions, and fields are cleared
|
||||
- The new page starts completely fresh
|
||||
|
||||
The SSE connection and the context itself survive. This is what makes it an SPA — the existing stream is reused.
|
||||
|
||||
### Layouts
|
||||
|
||||
Define a layout to provide persistent chrome (nav bars, sidebars) that wraps every page:
|
||||
|
||||
```go
|
||||
v.Layout(func(content func() h.H) h.H {
|
||||
return h.Div(
|
||||
h.Nav(
|
||||
h.A(h.Href("/"), h.Text("Home")),
|
||||
h.A(h.Href("/counter"), h.Text("Counter")),
|
||||
h.A(h.Href("/clock"), h.Text("Clock")),
|
||||
),
|
||||
h.Main(content()),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The `content` parameter is the page's view function. During SPA navigation, the entire layout + content is re-rendered and morphed — Datastar's morph algorithm (idiomorph) efficiently updates only the changed parts, so the nav bar stays visually stable while the main content transitions.
|
||||
|
||||
> **Gotcha:** Layout state does not persist across navigations in the way page state doesn't — the layout is re-rendered from scratch each time. If you need state that survives navigation (like a selected nav item), derive it from the current route rather than storing it in a variable.
|
||||
|
||||
### View transitions
|
||||
|
||||
Animate elements across page navigations using the browser View Transitions API:
|
||||
|
||||
```go
|
||||
// On the home page:
|
||||
h.H1(h.Text("Home"), h.DataViewTransition("page-title"))
|
||||
|
||||
// On the counter page:
|
||||
h.H1(h.Text("Counter"), h.DataViewTransition("page-title"))
|
||||
```
|
||||
|
||||
Elements with matching `view-transition-name` values animate smoothly during SPA navigation. `DataViewTransition` sets the CSS `view-transition-name` as an inline `style` attribute. If the element also needs other inline styles, set `view-transition-name` directly in a `Style()` call instead.
|
||||
|
||||
Via automatically includes the `<meta name="view-transition" content="same-origin">` tag to enable the API.
|
||||
|
||||
### Opting out
|
||||
|
||||
Add `data-via-no-boost` to links that should trigger a full page reload:
|
||||
|
||||
```go
|
||||
h.A(h.Href("/"), h.Text("Full Reload"), h.Attr("data-via-no-boost"))
|
||||
```
|
||||
|
||||
Links are also auto-ignored when:
|
||||
- They have a `target` attribute (e.g. `target="_blank"`)
|
||||
- Modifier keys are held (Ctrl, Meta, Shift, Alt)
|
||||
- The `href` starts with `#` or is cross-origin
|
||||
- The `href` is missing
|
||||
|
||||
### Programmatic navigation
|
||||
|
||||
Trigger SPA navigation from an action handler:
|
||||
|
||||
```go
|
||||
goCounter := c.Action(func() {
|
||||
c.Navigate("/counter", false)
|
||||
})
|
||||
```
|
||||
|
||||
The second parameter controls history behavior: `false` for `pushState` (normal navigation), `true` for `replaceState` (back/forward).
|
||||
|
||||
If the path doesn't match any registered route, `Navigate` falls back to `c.Redirect()` (full page navigation).
|
||||
|
||||
### DataIgnoreMorph
|
||||
|
||||
Prevent Datastar from overwriting an element during morph:
|
||||
|
||||
```go
|
||||
h.Div(h.ID("toast-container"), h.DataIgnoreMorph())
|
||||
```
|
||||
|
||||
The element and its subtree are skipped during DOM patches. Useful for elements with client-side state: a focused input, an animation, a third-party widget, or a toast notification container.
|
||||
|
||||
## Custom HTTP Handlers
|
||||
|
||||
Access the underlying mux for non-Via routes (APIs, webhooks, health checks):
|
||||
|
||||
```go
|
||||
mux := v.HTTPServeMux()
|
||||
mux.HandleFunc("GET /api/health", healthHandler)
|
||||
mux.HandleFunc("POST /api/webhook", webhookHandler)
|
||||
```
|
||||
|
||||
Register before `v.Start()`. These routes bypass Via's context/SSE system entirely.
|
||||
Reference in New Issue
Block a user