Type a name and hit Say hi. The submit calls a Go method, the server re-renders the template, and only the changed HTML comes back.
+Type a name. Your headline updates and your line joins the wall, along with everyone else's — including anyone else reading this page right now. Open this page in a second tab and watch it land there too, with no reload. The code for all of it is directly below.
<!DOCTYPE html>
-<html><head>
- <script defer src="{{lvtClientScriptURL}}"></script>
-</head><body>
- <h1>Hello, {{.Name}}</h1>
- <form method="POST">
- <input name="name" placeholder="Your name">
- <button name="greet">Say hi</button>
- </form>
-</body></html>
+ <script defer src="{{lvtClientScriptURL}}"></script>
+<h1>Hello, {{.Name}}</h1>
+<form method="POST">
+ <input name="name" placeholder="Your name" required {{.lvt.AriaInvalid "name"}}>
+ {{.lvt.ErrorTag "name"}}
+ <button name="greet">Say hi</button>
+</form>
+<ul>
+ {{range .Wall}}<li><b>{{.Name}}</b> said hi {{.At}}</li>{{end}}
+</ul>
package main
-import (
- "net/http"
- lvt "github.com/livetemplate/livetemplate"
-)
-type State struct{ Name string }
+ app.go — the whole controller, and the wiring
+type State struct {
+ Name string // your headline, synced across your tabs
+ Wall []Greeting // the shared list, synced across everyone
+}
+func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
+ ctx.Subscribe(ctx.SelfTopic()) // your own tabs
+ ctx.Subscribe("wall") // every visitor
+ s.Name, s.Wall = a.nameFor(ctx.GroupID()), a.snapshot()
+ return s, nil
+}
+func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
+ if err := ctx.ValidateForm(); err != nil {
+ return s, err // re-runs the HTML rules
+ }
+ name := sanitize(ctx.GetString("name"))
+ if name == "" {
+ return s, lvt.NewFieldError("name", errors.New("Please enter a name"))
+ }
+ if strings.EqualFold(name, "admin") { // a rule HTML can't express
+ return s, lvt.NewFieldError("name", errors.New(`"admin" is reserved`))
+ }
+ a.saveName(ctx.GroupID(), name) // so Refresh can re-read it on your tabs
+ a.appendWall(name) // the one shared list everyone sees
+ // A publish skips the connection that called it, so this one renders
+ // from the values returned here.
+ s.Name, s.Wall = name, a.snapshot()
+ ctx.Publish(ctx.SelfTopic(), "Refresh", nil) // your other tabs
+ ctx.Publish("wall", "WallRefresh", nil) // everyone else
+ return s, nil
+}
+// A publish just runs an ordinary action on the peers it reaches.
+func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
+ s.Name = a.nameFor(ctx.GroupID()); return s, nil
+}
+func (a *App) WallRefresh(s State, ctx *lvt.Context) (State, error) {
+ s.Wall = a.snapshot(); return s, nil
+}
+func main() {
+ app := lvt.Must(lvt.New("wall",
+ lvt.WithParseFiles("app.tmpl"),
+ // Developer topics are deny-all; admit just this one. Each user's
+ // own SelfTopic() is always permitted.
+ lvt.WithTopicACL(func(topic, _ string, _ *http.Request) (bool, error) {
+ return topic == "wall", nil
+ })))
+ http.ListenAndServe(":8080", app.Handle(&App{}, lvt.AsState(&State{Name: "there"})))
+}
+ That is the whole interface: a template, four methods and a main, with no JavaScript you had to write. The running demo adds about forty more lines of ordinary Go — sanitize, the map writes behind saveName, a twenty-line cap in appendWall and a per-session throttle — none of which is framework API. Read the real file.
Every section below points at lines you have just read — except the pending state, which the wall has no slow work to demonstrate, and which says so. Each one also runs here as its own app, so you can check the claim rather than take it.
+<button name="greet"> calls Greet. That's the whole binding — no hx-post, no onClick, no route to register. Strip the wall away and the same idea is a complete app in twenty lines, this time with nothing elided at all.
type State struct{ Name string }
type App struct{}
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
s.Name = ctx.GetString("name")
@@ -101,25 +173,13 @@ func main() {
That's the whole app, about 20 lines of Go and some standard HTML. Everything else on this page is a small diff on it.
-Everything below is the same greeting app. It picks up a plain POST fallback, then validation, then a pending state, then live updates over a WebSocket. It stays one Go codebase, and application logic never ends up in two places.
+There are lvt-* attributes, but only for behavior HTML cannot express — a debounce, a keyboard shortcut, a class toggle. They're an escape hatch, not the interface.
When the script loads, the client enhances the submit and patches the headline. When it doesn't, the same <form> does a native POST and the server renders the page. There's no if jsEnabled branch to write. Both cards below run the same app — the right one has scripting switched off.
One <script> tag is the only difference between the two cards below. With it, the client enhances the submit and patches the headline. Without it, the same <form> does a native POST and the server renders the page. There's no if jsEnabled branch anywhere in the Go.
<!-- the only line that flips the transport -->
-<script defer src="{{lvtClientScriptURL}}"></script>
-<form method="POST"> <!-- JS on → fetch + patch · JS off → native POST -->
- <input name="name">
- <button name="greet">Say hi</button>
-</form>
+ <script defer src="{{lvtClientScriptURL}}"></script>
Standard attributes like required run again server-side via ctx.ValidateForm(), then you add the rules HTML cannot express. Try an empty submit, or type admin.
Both halves are in the app above. The input carries required, and ctx.ValidateForm() re-runs exactly that rule on the server — a client that skipped it, scripting off or a direct POST, gets the same answer. Then strings.EqualFold(name, "admin") adds the rule HTML has no way to state.
The template side is the other two lines you read: {{.lvt.AriaInvalid "name"}} marks the field, {{.lvt.ErrorTag "name"}} is where the message lands. Returning an error from Greet is the whole mechanism — there's nothing to route. Scroll up and type admin, or try the smaller app here.
<input name="name" required {{.lvt.AriaInvalid "name"}}>
-{{.lvt.ErrorTag "name"}}
- func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
- if err := ctx.ValidateForm(); err != nil {
- return s, err // re-runs the HTML rules
- }
- name := strings.TrimSpace(ctx.GetString("name"))
- if strings.EqualFold(name, "admin") {
- return s, lvt.NewFieldError("name",
- errors.New(`"admin" is reserved`))
- }
- s.Name = name
- return s, nil
-}
- The slow work runs on the server, so you can render its pending state with ordinary template conditionals. If you'd rather not touch the Go code at all, there's a button-level attribute for that.
+This is the one thing the app above cannot show you: the wall answers instantly, so it has no pending state to render. Both apps below do have slow work. The first is the way to reach for — the pending flag is a template variable, so the spinner is ordinary Go and ordinary HTML, with no new attribute to learn.
<button {{if .lvt.Pending}}type="button" aria-busy="true"
disabled{{else}}name="greet"{{end}}>Say hi</button>
lvt.Async(ctx,
- func(context.Context) (string, error) {
- time.Sleep(700 * time.Millisecond)
- return name, nil
- },
+ func(context.Context) (string, error) { return slowWork() },
func(s State, name string, _ error) (State, error) {
s.Name = name
return s, nil
@@ -224,9 +255,9 @@ func main() {
No second action to wire up and no Loading field in state, though it does need a live session for the completion render.
<button name="greet"
lvt-el:addClass:on:pending="is-loading"
lvt-el:removeClass:on:done="is-loading">Say hi</button>
-func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
- time.Sleep(700 * time.Millisecond)
- if name := strings.TrimSpace(
- ctx.GetString("name")); name != "" {
- s.Name = name
- }
- return s, nil
-}
- This one keeps pending UI out of server state and works as a single request/response. It's the right one when the spinner is just button chrome rather than something the app cares about.
+Two lvt-* attributes, and the Go is untouched. This is what the escape hatch is for: the spinner is button chrome, not something the app models. It also works as a single request/response, where A needs a live session for its completion render.
Subscribe the session to its own topic and publish after the handler runs. The same live session also lets the server push first, without waiting for a click.
+This is the part of Greet worth re-reading. ctx.SelfTopic() reaches your own tabs; "wall" reaches every visitor. Same two calls, different topic — that's the entire difference between "keeps my tabs in sync" and "multiplayer".
Open this page in a second tab, greet in either, and the headline updates in both.
- -func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
- ctx.Subscribe(ctx.SelfTopic()) // your tabs share a topic
- s.Name = a.name(ctx.GroupID())
- return s, nil
-}
-func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
- a.setName(ctx.GroupID(), sanitize(ctx.GetString("name")))
- ctx.Publish(ctx.SelfTopic(), "Refresh", nil) // run Refresh on your other tabs
- return s, nil
-}
-// Refresh is an ordinary action — the publish above runs it on each peer tab.
-func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
- s.Name = a.name(ctx.GroupID())
- return s, nil
-}
-
- There's no magic here. The publish just runs your Refresh method on your other tabs. It re-reads the shared data and returns new state, and the framework does the diffing and patching. The server can start the same cycle itself with sess.TriggerAction("ServerRefresh", nil).
Swap the self-topic for a shared one, admitted by a small ACL, and the same publish fans out to every visitor. The two cards below are separate sessions, like two different people. Greet in one and the line shows up on both walls.
+The two cards below are separate sessions, like two different people. Greet in one and the line shows up on both walls — while the headlines stay independent.
func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
- ctx.Subscribe("wall") // shared, cross-user topic
- return s, nil
-}
-func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
- a.append(sanitize(ctx.GetString("name")))
- ctx.Publish("wall", "WallRefresh", nil)
- return s, nil
-}
- lvt.WithTopicACL(
- func(topic, _ string, _ *http.Request) (bool, error) {
- return topic == "wall", nil // deny-all by default
- })
- Each card is its own session, so the headlines stay independent, but the wall is global. It's the same two pubsub calls as step 5, with a different topic.
-sess.TriggerAction("ServerRefresh", nil)
+ You already read the WithTopicACL in main that admits "wall" — developer topics are deny-all until one is named. This is the same publish path with no user action behind it: the "the server said hi at …" line in the cards above, pushed on a timer.
Templates get split into static structure, which is cached, and dynamic values, so a greeting comes back as {"tree":{"0":"Ada"}} instead of a page.