diff --git a/content/assets/brand.css b/content/assets/brand.css index 290a645..f313e53 100644 --- a/content/assets/brand.css +++ b/content/assets/brand.css @@ -74,7 +74,8 @@ --lt-code-comment: #6B6862; --lt-code-string: #4A6B52; --lt-code-keyword: #2F5D8A; - --lt-code-name: #5A5560; /* declared names: func names, types, HTML tags */ + --lt-code-name: #6B4A7A; /* declared names: func names, HTML tags */ + --lt-code-type: #7D5226; /* built-in types, numbers, booleans */ } /* ── Prism, neutralised ── @@ -87,12 +88,13 @@ pre[class*="language-"] { color: var(--pre-text); text-shadow: none } .token.comment, .token.prolog, .token.doctype, .token.cdata { color: var(--lt-code-comment) } /* What the code declares or invokes, and what an HTML element IS. */ .token.tag, .token.function, .token.class-name { color: var(--lt-code-name) } +/* The things a value IS: Go's built-in types, literals. */ +.token.builtin, .token.number, .token.boolean, .token.constant { color: var(--lt-code-type) } .token.punctuation, .token.operator, .token.entity, .token.url, -.token.property, .token.boolean, .token.number, -.token.constant, .token.symbol, .token.deleted, .token.namespace, +.token.property, .token.symbol, .token.deleted, .token.namespace, .token.regex, .token.important, .token.variable { color: var(--pre-text) } .token.selector, .token.attr-name, .token.string, .token.char, -.token.builtin, .token.inserted, +.token.inserted, .language-css .token.string, .style .token.string { color: var(--lt-code-string) } .token.atrule, .token.attr-value, .token.keyword { color: var(--lt-code-keyword) } diff --git a/content/assets/landing.css b/content/assets/landing.css index 8124853..50a2cf2 100644 --- a/content/assets/landing.css +++ b/content/assets/landing.css @@ -174,9 +174,10 @@ section { scroll-margin-top: 72px } .close { margin: 28px 0 0 } .note { font-size: 15px; line-height: 1.6; color: var(--body-muted); max-width: 62ch } -.hero { padding: 88px 0 64px } +.hero { padding: 88px 0 40px } .hero .sub { font-size: 18px; line-height: 1.6; color: var(--body); margin: 0; max-width: 56ch } -#whole-app { padding: 56px 0; border-top: 1px solid var(--line) } +/* The app is the hero's payload, not the next topic — no rule between them. */ +.lead-in { padding: 0 0 56px } .intro { padding: 56px 0 24px; border-top: 1px solid var(--line) } .step { padding: 40px 0; border-top: 1px solid var(--line-soft) } .cta { @@ -327,6 +328,13 @@ section { scroll-margin-top: 72px } } .pair > * { min-width: 0 } +/* The hero's two snippets are read in sequence — template, then the Go that + serves it — not compared. Side by side they get ~420px each at 1440px, and + these signatures run past 60 characters, so every interesting line clipped. + Full width fits them without a scrollbar. */ +.stack { display: grid; gap: 16px; margin-bottom: 4px } +.stack > * { min-width: 0 } + /* ══ On the wire ═════════════════════════════════════════════════════════ */ .wire { diff --git a/content/index.md b/content/index.md index b2b83e3..5c6f527 100644 --- a/content/index.md +++ b/content/index.md @@ -27,13 +27,14 @@ layout: landing @@ -51,43 +52,114 @@ layout: landing -
-
Step 1 · Render
-

This is the whole app, running on this page.

-

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.

+
+
The app
+

A shared greeting wall, running on this page.

+

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.

-
greet · running in this page
+
greet-wall · live, shared with every visitor
-```embed-lvt path="/apps/greet/" upstream="http://localhost:9091" height="130px" +```embed-lvt path="/apps/greet-wall/" upstream="http://localhost:9091" height="230px" ```
-
+
-
app.tmpl — the entire template
-
<!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>
+
app.tmpl — the whole template, verbatim
+
<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>
-
app.go — the entire program
-
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.

+
+ +
+
What's going on
+

The parts of that worth a second look.

+

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.

+
+ +
+
No attributes
+

The button's name is the action.

+

<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.

+ +
+
+
greet · the smallest version
+
+ +```embed-lvt path="/apps/greet/" upstream="http://localhost:9091" height="130px" +``` + +
+
+
+
app.go · complete, nothing elided
+
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() {
     
-
-
on the wire · WebSocket
-
▲ action · 40 B {"action":"greet","data":{"name":"Ada"}}
-
▼ diff  · 20 B {"tree":{"0":"Ada"}}
-
- -

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.

-
- -
-
One app, five more steps
-

Adding the things an app usually ends up needing.

-

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.

-
-
Step 2 · Works without JavaScript
-

The same app works with JavaScript disabled.

-

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.

+
+
No JavaScript
+

The same app works with 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.

@@ -137,23 +197,20 @@ func main() {
-
app.tmpl — one form, either transport
-
<!-- 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>
+
app.tmpl · the line that flips the transport
+
<script defer src="{{lvtClientScriptURL}}"></script>
-
-
Step 3 · Validation
-

Validation rules written in HTML, re-checked in Go.

-

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.

+
+
Validation
+

The HTML rule runs again in Go.

+

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.

-
greet-validate · server-checked
+
greet-validate · the same pair, on its own
```embed-lvt path="/apps/greet-validate/" upstream="http://localhost:9091" height="160px" @@ -162,46 +219,23 @@ func main() {
-
-
-
app.tmpl · the rule, written once
-
<input name="name" required {{.lvt.AriaInvalid "name"}}>
-{{.lvt.ErrorTag "name"}}
-
-
-
app.go · re-check, then add your own rule
-
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
-}
-
-
-
-
on the wire · HTTP fetch
+
on the wire · the rejected submit
{"action":"greet","data":{"name":"admin"}}
{"meta":{"errors":{"name":"\"admin\" is reserved"}}}
-
-
Step 4 · Loading state
-

