From 11c6354da0b2b33de943d4aa5f612d48ce29876c Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:55:07 -1000 Subject: [PATCH] docs: add guide covering routing, state, HTML DSL, pubsub, and project structure --- docs/getting-started.md | 179 ++++++++++++++++++ docs/html-dsl.md | 164 +++++++++++++++++ docs/project-structure.md | 164 +++++++++++++++++ docs/pubsub-and-sessions.md | 251 +++++++++++++++++++++++++ docs/routing-and-navigation.md | 222 ++++++++++++++++++++++ docs/state-and-interactivity.md | 313 ++++++++++++++++++++++++++++++++ 6 files changed, 1293 insertions(+) create mode 100644 docs/getting-started.md create mode 100644 docs/html-dsl.md create mode 100644 docs/project-structure.md create mode 100644 docs/pubsub-and-sessions.md create mode 100644 docs/routing-and-navigation.md create mode 100644 docs/state-and-interactivity.md diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..0c20ca3 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,179 @@ +# Getting Started + +Via is a server-side reactive web framework for Go. The browser connects over SSE (Server-Sent Events), and all state lives on the server — signals, actions, and view rendering happen in Go. The browser is a thin display layer that Datastar keeps in sync via DOM morphing. + +## Core Loop + +Every Via app follows the same pattern: + +```go +package main + +import ( + "github.com/ryanhamamura/via" + "github.com/ryanhamamura/via/h" +) + +func main() { + v := via.New() + + v.Config(via.Options{ + DocumentTitle: "My App", + }) + + v.Page("/", func(c *via.Context) { + count := 0 + step := c.Signal(1) + + increment := c.Action(func() { + count += step.Int() + c.Sync() + }) + + c.View(func() h.H { + return h.Div( + h.P(h.Textf("Count: %d", count)), + h.Label( + h.Text("Step: "), + h.Input(h.Type("number"), step.Bind()), + ), + h.Button(h.Text("+"), increment.OnClick()), + ) + }) + }) + + v.Start() +} +``` + +What happens: + +1. `via.New()` creates the app, starts an embedded NATS server, and registers internal routes (`/_sse`, `/_action/{id}`, `/_navigate`, `/_session/close`). +2. `v.Config()` applies settings. +3. `v.Page()` registers a route. The init function receives a `*Context` where you define signals, actions, and the view. +4. `v.Start()` starts the HTTP server and blocks until SIGINT/SIGTERM. + +When a browser hits the page, Via creates a new `Context`, runs the init function, renders the full HTML document, and opens an SSE connection. From that point, every `c.Sync()` re-renders the view and pushes a DOM patch to the browser. + +## Configuration + +```go +v.Config(via.Options{ + DevMode: true, + ServerAddress: ":8080", + LogLevel: via.LogLevelDebug, + DocumentTitle: "My App", + Plugins: []via.Plugin{MyPlugin}, + SessionManager: sm, + PubSub: customBackend, + ContextTTL: 60 * time.Second, + ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40}, +}) +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `DevMode` | `false` | Enables context persistence across restarts, console logger, and Datastar inspector widget | +| `ServerAddress` | `":3000"` | HTTP listen address | +| `LogLevel` | `InfoLevel` | Minimum log level. Use `via.LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError` | +| `Logger` | (auto) | Replace the default logger entirely. When set, `LogLevel` and `DevMode` have no effect on logging | +| `DocumentTitle` | `"⚡ Via"` | The `` of the HTML document | +| `Plugins` | `nil` | Slice of plugin functions executed during `Config()` | +| `SessionManager` | in-memory | Cookie-based session manager. See [PubSub and Sessions](pubsub-and-sessions.md) | +| `DatastarContent` | (embedded) | Custom Datastar JS bytes | +| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script | +| `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) | +| `ContextTTL` | `30s` | Max time a context survives without an SSE connection before cleanup. Negative value disables the reaper | +| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting | + +## Static Files + +Serve files from a directory: + +```go +v.Static("/assets/", "./static") +``` + +Or from an embedded filesystem: + +```go +//go:embed static +var staticFS embed.FS + +v.StaticFS("/assets/", staticFS) +``` + +Both disable directory listing and return 404 for directory paths. + +## Head and Foot Injection + +Add elements to every page's `<head>` or end of `<body>`: + +```go +v.AppendToHead( + h.Link(h.Rel("stylesheet"), h.Href("/assets/style.css")), + h.Meta(h.Attr("name", "viewport"), h.Attr("content", "width=device-width, initial-scale=1")), +) + +v.AppendToFoot( + h.Script(h.Src("/assets/app.js")), +) +``` + +These are additive and affect all pages globally. + +## Plugins + +A plugin is a `func(v *via.V)` that mutates the app during configuration — registering routes, injecting assets, or applying middleware. + +```go +func PicoCSSPlugin(v *via.V) { + v.HTTPServeMux().HandleFunc("GET /css/pico.css", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css") + w.Write(picoCSSBytes) + }) + v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css"))) +} + +// Usage: +v.Config(via.Options{ + Plugins: []via.Plugin{PicoCSSPlugin}, +}) +``` + +Plugins have full access to the `*V` public API: `HTTPServeMux()`, `AppendToHead()`, `AppendToFoot()`, `Config()`, etc. + +## DevMode + +Enable during development for a better feedback loop: + +```go +v.Config(via.Options{DevMode: true}) +``` + +What it does: + +- **Console logger** — Human-readable log output with timestamps. +- **Context persistence** — Saves context-to-route mappings to `.via/devmode/ctx.json`. On server restart, reconnecting browsers restore their state instead of getting a blank page. Pair with [Air](https://github.com/air-verse/air) for hot-reloading. +- **Datastar inspector** — Injects a widget showing live signal values and SSE activity. + +## Custom HTTP Handlers + +Access the underlying `*http.ServeMux` for custom routes: + +```go +mux := v.HTTPServeMux() +mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) +}) +``` + +Register custom handlers before calling `v.Start()`. + +## Next Steps + +- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation +- [Routing and Navigation](routing-and-navigation.md) — Multi-page apps, middleware, SPA navigation +- [PubSub and Sessions](pubsub-and-sessions.md) — Real-time messaging, persistent sessions +- [HTML DSL](html-dsl.md) — The `h` package reference +- [Project Structure](project-structure.md) — Organizing files as your app grows diff --git a/docs/html-dsl.md b/docs/html-dsl.md new file mode 100644 index 0000000..aec3155 --- /dev/null +++ b/docs/html-dsl.md @@ -0,0 +1,164 @@ +# HTML DSL + +Reference for the `h` package — Via's HTML builder. + +## Overview + +The `h` package wraps [gomponents](https://github.com/maragudk/gomponents) with a single interface: + +```go +type H interface { + Render(w io.Writer) error +} +``` + +Every element, attribute, and text node implements `H`. Build HTML by nesting function calls: + +```go +import "github.com/ryanhamamura/via/h" + +h.Div(h.Class("card"), + h.H2(h.Text("Title")), + h.P(h.Textf("Count: %d", count)), + h.Button(h.Text("Click"), action.OnClick()), +) +``` + +For cleaner templates, use a dot import: + +```go +import . "github.com/ryanhamamura/via/h" + +Div(Class("card"), + H2(Text("Title")), + P(Textf("Count: %d", count)), + Button(Text("Click"), action.OnClick()), +) +``` + +## Text Nodes + +| Function | Description | +|----------|-------------| +| `Text(s)` | Escaped text node | +| `Textf(fmt, args...)` | Escaped text with `fmt.Sprintf` | +| `Raw(s)` | Unescaped raw HTML — use for trusted content like SVG | +| `Rawf(fmt, args...)` | Unescaped raw HTML with `fmt.Sprintf` | + +## Elements + +Every element function takes `...H` children (elements, attributes, and text nodes mixed together) except `Style(v string)` and `Title(v string)` which take a single string. + +### Document structure + +`HTML`, `Head`, `Body`, `Main`, `Header`, `Footer`, `Section`, `Article`, `Aside`, `Nav`, `Div`, `Span` + +### Headings + +`H1`, `H2`, `H3`, `H4`, `H5`, `H6` + +### Text + +`P`, `A`, `Strong`, `Em`, `B`, `I`, `U`, `S`, `Small`, `Mark`, `Del`, `Ins`, `Sub`, `Sup`, `Abbr`, `Cite`, `Code`, `Pre`, `Samp`, `Kbd`, `Var`, `Q`, `BlockQuote`, `Dfn`, `Wbr`, `Br`, `Hr` + +### Forms + +`Form`, `Input`, `Textarea`, `Select`, `Option`, `OptGroup`, `Button`, `Label`, `FieldSet`, `Legend`, `DataList`, `Meter`, `Progress` + +### Tables + +`Table`, `THead`, `TBody`, `TFoot`, `Tr`, `Th`, `Td`, `Caption`, `Col`, `ColGroup` + +### Lists + +`Ul`, `Ol`, `Li`, `Dl`, `Dt`, `Dd` + +### Media + +`Img`, `Audio`, `Video`, `Source`, `Picture`, `Canvas`, `IFrame`, `Embed`, `Object` + +### Other + +`Details`, `Summary`, `Dialog`, `Template`, `NoScript`, `Figure`, `FigCaption`, `Address`, `Time`, `Base`, `Link`, `Meta`, `Script`, `Area` + +### Special signatures + +| Function | Signature | Notes | +|----------|-----------|-------| +| `Style(v)` | `func Style(v string) H` | Inline `style` attribute, not a container element | +| `StyleEl(children...)` | `func StyleEl(children ...H) H` | The `<style>` element as a container | +| `Title(v)` | `func Title(v string) H` | Sets `<title>` text | + +## Attributes + +### Generic + +```go +Attr("name", "value") // name="value" +Attr("disabled") // boolean attribute (no value) +``` + +`Attr` with no value produces a boolean attribute. With one value, it produces a name-value pair. More than one value panics. + +### Named helpers + +| Function | HTML output | +|----------|-------------| +| `ID(v)` | `id="v"` | +| `Class(v)` | `class="v"` | +| `Href(v)` | `href="v"` | +| `Src(v)` | `src="v"` | +| `Type(v)` | `type="v"` | +| `Value(v)` | `value="v"` | +| `Placeholder(v)` | `placeholder="v"` | +| `Rel(v)` | `rel="v"` | +| `Role(v)` | `role="v"` | +| `Data(name, v)` | `data-name="v"` (auto-prefixes `data-`) | + +## Conditional Rendering + +```go +h.If(showError, h.P(h.Class("error"), h.Text("Something went wrong"))) +``` + +Returns the node when `true`, `nil` (renders nothing) when `false`. + +## Datastar Helpers + +These produce attributes used by Datastar for client-side reactivity. + +| Function | Output | Description | +|----------|--------|-------------| +| `DataInit(expr)` | `data-init="expr"` | Initialize client-side state | +| `DataEffect(expr)` | `data-effect="expr"` | Reactive side effect expression | +| `DataIgnoreMorph()` | `data-ignore-morph` | Skip this element during DOM morph. See [SPA Navigation](routing-and-navigation.md#dataignoremorph) | +| `DataViewTransition(name)` | `style="view-transition-name: name"` | Animate element across SPA navigations. See [View Transitions](routing-and-navigation.md#view-transitions) | + +> `DataViewTransition` sets the entire `style` attribute. If you also need other inline styles, include `view-transition-name` directly in a `Style()` call. + +## Utilities + +### HTML5 + +Full HTML5 document template: + +```go +h.HTML5(h.HTML5Props{ + Title: "My Page", + Description: "Page description", + Language: "en", + Head: []h.H{h.Link(h.Rel("stylesheet"), h.Href("/style.css"))}, + Body: []h.H{h.Div(h.Text("Hello"))}, +}) +``` + +Via uses this internally to render the initial page document. You typically don't need it directly. + +### JoinAttrs + +Joins attribute values from child nodes by spaces: + +```go +h.JoinAttrs("class", h.Class("card"), h.Class("active")) +// → class="card active" +``` diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..97caf5b --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,164 @@ +# Project Structure + +Via's closure-based page model pulls signals, actions, and views into a single scope — similar to Svelte's single-file components. This works well at every scale, but the way you organize files should evolve as your app grows. + +## Stage 1: Everything in main.go + +For small apps and prototypes, keep everything in `main.go`. This is the right choice when your app is under ~150 lines or has a single page. + +Within the file, follow this ordering convention inside each page: + +```go +v.Page("/", func(c *via.Context) { + // State — plain Go variables and signals + count := 0 + step := c.Signal(1) + + // Actions — event handlers that mutate state + increment := c.Action(func() { + count += step.Int() + c.Sync() + }) + + // View — returns the HTML tree + c.View(func() h.H { + return h.Div( + h.P(h.Textf("Count: %d", count)), + h.Button(h.Text("+"), increment.OnClick()), + ) + }) +}) +``` + +State → signals → actions → view. This reads top-to-bottom and matches the data flow: state is declared, actions mutate it, the view renders it. + +The [counter](../internal/examples/counter/main.go) and [greeter](../internal/examples/greeter/main.go) examples use this layout. + +## Stage 2: Page per file + +When `main.go` has multiple pages or exceeds ~150 lines, extract each page into its own file as a package-level function. + +`main.go` becomes the app skeleton — setup, configuration, routes, and start: + +```go +package main + +import ( + "github.com/ryanhamamura/via" + "github.com/ryanhamamura/via/h" +) + +func main() { + v := via.New() + v.Config(via.Options{ + DocumentTitle: "My App", + }) + + v.AppendToHead( + h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")), + ) + + v.Page("/", HomePage) + v.Page("/chat", ChatPage) + + v.Start() +} +``` + +Each page lives in its own file with a descriptive name: + +```go +// home.go +package main + +import ( + "github.com/ryanhamamura/via" + "github.com/ryanhamamura/via/h" +) + +func HomePage(c *via.Context) { + greeting := c.Signal("Hello") + + c.View(func() h.H { + return h.Div(h.P(h.Text(greeting.String()))) + }) +} +``` + +Components follow the same pattern — keep them in the page file if single-use, or extract to their own file if reused across pages. Middleware goes in the same file as the route group it protects, or in `middleware.go` if shared. + +``` +myapp/ +├── main.go # skeleton + routes +├── home.go # func HomePage(c *via.Context) +├── chat.go # func ChatPage(c *via.Context) +└── middleware.go # shared middleware +``` + +## Stage 3: Co-located CSS and shared types + +As pages accumulate custom styling, CSS strings in Go become hard to maintain — no syntax highlighting, no linting. Extract them to `.css` files alongside the pages they belong to and use `//go:embed` to load them. + +```go +// main.go +package main + +import "embed" + +//go:embed chat.css +var chatCSS string + +func main() { + v := via.New() + + v.AppendToHead( + h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")), + h.StyleEl(h.Raw(chatCSS)), + ) + + // ... +} +``` + +When multiple pages share the same structs, extract them to `types.go`. Framework-agnostic domain logic (helpers, dummy data, business rules) gets its own file too. + +``` +myapp/ +├── main.go # skeleton + routes + global styles +├── home.go +├── chat.go +├── chat.css # //go:embed in main.go +├── types.go # shared types +└── userdata.go # helpers, dummy data +``` + +The [nats-chatroom](../internal/examples/nats-chatroom/) example demonstrates this layout. + +## CSS Approaches + +Via doesn't prescribe a CSS strategy. Two approaches work well: + +**CSS framework classes in Go code** — Use Pico, Tailwind, or similar. Classes go directly in the view via `h.Class()`. Good for rapid prototyping since there's nothing to extract. + +```go +h.Div(h.Class("container"), + h.Button(h.Class("primary"), h.Text("Save")), +) +``` + +**Co-located `.css` files with `//go:embed`** — Write plain CSS in a separate file, embed it, and inject via `AppendToHead`. You get syntax highlighting, linting, and clean separation. + +```go +//go:embed chat.css +var chatCSS string + +// in main(): +v.AppendToHead(h.StyleEl(h.Raw(chatCSS))) +``` + +Use a framework for quick prototypes and dashboards. Switch to co-located CSS files when you have significant custom styling or want tooling support. + +## Next Steps + +- [Getting Started](getting-started.md) — The core loop and configuration +- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation diff --git a/docs/pubsub-and-sessions.md b/docs/pubsub-and-sessions.md new file mode 100644 index 0000000..1d6f4aa --- /dev/null +++ b/docs/pubsub-and-sessions.md @@ -0,0 +1,251 @@ +# PubSub and Sessions + +Infrastructure for multi-user real-time communication and persistent state. + +## PubSub + +Via includes an embedded NATS server that starts automatically with `via.New()`. No external services required — pub/sub works out of the box. + +### Interface + +```go +type PubSub interface { + Publish(subject string, data []byte) error + Subscribe(subject string, handler func(data []byte)) (Subscription, error) + Close() error +} + +type Subscription interface { + Unsubscribe() error +} +``` + +You can replace the default NATS with any backend implementing this interface via `Options.PubSub`. + +### Basic pub/sub + +```go +// Subscribe to messages +via.Subscribe(c, "chat.room.general", func(msg ChatMessage) { + messages = append(messages, msg) + c.Sync() +}) + +// Publish a message +via.Publish(c, "chat.room.general", ChatMessage{ + User: username, + Message: text, + Time: time.Now().UnixMilli(), +}) +``` + +The generic helpers `via.Publish[T]` and `via.Subscribe[T]` handle JSON marshaling/unmarshaling automatically. They are package-level functions (not methods) because Go doesn't support generic methods. + +Raw byte-level access is also available on the context: + +```go +c.Publish("subject", []byte("raw data")) +c.Subscribe("subject", func(data []byte) { /* ... */ }) +``` + +### Auto-cleanup + +Subscriptions created via `c.Subscribe()` or `via.Subscribe()` are tracked on the context and automatically unsubscribed when: + +- The context is disposed (browser disconnects, tab closes) +- SPA navigation moves to a different page + +You don't need to manually unsubscribe in normal usage. + +### Custom backend + +Replace the embedded NATS with your own PubSub implementation: + +```go +v.Config(via.Options{ + PubSub: myRedisBackend, +}) +``` + +This disables the embedded NATS server. The `NATSConn()` and `JetStream()` accessors will return nil. + +## JetStream + +NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages. + +### Ensure a stream exists + +```go +err := via.EnsureStream(v, via.StreamConfig{ + Name: "CHAT", + Subjects: []string{"chat.>"}, + MaxMsgs: 1000, + MaxAge: 24 * time.Hour, +}) +``` + +| Field | Description | +|-------|-------------| +| `Name` | Stream name | +| `Subjects` | NATS subjects to capture (supports wildcards: `>` matches all sub-levels) | +| `MaxMsgs` | Maximum number of messages to retain | +| `MaxAge` | Maximum age before messages are discarded | + +Call `EnsureStream` during app initialization, before `v.Start()`. + +### Replay history + +Retrieve recent messages from a stream: + +```go +messages, err := via.ReplayHistory[ChatMessage](v, "chat.room.general", 50) +``` + +Returns up to the last `limit` messages on the subject, deserialized as `T`. Use this when a new user joins and needs to see recent history. + +### Direct NATS access + +For advanced use cases, access the NATS connection and JetStream context directly: + +```go +nc := v.NATSConn() // *nats.Conn, nil if custom PubSub +js := v.JetStream() // nats.JetStreamContext, nil if custom PubSub +``` + +### PubSub accessor + +Access the configured PubSub backend from the `V` instance: + +```go +ps := v.PubSub() // via.PubSub interface, nil if none configured +``` + +## Sessions + +Via uses [SCS](https://github.com/alexedwards/scs) for cookie-based session management. + +### Setup with SQLite + +```go +db, _ := sql.Open("sqlite3", "app.db") + +sm, _ := via.NewSQLiteSessionManager(db) +sm.Lifetime = 24 * time.Hour +sm.Cookie.SameSite = http.SameSiteLaxMode + +v.Config(via.Options{SessionManager: sm}) +``` + +`NewSQLiteSessionManager` creates the `sessions` table and index if they don't exist. The returned `*scs.SessionManager` can be configured further (lifetime, cookie settings) before passing to `Config`. + +A default in-memory session manager is always available, even without explicit configuration. Use `NewSQLiteSessionManager` when you need sessions to survive server restarts. + +### Session API + +Access the session from any context: + +```go +s := c.Session() +``` + +**Getters:** + +| Method | Return type | +|--------|-------------| +| `s.Get(key)` | `any` | +| `s.GetString(key)` | `string` | +| `s.GetInt(key)` | `int` | +| `s.GetBool(key)` | `bool` | +| `s.GetFloat64(key)` | `float64` | +| `s.GetTime(key)` | `time.Time` | +| `s.GetBytes(key)` | `[]byte` | + +**Pop** (get and delete — useful for flash messages): + +| Method | Return type | +|--------|-------------| +| `s.Pop(key)` | `any` | +| `s.PopString(key)` | `string` | +| `s.PopInt(key)` | `int` | +| `s.PopBool(key)` | `bool` | +| `s.PopFloat64(key)` | `float64` | +| `s.PopTime(key)` | `time.Time` | +| `s.PopBytes(key)` | `[]byte` | + +**Mutators:** + +| Method | Description | +|--------|-------------| +| `s.Set(key, val)` | Store a value | +| `s.Delete(key)` | Remove a single key | +| `s.Clear()` | Remove all session data | +| `s.Destroy()` | Destroy the entire session (for logout) | +| `s.RenewToken()` | Regenerate session ID (prevents session fixation — call after login) | + +**Introspection:** + +| Method | Description | +|--------|-------------| +| `s.Exists(key)` | True if key exists | +| `s.Keys()` | All keys in the session | +| `s.ID()` | Session token (cookie value) | + +All getters return zero values if the key doesn't exist or the session manager is nil. + +### Auth pattern + +A common login/logout flow using sessions and middleware: + +```go +// Middleware +func authRequired(c *via.Context, next func()) { + if c.Session().GetString("username") == "" { + c.Session().Set("flash", "Please log in first") + c.RedirectView("/login") + return + } + next() +} + +// Login page +v.Page("/login", func(c *via.Context) { + user := c.Signal("") + pass := c.Signal("") + flash := c.Session().PopString("flash") + + login := c.Action(func() { + if authenticate(user.String(), pass.String()) { + c.Session().RenewToken() + c.Session().Set("username", user.String()) + c.Redirect("/dashboard") + } else { + flash = "Invalid credentials" + c.Sync() + } + }) + + c.View(func() h.H { + return h.Form(login.OnSubmit(), + h.If(flash != "", h.P(h.Text(flash))), + h.Input(h.Type("text"), user.Bind(), h.Placeholder("Username")), + h.Input(h.Type("password"), pass.Bind(), h.Placeholder("Password")), + h.Button(h.Type("submit"), h.Text("Log In")), + ) + }) +}) + +// Protected pages +protected := v.Group("", authRequired) +protected.Page("/dashboard", dashboardHandler) + +// Logout action (inside a protected page) +logout := c.Action(func() { + c.Session().Destroy() + c.Redirect("/login") +}) +``` + +Key points: +- Call `RenewToken()` after login to prevent session fixation. +- Use `PopString` for flash messages — they're read once then removed. +- Use `RedirectView` in middleware, `Redirect` in actions. See the [gotcha in routing](routing-and-navigation.md#middleware). diff --git a/docs/routing-and-navigation.md b/docs/routing-and-navigation.md new file mode 100644 index 0000000..10762c9 --- /dev/null +++ b/docs/routing-and-navigation.md @@ -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. diff --git a/docs/state-and-interactivity.md b/docs/state-and-interactivity.md new file mode 100644 index 0000000..3bf4a94 --- /dev/null +++ b/docs/state-and-interactivity.md @@ -0,0 +1,313 @@ +# State and Interactivity + +This is the core reactive model — signals, actions, views, components, and validation. + +## Context Lifecycle + +A `*Context` is created per browser visit. It holds all page state: signals, actions, fields, subscriptions, and the view function. + +``` +Browser hits page → new Context created → init function runs → HTML rendered + ↓ + SSE connection opens ← browser loads page + ↓ + action fires → signals injected from browser → handler runs → Sync() → DOM patched +``` + +The context is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s). + +During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists. + +## Signals + +Signals are reactive values synchronized between server and browser. Create one with an initial value: + +```go +name := c.Signal("world") +count := c.Signal(0) +items := c.Signal([]string{"a", "b"}) +``` + +### Reading values + +```go +name.String() // "world" +count.Int() // 0 +count.Bool() // false (parses "true", "1", "yes", "on") +``` + +Signal values come from the browser. Before every action call, the browser sends all current signal values to the server. You always read the latest browser state inside action handlers. + +### Writing values + +```go +name.SetValue("Via") +c.SyncSignals() // push only changed signals to browser +// or +c.Sync() // re-render view AND push changed signals +``` + +`SetValue` marks the signal as changed. The change is not sent to the browser until you call `Sync()` or `SyncSignals()`. + +### Rendering in the view + +```go +// Two-way binding on an input — browser edits update the signal +h.Input(h.Type("text"), name.Bind()) + +// Reactive text display — updates when the signal changes +h.Span(name.Text()) + +// Read value at render time — static until next Sync() +h.P(h.Textf("Count: %d", count.Int())) +``` + +`Bind()` outputs a `data-bind` attribute for two-way binding. `Text()` outputs a `<span data-text="$signalID">` for reactive display. + +## Actions + +Actions are server-side event handlers. They run on the server when triggered by a browser event. + +```go +submit := c.Action(func() { + // signals are already injected — read them here + fmt.Println(name.String()) + count.SetValue(count.Int() + 1) + c.Sync() +}) +``` + +### Trigger methods + +Attach an action to a DOM event by calling a trigger method in the view: + +```go +h.Button(h.Text("Submit"), submit.OnClick()) +h.Input(name.Bind(), submit.OnKeyDown("Enter")) +h.Select(category.Bind(), filter.OnChange()) +h.Form(submit.OnSubmit()) +``` + +Available triggers: + +| Method | Event | Notes | +|--------|-------|-------| +| `OnClick()` | `click` | | +| `OnDblClick()` | `dblclick` | | +| `OnChange()` | `change` | 200ms debounce | +| `OnInput()` | `input` | No debounce | +| `OnSubmit()` | `submit` | | +| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) | +| `OnFocus()` | `focus` | | +| `OnBlur()` | `blur` | | +| `OnMouseEnter()` | `mouseenter` | | +| `OnMouseLeave()` | `mouseleave` | | +| `OnScroll()` | `scroll` | | + +### Trigger options + +Every trigger method accepts `ActionTriggerOption` values: + +```go +// Set a signal value before the action fires +submit.OnClick(via.WithSignal(mode, "delete")) +submit.OnClick(via.WithSignalInt(page, 3)) + +// Listen on window instead of the element +submit.OnKeyDown("Escape", via.WithWindow()) + +// Prevent browser default behavior +submit.OnKeyDown("ArrowDown", via.WithPreventDefault()) +``` + +### Multi-key dispatch + +`OnKeyDownMap` binds multiple keys to different actions in a single attribute: + +```go +via.OnKeyDownMap( + via.KeyBind("w", move, via.WithSignal(dir, "up")), + via.KeyBind("s", move, via.WithSignal(dir, "down")), + via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()), + via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()), +) +``` + +This produces a single `data-on:keydown__window` attribute. Place it on any element in the view. + +### Action options + +```go +// Per-action rate limiting (overrides the context-level default) +c.Action(handler, via.WithRateLimit(5, 10)) + +// Per-action middleware (runs after CSRF and rate-limit checks) +c.Action(handler, via.WithMiddleware(requireAdmin)) +``` + +## Views and Sync + +Every page handler must call `c.View()` to define the UI: + +```go +c.View(func() h.H { + return h.Div( + h.P(h.Textf("Hello, %s!", name.String())), + ) +}) +``` + +> **Gotcha:** If you forget `c.View()`, the app panics at startup during route registration — not at request time. + +The view function is re-evaluated on every `c.Sync()`. The resulting HTML is pushed to the browser via SSE, where Datastar morphs the DOM. + +### Sync variants + +| Method | What it sends | +|--------|---------------| +| `c.Sync()` | Re-renders the view HTML **and** pushes changed signals | +| `c.SyncSignals()` | Pushes only changed signals, no view re-render | +| `c.SyncElements(elem...)` | Pushes specific HTML elements to merge into the DOM. Each element **must have an ID** matching an existing DOM element | +| `c.ExecScript(js)` | Sends JavaScript for the browser to execute (auto-removed after execution) | + +Use `SyncSignals()` when only signal values changed and the view structure is the same. Use `SyncElements()` for targeted updates without re-rendering the entire view. Use `ExecScript()` to interact with client-side libraries (e.g. pushing data to a chart). + +## Components + +Extract reusable UI with `c.Component()`: + +```go +func counterFn(c *via.Context) { + count := 0 + step := c.Signal(1) + + increment := c.Action(func() { + count += step.Int() + c.Sync() + }) + + c.View(func() h.H { + return h.Div( + h.P(h.Textf("Count: %d", count)), + h.Input(h.Type("number"), step.Bind()), + h.Button(h.Text("+"), increment.OnClick()), + ) + }) +} + +// In a page: +v.Page("/", func(c *via.Context) { + counter1 := c.Component(counterFn) + counter2 := c.Component(counterFn) + + c.View(func() h.H { + return h.Div( + h.H2(h.Text("Counter 1")), counter1(), + h.H2(h.Text("Counter 2")), counter2(), + ) + }) +}) +``` + +Each component instance gets its own closure state, but signals, actions, and fields are registered on the parent page context. Components share the parent's SSE stream — `c.Sync()` from a component re-renders the entire page view. + +## Fields and Validation + +Fields are signals with validation rules. Use them for form inputs: + +```go +username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20)) +email := c.Field("", via.Required(), via.Email()) +age := c.Field("", via.Required(), via.Min(13), via.Max(120)) +website := c.Field("", via.Pattern(`^https?://`, "Must start with http:// or https://")) +``` + +### Built-in rules + +| Rule | Description | +|------|-------------| +| `Required(msg...)` | Rejects empty/whitespace-only values | +| `MinLen(n, msg...)` | Minimum character count (Unicode-aware) | +| `MaxLen(n, msg...)` | Maximum character count (Unicode-aware) | +| `Min(n, msg...)` | Minimum numeric value (parsed as int) | +| `Max(n, msg...)` | Maximum numeric value (parsed as int) | +| `Email(msg...)` | Email format regex | +| `Pattern(re, msg...)` | Custom regex | +| `Custom(fn)` | `func(string) error` — return non-nil to fail | + +All rules accept an optional custom error message as the last argument. + +### Using fields in views and actions + +```go +submit := c.Action(func() { + if !c.ValidateAll() { + c.Sync() + return + } + // Server-side validation + if userExists(username.String()) { + username.AddError("Username taken") + c.Sync() + return + } + createUser(username.String(), email.String()) + c.ResetFields() + c.Sync() +}) + +c.View(func() h.H { + return h.Form(submit.OnSubmit(), + h.Input(h.Type("text"), username.Bind(), h.Placeholder("Username")), + h.If(username.HasError(), h.Small(h.Text(username.FirstError()))), + + h.Input(h.Type("email"), email.Bind(), h.Placeholder("Email")), + h.If(email.HasError(), h.Small(h.Text(email.FirstError()))), + + h.Button(h.Type("submit"), h.Text("Sign Up")), + ) +}) +``` + +| Method | Description | +|--------|-------------| +| `field.Validate()` | Run rules, return true if all pass | +| `field.HasError()` | True if any validation errors exist | +| `field.FirstError()` | First error message, or `""` | +| `field.Errors()` | All error messages | +| `field.AddError(msg)` | Add a custom server-side error | +| `field.ClearErrors()` | Remove all errors | +| `field.Reset()` | Restore initial value and clear errors | +| `c.ValidateAll(fields...)` | Validate given fields (or all if none specified). Does not short-circuit — all fields get validated so all errors are populated | +| `c.ResetFields(fields...)` | Reset given fields (or all if none specified) | + +Fields embed `*signal`, so `Bind()`, `Text()`, `String()`, `Int()`, `Bool()`, `SetValue()`, and `ID()` all work. + +## OnInterval + +Run a function at regular intervals, tied to the page lifecycle: + +```go +stop := c.OnInterval(time.Second, func() { + now = time.Now() + c.Sync() +}) +``` + +- Starts immediately — no separate start call needed. +- Returns a `func()` that stops the interval (idempotent). +- Automatically stops on context disposal (tab close) or SPA navigation away. +- Call `c.Sync()` inside the handler to push updates to the browser. + +## Navigation Helpers + +| Method | Effect | +|--------|--------| +| `c.Redirect(url)` | Full page navigation. Disposes the context, browser loads a new page | +| `c.Redirectf(fmt, args...)` | `Redirect` with `fmt.Sprintf` | +| `c.RedirectView(url)` | Sets the view to trigger a redirect on SSE connect. Use in [middleware](routing-and-navigation.md#middleware) to abort the chain and redirect | +| `c.ReplaceURL(url)` | Updates the browser URL bar without navigation. Useful for reflecting state in query params | +| `c.ReplaceURLf(fmt, args...)` | `ReplaceURL` with `fmt.Sprintf` | +| `c.Navigate(path, popstate)` | [SPA navigation](routing-and-navigation.md#spa-navigation). Resets page state, runs the target page handler on the same context, pushes the new view with a view transition | + +> **Gotcha:** In middleware, use `c.RedirectView()`, not `c.Redirect()`. `Redirect` sends a patch over SSE, but the SSE connection isn't established yet during the initial page load.