docs: add guide covering routing, state, HTML DSL, pubsub, and project structure
This commit is contained in:
179
docs/getting-started.md
Normal file
179
docs/getting-started.md
Normal file
@@ -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 `<title>` 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
|
||||||
164
docs/html-dsl.md
Normal file
164
docs/html-dsl.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
164
docs/project-structure.md
Normal file
164
docs/project-structure.md
Normal file
@@ -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
|
||||||
251
docs/pubsub-and-sessions.md
Normal file
251
docs/pubsub-and-sessions.md
Normal file
@@ -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).
|
||||||
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.
|
||||||
313
docs/state-and-interactivity.md
Normal file
313
docs/state-and-interactivity.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user