Two ways to show a pending state.

-

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.

+
+
Pending state
+

Slow work has a pending state you can render.

+

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.

-
A · server-owned, template variables only
+
A · server-owned — template variables, no attributes
-
greet-async · server-owned pending
+
greet-async
```embed-lvt path="/apps/greet-async/" upstream="http://localhost:9091" height="130px" @@ -212,10 +246,7 @@ func main() {
<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.

-
B · button-level escape hatch
+
B · the escape hatch, when the Go should not change
-
greet-loading · attribute version
+
greet-loading
```embed-lvt path="/apps/greet-loading/" upstream="http://localhost:9091" height="130px" @@ -237,32 +268,15 @@ func main() {
<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.

- -
-
on the wire · A, server-side
-
{"action":"greet","data":{"name":"Ada"}}
-
{"tree":{"1":{"aria-busy":"true","disabled":true,"type":"button"}}}
-
{"tree":{"0":"Ada","1":{"name":"greet"}}}
-
on the wire · B, attribute version
-
{"action":"greet","data":{"name":"Ada"}}   ▼ {"tree":{"0":"Ada"}}
-
-
-
Step 5 · Sync your own tabs
-

Keeping your own tabs in sync with two calls.

-

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.

+
+
Multi-user
+

Two calls sync your tabs. Changing the topic syncs everyone.

+

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".

1 state changes @@ -271,47 +285,7 @@ func main() { 4 patch the browser
-
-
greet-wall · WebSocket on
-
- -```embed-lvt path="/apps/greet-wall/" upstream="http://localhost:9091" height="200px" -``` - -
-
-

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).

- -
-
on the wire · WebSocket
-
▲ this tab   {"action":"greet","data":{"name":"Ada"}}
-
▼ other tab  {"tree":{"0":"Ada"}}
-
▼ server push {"tree":{"3":{"0":"15:04:08"}}} — no ▲; just the changed value goes down
-
-
- -
-
Step 6 · A wall everyone shares
-

Changing the topic makes it cross-user.

-

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.

@@ -334,51 +308,12 @@ func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
-
-
-
app.go · the topic is the only difference
-
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
-}
-
-
-
app.go · admit the shared topic
-
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.

-
-
- -
-
on the wire · WebSocket
-
▲ visitor 1 {"action":"greet","data":{"name":"Ada"}}
-
▼ visitor 2 {"tree":{"3":[["a",[{"0":"Ada","1":"15:04"}]]]}}
+
+
app.go · the server can start the same cycle
+
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.

-
-
-
-
-
Only the diff goes over the wire
-

Only the changed values go over the wire.

-

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.

-
-
-
full HTML2.4 KB
-
-
lvt diff340 B
-
-
86% smaller per update
-
-
diff --git a/examples/greet-wall/wall.go b/examples/greet-wall/wall.go index 49a2ca5..492fd7c 100644 --- a/examples/greet-wall/wall.go +++ b/examples/greet-wall/wall.go @@ -119,10 +119,21 @@ func (c *Controller) OnConnect(s State, ctx *livetemplate.Context) (State, error // headline, Step 5) and the wall topic -> WallRefresh (everyone's list, Step // 6). The calling connection is excluded from both — it already has the result. func (c *Controller) Greet(s State, ctx *livetemplate.Context) (State, error) { + // Re-run the rules the template already declared (the input carries + // `required`) on the server. A client that never enforced them — scripting + // off, or a direct POST — gets the same answer as one that did. + if err := ctx.ValidateForm(); err != nil { + return s, err + } name := sanitize(ctx.GetString("name")) if name == "" { return s, livetemplate.NewFieldError("name", errors.New("Please enter a name")) } + // A rule HTML has no way to state. The wall is public, so one name is + // held back rather than letting a visitor pose as the server. + if strings.EqualFold(name, "admin") { + return s, livetemplate.NewFieldError("name", errors.New(`"admin" is reserved`)) + } group := ctx.GroupID() now := time.Now() diff --git a/examples/greet-wall/wall.tmpl b/examples/greet-wall/wall.tmpl index 210034f..d8350ec 100644 --- a/examples/greet-wall/wall.tmpl +++ b/examples/greet-wall/wall.tmpl @@ -38,7 +38,7 @@

Hello, {{.Name}}

- + {{.lvt.ErrorTag "name"}}