diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c22880f..6c78963 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,18 @@ Steps: 1. Create the folder + four files (use `examples/counter/` as the template — it's the smallest runnable shape). 2. Optional: add `content/recipes/foo/index.md` if you want a recipe write-up. Cite source via site-rooted includes: `` ```go include="/examples/foo/foo.go" lines="5-15" `` ``. + + **Include the code, don't retype it.** A pasted snippet drifts from the app + the moment either changes, and nothing catches it — + `content/recipes/apps/chat.md` documented a `Change(ctx *ActionContext)` API + that never existed, plus an undefined variable, for as long as it was + hand-written. `include=` cannot drift that way. + + **Read [`VOICE.md`](VOICE.md) before writing the prose.** The ten pages under + `content/recipes/apps/` were ported from example READMEs and carried that + register — Title Case headings, emoji, "The magic:" — until #137. The + `livetemplate/examples` repo that taught it is archived, so this file is now + the only place that guidance lives. 3. Wire `cmd/site/main.go`: import `"github.com/livetemplate/docs/examples/foo"` and add a `mux.Handle("/apps/foo/", ...)` line. 4. `go build ./...` + `go test ./examples/foo` to confirm. diff --git a/content/recipes/apps/chat.md b/content/recipes/apps/chat.md index c50d2ba..1bef3b0 100644 --- a/content/recipes/apps/chat.md +++ b/content/recipes/apps/chat.md @@ -13,11 +13,12 @@ A tutorial for building a real-time chat room on LiveTemplate's simple kit: mult - Real-time messaging, synced across a browser's tabs - User login and presence tracking -- Instant UI updates across all tabs in the same browser +- Updates that reach the other tabs without a reload - Browser session isolation (each browser has its own chat room) - Message history and timestamps -**All in just 2 files: `main.go` and `chat.tmpl`** +Two files: `main.go` and `chat.tmpl`. Every snippet here comes straight out of +`examples/chat/main.go`, so it cannot drift from the app you are running. ## Quick start @@ -51,170 +52,59 @@ The `simple` kit generates a minimal structure: No `cmd/`, no `internal/`, no database. A larger app will grow some of those; a chat room this size doesn't need them. -### Step 2: define the chat state +### Step 2: define the controller and the state -Open `main.go` and replace the counter example with chat state: +Two types, and the split between them is the thing to get right. -```go -package main - -import ( - "log" - "net/http" - "os" - "sync" - "time" - - "github.com/livetemplate/livetemplate" -) +The **controller** is a singleton. It holds what every tab must agree on — the +message list, who is online — behind a mutex. -type ChatState struct { - Messages []Message - Users map[string]*User - CurrentUser string - OnlineCount int - TotalMessages int - mu sync.RWMutex // Thread-safe access -} - -type Message struct { - ID int - Username string - Text string - Timestamp string -} +The **state** is per connection. Each tab gets its own copy, which is why +`CurrentUser` can differ between two tabs of the same browser. -type User struct { - Username string - JoinedAt time.Time - IsOnline bool -} +```go include="/examples/chat/main.go" lines="16-37" ``` -**Key concepts:** +Put dependencies on the controller and serializable UI data in the state. Put a +mutex on the state struct and each connection gets its own copy, guarding +nothing. -- Single `ChatState` struct holds all app state -- `sync.RWMutex` for thread-safe concurrent access -- Plain Go structs. No database and no ORM, because a room this size fits in memory +### Step 3: subscribe, then publish -### Step 3: implement actions - -Add the `Change` method to handle user actions: - -```go -func (s *ChatState) Change(ctx *livetemplate.ActionContext) error { - s.mu.Lock() - defer s.mu.Unlock() - - switch ctx.Action { - case "send": - var data struct { - Message string `json:"message"` - } - - if err := ctx.Bind(&data); err != nil { - return nil - } - - if data.Message == "" { - return nil - } +`Mount` runs once per session group. It opts this connection into its own topic +and seeds the state from the controller: - s.TotalMessages++ - msg := Message{ - ID: s.TotalMessages, - Username: s.CurrentUser, - Text: data.Message, - Timestamp: time.Now().Format("15:04:05"), - } - - s.Messages = append(s.Messages, msg) - return nil // Auto-syncs to all tabs in same browser! - - case "join": - var data struct { - Username string `json:"username"` - } - - if err := ctx.Bind(&data); err != nil { - return nil - } - - s.CurrentUser = data.Username - - if _, exists := s.Users[data.Username]; !exists { - s.Users[data.Username] = &User{ - Username: data.Username, - JoinedAt: time.Now(), - IsOnline: true, - } - s.updateOnlineCount() - } - - return nil - } - - return nil -} - -func (s *ChatState) updateOnlineCount() { - count := 0 - for _, user := range s.Users { - if user.IsOnline { - count++ - } - } - s.OnlineCount = count -} +```go include="/examples/chat/main.go" lines="41-51" ``` -**Key concepts:** - -- Actions route via `
` and `` (button/form `name` routing) -- `ctx.GetString("field")` extracts form data -- Mutating state is not enough on its own — `Subscribe` opts a connection in, - and `Publish` is what reaches the peers -- You don't write WebSocket code, but you do write both of those - -### Step 4: initialize and run +`Send` appends to the shared list, then tells the peers: -Add initialization and main function: +```go include="/examples/chat/main.go" lines="101-125" +``` -```go -func (s *ChatState) Init() error { - if s.Users == nil { - s.Users = make(map[string]*User) - } - if s.Messages == nil { - s.Messages = []Message{} - } - return nil -} +`Publish` does not push state. It runs a named action — here `NewMessage` — on +every other subscribed connection, and that action rebuilds its own tab's view: -func main() { - log.Println("chat starting...") +```go include="/examples/chat/main.go" lines="129-135" +``` - state := &ChatState{ - Users: make(map[string]*User), - Messages: []Message{}, - } +You need both. Without the `Subscribe` in `Mount` the publish reaches nobody; +without the `Publish` no peer ever runs. Neither happens on its own. - tmpl := livetemplate.Must(livetemplate.New("chat", livetemplate.WithDevMode(true))) - http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(state))) +`OnConnect` fires per WebSocket rather than per session group, which is how a +second tab starts logged out instead of inheriting the first tab's user: - port := os.Getenv("PORT") - if port == "" { - port = "8090" - } +```go include="/examples/chat/main.go" lines="55-63" +``` - log.Printf("🚀 Chat server starting on http://localhost:%s", port) - log.Println("📝 Open multiple browser tabs to test multi-user chat") - log.Println("💬 Messages are broadcast to all connected users") +### Step 4: wire it up - http.ListenAndServe(":"+port, nil) -} +```go include="/examples/chat/main.go" lines="179-198" ``` +`Handle` takes the controller and the initial state. Method names are the action +names, so `