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 `` or end of ``:
+
+```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 `