From db6e8cd1fe8fca9daafb332f7908de54727c6dd9 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sun, 3 May 2026 19:32:54 +0000 Subject: [PATCH 1/7] docs(README): cross-link to https://livetemplate.fly.dev docs site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified framework documentation is now public — add a prominent link near the top of the README so users landing on this repo find the guides, recipes, and the live patterns catalog. The /examples and /patterns sections of the docs site index every app here. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 98d5f99..9fa723b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Example applications demonstrating LiveTemplate usage with various features and patterns. +📚 **Framework documentation:** **** — guides, recipes, patterns catalog (with live demos), full reference. The `/examples` and `/patterns` sections of the docs site index every app in this repo. + ## Showcase: Todo App The todo app demonstrates LiveTemplate's core features in ~150 lines of Go + ~80 lines of HTML: From da119032dd10549ac070339e3f47f8efea5aa56e Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Mon, 4 May 2026 23:07:56 +0000 Subject: [PATCH 2/7] feat(landing-demo): add minimal LiveTemplate counter for docs landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A purpose-built tiny counter app (~50 lines main.go + 30 lines counter.tmpl) intended to be deployed standalone as lt-landing-demo.fly.dev and same-origin proxied by the docs site so the home page can iframe a real, copy-pasteable LiveTemplate app. The app is the example. The code shown on the docs landing page IS this code — no abstractions over it, no curation. Increment / Decrement / Reset, per-session ephemeral state, multi-tab WebSocket sync within a session via the lvt:"persist" tag. Includes Dockerfile + fly.toml mirroring the lt-patterns pattern so the deploy story is identical to the existing patterns app. Co-Authored-By: Claude Opus 4.7 (1M context) --- landing-demo/Dockerfile | 33 ++++++++++++++++++++++++ landing-demo/README.md | 30 ++++++++++++++++++++++ landing-demo/counter.tmpl | 34 ++++++++++++++++++++++++ landing-demo/fly.toml | 21 +++++++++++++++ landing-demo/main.go | 54 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 landing-demo/Dockerfile create mode 100644 landing-demo/README.md create mode 100644 landing-demo/counter.tmpl create mode 100644 landing-demo/fly.toml create mode 100644 landing-demo/main.go diff --git a/landing-demo/Dockerfile b/landing-demo/Dockerfile new file mode 100644 index 0000000..097a576 --- /dev/null +++ b/landing-demo/Dockerfile @@ -0,0 +1,33 @@ +# Deployable image for the LiveTemplate landing-demo. The docs site +# proxies same-origin to this app so the home page can iframe a real +# LiveTemplate counter without cross-origin or chrome friction. + +ARG EXAMPLES_REF=main + +# ---- Build stage ---- +FROM golang:1.26-alpine AS go-builder +ARG EXAMPLES_REF +RUN apk add --no-cache git ca-certificates +ENV GOTOOLCHAIN=auto +WORKDIR /src +RUN git clone --depth=1 --branch=${EXAMPLES_REF} https://github.com/livetemplate/examples.git . +WORKDIR /src/landing-demo +RUN go mod download -C .. +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o /out/landing-demo . + +# ---- Runtime stage ---- +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +RUN adduser -D -u 1000 demo +WORKDIR /app +COPY --from=go-builder /out/landing-demo /usr/local/bin/landing-demo +# counter.tmpl is loaded via livetemplate.WithParseFiles at runtime as +# a relative path, so cwd must contain it at process start. +COPY --from=go-builder /src/landing-demo/counter.tmpl /app/counter.tmpl +RUN chown -R demo:demo /app +USER demo +EXPOSE 8080 +ENV PORT=8080 +CMD ["landing-demo"] diff --git a/landing-demo/README.md b/landing-demo/README.md new file mode 100644 index 0000000..d0e8646 --- /dev/null +++ b/landing-demo/README.md @@ -0,0 +1,30 @@ +# landing-demo + +The minimal LiveTemplate counter that powers the live demo on +[livetemplate.fly.dev](https://livetemplate.fly.dev). Deployed standalone +as `lt-landing-demo.fly.dev` and proxied same-origin by the docs site so +the landing page can iframe it without cross-origin friction. + +The whole app is `main.go` (~50 lines) plus `counter.tmpl` (~30 lines). +Same code, three transports: + +- **Without JS**: form POST, page reloads with new state. +- **With the JS client (fetch)**: same form POSTs via `fetch()`; the DOM is patched in place. +- **With WebSocket**: actions ride the WS; other tabs in the same session sync automatically. + +Per-session ephemeral state (no DB). Each visitor has their own counter; +their own tabs stay in sync via WebSocket. + +## Run locally + +```bash +go run . +``` + +Then open http://localhost:8080. + +## Deploy + +```bash +flyctl deploy --remote-only +``` diff --git a/landing-demo/counter.tmpl b/landing-demo/counter.tmpl new file mode 100644 index 0000000..d4008b3 --- /dev/null +++ b/landing-demo/counter.tmpl @@ -0,0 +1,34 @@ + + + + + + + Counter — LiveTemplate + + {{if .lvt.DevMode}} + + + {{else}} + + + {{end}} + + + +
+

{{.Count}}

+
+
+ + + +
+
+
+ + diff --git a/landing-demo/fly.toml b/landing-demo/fly.toml new file mode 100644 index 0000000..42e22f8 --- /dev/null +++ b/landing-demo/fly.toml @@ -0,0 +1,21 @@ +# Deployment of the LiveTemplate landing-demo. +# Iframed by the docs site (livetemplate.fly.dev) on the landing page, +# routed via tinkerdown's same-origin proxy at /demo/counter/. + +app = "lt-landing-demo" +primary_region = "sjc" + +[build] + dockerfile = "Dockerfile" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 512 diff --git a/landing-demo/main.go b/landing-demo/main.go new file mode 100644 index 0000000..cc9442f --- /dev/null +++ b/landing-demo/main.go @@ -0,0 +1,54 @@ +// Minimal LiveTemplate counter, sized for a landing-page demo. The whole +// app fits in this file; the template is a single counter.tmpl. Per-session +// ephemeral state — each visitor has their own counter, and their own tabs +// stay in sync over WebSocket via the `lvt:"persist"` field tag. +package main + +import ( + "log" + "net/http" + "os" + + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +type CounterController struct{} + +type CounterState struct { + Count int `json:"count" lvt:"persist"` +} + +func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) { + s.Count++ + return s, nil +} + +func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) { + s.Count-- + return s, nil +} + +func (c *CounterController) Reset(s CounterState, ctx *livetemplate.Context) (CounterState, error) { + s.Count = 0 + return s, nil +} + +func main() { + tmpl := livetemplate.Must(livetemplate.New("counter", + livetemplate.WithParseFiles("counter.tmpl"), + )) + handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{})) + + mux := http.NewServeMux() + mux.Handle("/", handler) + mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) + mux.HandleFunc("/livetemplate.css", e2etest.ServeCSS) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Printf("landing-demo listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, mux)) +} From 1436612d999d05f4980d1f9c12eb3588cab730e7 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Tue, 5 May 2026 04:19:39 +0000 Subject: [PATCH 3/7] fix(landing-demo): address bot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot + Claude bot feedback on PR #93: Cross-tab sync (Copilot, main.go:19): the README and docs landing page claim that same-session tabs sync over WebSocket, but lvt:"persist" alone only restores state on reconnect — it doesn't push updates to peer connections. Adds a Sync() method on the controller, which is the reserved name that signals the framework to dispatch peer-tab updates after every action. Together with the persist tag, peer tabs reload Count from the SessionStore and re-render with the new value. CSS conventions (Copilot, counter.tmpl:24): the previous template had a
-

{{.Count}}

-
-
- - - -
-
+
+

Count: {{.Count}}

+
+
+ + + +
+
+
diff --git a/landing-demo/landing_demo_test.go b/landing-demo/landing_demo_test.go new file mode 100644 index 0000000..25fd75f --- /dev/null +++ b/landing-demo/landing_demo_test.go @@ -0,0 +1,144 @@ +// Browser e2e for the landing-demo counter. Mirrors examples/counter's +// shape: spin up the server on a free port, drive a real Chrome via +// chromedp, exercise every controller method (Increment, Decrement, +// Reset, Sync). The Sync sub-test opens a second browser session in the +// same browser context so peer-tab dispatch can be observed. +package main + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + e2etest "github.com/livetemplate/lvt/testing" +) + +func TestMain(m *testing.M) { + e2etest.CleanupChromeContainers() + code := m.Run() + e2etest.CleanupChromeContainers() + os.Exit(code) +} + +func TestLandingDemoE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + serverPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("get free server port: %v", err) + } + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("get free debug port: %v", err) + } + + serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() + + chromeCmd := e2etest.StartDockerChrome(t, debugPort) + defer e2etest.StopDockerChrome(t, debugPort) + _ = chromeCmd + + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + defer cancel() + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + t.Run("Initial_Load_Renders_Counter_At_Zero", func(t *testing.T) { + var bodyHTML string + if err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`output[aria-live="polite"]`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.OuterHTML(`body`, &bodyHTML, chromedp.ByQuery), + ); err != nil { + t.Fatalf("initial load: %v", err) + } + // Counter starts at zero and is rendered inside the live region. + if !strings.Contains(bodyHTML, "0") { + t.Errorf("initial Count != 0; body = %s", bodyHTML) + } + if !strings.Contains(bodyHTML, `aria-live="polite"`) { + t.Errorf("counter is not in a live region; screen readers won't announce updates") + } + }) + + t.Run("UI_Standards_Pico_And_CSP_Clean", func(t *testing.T) { + var violations string + err := chromedp.Run(ctx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL') + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + if (document.querySelector('style')) v.push('disallowed