diff --git a/cmd/site/main.go b/cmd/site/main.go index 8e002da..bb7a0ca 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -22,10 +22,23 @@ import ( counter "github.com/livetemplate/docs/content/recipes/counter/_app" patterns "github.com/livetemplate/docs/content/recipes/patterns/_app" + pe "github.com/livetemplate/docs/content/recipes/progressive-enhancement/_app" todos "github.com/livetemplate/docs/content/recipes/todos/_app" ) func main() { + // Origin allowlist shared by every recipe — the docs binary serves + // from one of these hosts in production (Fly prod, Fly staging) or + // localhost / devbox during dev. Defining it once avoids drift when + // new origins (e.g. preview deploys) are added. + allowedOrigins := []string{ + "https://livetemplate.fly.dev", + "https://livetemplate-docs-staging.fly.dev", + "http://localhost:8080", + "http://localhost:8084", + "http://devbox:8084", + } + mux := http.NewServeMux() // Recipes are mounted under /apps// to match the embed-lvt // `path=` attribute on docs pages. Tinkerdown's auto-proxy @@ -49,13 +62,7 @@ func main() { // WithPermissiveOriginCheck for random-port test setups. mux.Handle("/patterns/", http.StripPrefix("/patterns", patterns.Handler("/patterns", livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}), - livetemplate.WithAllowedOrigins([]string{ - "https://livetemplate.fly.dev", - "https://livetemplate-docs-staging.fly.dev", - "http://localhost:8080", - "http://localhost:8084", - "http://devbox:8084", - }), + livetemplate.WithAllowedOrigins(allowedOrigins), ))) // todos is mounted at /apps/todos/ — recipe-only (no public catalog @@ -63,13 +70,22 @@ func main() { // alice/bob inside todos.Handler), so cmd/site only supplies the // origin allowlist for the docs deploy targets. mux.Handle("/apps/todos/", http.StripPrefix("/apps/todos", todos.Handler( - livetemplate.WithAllowedOrigins([]string{ - "https://livetemplate.fly.dev", - "https://livetemplate-docs-staging.fly.dev", - "http://localhost:8080", - "http://localhost:8084", - "http://devbox:8084", - }), + livetemplate.WithAllowedOrigins(allowedOrigins), + ))) + + // progressive-enhancement is mounted twice from one handler package + // — the only difference is WithWebSocketDisabled on the /no-ws/ + // mount. Tier A (default) demonstrates JS+WS; Tier B (no-ws) shows + // the client falling back to HTTP fetch when the server rejects WS + // upgrades; Tier C (no-JS) is the same Tier A URL viewed with + // JavaScript disabled in the browser — the recipe page describes + // how to try it. + mux.Handle("/apps/progressive-enhancement/", http.StripPrefix("/apps/progressive-enhancement", pe.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + ))) + mux.Handle("/apps/progressive-enhancement/no-ws/", http.StripPrefix("/apps/progressive-enhancement/no-ws", pe.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + livetemplate.WithWebSocketDisabled(), ))) addr := ":" + getenv("RECIPES_PORT", "9091") diff --git a/content/recipes/progressive-enhancement/_app/controller.go b/content/recipes/progressive-enhancement/_app/controller.go new file mode 100644 index 0000000..91e5044 --- /dev/null +++ b/content/recipes/progressive-enhancement/_app/controller.go @@ -0,0 +1,125 @@ +package progressiveenhancement + +import ( + "fmt" + "strings" + "time" + + "github.com/go-playground/validator/v10" + "github.com/livetemplate/livetemplate" +) + +// TodoController is a singleton that holds dependencies (the validator). +type TodoController struct { + validate *validator.Validate +} + +// TodoState is pure data, cloned per session. +type TodoState struct { + Title string `json:"title"` + Items []Todo `json:"items" lvt:"persist"` + // InputTitle preserves the form value when validation fails so the + // user doesn't have to retype on the second attempt. + InputTitle string `json:"input_title" lvt:"persist"` +} + +// Todo represents a single todo item. +type Todo struct { + ID string `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` + CreatedAt string `json:"created_at"` +} + +// AddInput is the input struct for the Add action. The validator tags +// drive both server-side validation and the inline error display via +// .lvt.ErrorTag in the template. +type AddInput struct { + Title string `json:"title" validate:"required,min=3,max=100"` +} + +// Mount runs once per session and seeds the in-memory store with three +// sample todos. Flash messages are carried by the framework's lvt-flash +// cookie across PRG redirects — no URL-param bridging needed, which +// also avoids letting strangers spoof banners with ?success=... +func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + state.Title = "Progressive Enhancement Todo List" + + if len(state.Items) == 0 { + state.Items = []Todo{ + {ID: "1", Title: "Learn about progressive enhancement", Completed: true, CreatedAt: formatTime()}, + {ID: "2", Title: "Try the app without JavaScript", Completed: false, CreatedAt: formatTime()}, + {ID: "3", Title: "Enable JavaScript and see the difference", Completed: false, CreatedAt: formatTime()}, + } + } + + return state, nil +} + +// Add handles adding a new todo item. On validation failure, InputTitle +// is preserved on state so the form re-renders with the user's typed +// value rather than blanking it. +func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + var input AddInput + if err := ctx.BindAndValidate(&input, c.validate); err != nil { + state.InputTitle = ctx.GetString("title") + return state, err + } + + title := strings.TrimSpace(input.Title) + newID := fmt.Sprintf("%d", time.Now().UnixNano()) + state.Items = append(state.Items, Todo{ + ID: newID, + Title: title, + Completed: false, + CreatedAt: formatTime(), + }) + + state.InputTitle = "" + ctx.SetFlash("success", fmt.Sprintf("Added: %s", title)) + + return state, nil +} + +// Toggle flips a todo's completed status by ID. +func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + id := ctx.GetString("id") + found := false + for i := range state.Items { + if state.Items[i].ID == id { + state.Items[i].Completed = !state.Items[i].Completed + found = true + ctx.SetFlash("success", "Item updated") + break + } + } + if !found { + ctx.SetFlash("error", "Item not found") + } + return state, nil +} + +// Delete removes a todo by ID. +func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + id := ctx.GetString("id") + + deleteIndex := -1 + for i, item := range state.Items { + if item.ID == id { + deleteIndex = i + break + } + } + + if deleteIndex >= 0 { + state.Items = append(state.Items[:deleteIndex], state.Items[deleteIndex+1:]...) + ctx.SetFlash("success", "Item deleted") + } else { + ctx.SetFlash("error", "Item not found") + } + return state, nil +} + +func formatTime() string { + return time.Now().Format("2006-01-02 15:04:05") +} diff --git a/content/recipes/progressive-enhancement/_app/handler.go b/content/recipes/progressive-enhancement/_app/handler.go new file mode 100644 index 0000000..5e9f3ea --- /dev/null +++ b/content/recipes/progressive-enhancement/_app/handler.go @@ -0,0 +1,93 @@ +// Package progressiveenhancement is the docs-native fold of +// examples/progressive-enhancement (with the "no WebSocket upgrade" +// angle from examples/ws-disabled subsumed). The recipe demonstrates +// LiveTemplate's three graceful-degradation tiers from a single +// controller: +// +// Tier A — JS on + WS on: default options, instant updates over WS. +// Tier B — JS on + WS off: append WithWebSocketDisabled(); the client +// falls back to HTTP fetch transparently. +// Tier C — JS off: browser submits forms via raw POST; server +// responds with 303 See Other (POST-Redirect-GET). +// +// There is no main() here. Production runs via the docs single-binary +// container, mounted by cmd/site twice — once at +// /apps/progressive-enhancement/ (Tier A) and once at +// /apps/progressive-enhancement/no-ws/ (Tier B), the only difference +// being the option set passed in. +// +// Architecture notes: +// +// - The .tmpl ships as embed.FS and extracts once to a tmpdir at +// first Handler() call, mirroring counter/todos. livetemplate parses +// templates by filesystem path, so the extract is required. +// +// - Unlike the todos package, there is no handlerOnce singleton. +// Two mounts with different option sets cannot share a sync.Once, +// and PE has no expensive init (no DB) so caching the handler buys +// nothing. Each Handler() call builds a fresh livetemplate + mux; +// session state is cloned per session by the framework, so the two +// mounts stay isolated. +package progressiveenhancement + +import ( + "embed" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/go-playground/validator/v10" + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +//go:embed progressive-enhancement.tmpl +var templateFS embed.FS + +var ( + tmplPath string + tmplOnce sync.Once +) + +func extractTemplate() string { + tmplOnce.Do(func() { + dir, err := os.MkdirTemp("", "pe-tmpl-*") + if err != nil { + log.Fatalf("progressive-enhancement: mkdtemp: %v", err) + } + data, err := templateFS.ReadFile("progressive-enhancement.tmpl") + if err != nil { + log.Fatalf("progressive-enhancement: read embedded tmpl: %v", err) + } + tmplPath = filepath.Join(dir, "progressive-enhancement.tmpl") + if err := os.WriteFile(tmplPath, data, 0o644); err != nil { + log.Fatalf("progressive-enhancement: write tmpl: %v", err) + } + }) + return tmplPath +} + +// Handler returns the progressive-enhancement app as an http.Handler +// ready to mount. Production callers (cmd/site) supply +// WithAllowedOrigins (and optionally WithWebSocketDisabled for the +// Tier B mount). Test-server callers supply WithDevMode + +// WithPermissiveOriginCheck for random-port setups. +func Handler(opts ...livetemplate.Option) http.Handler { + controller := &TodoController{validate: validator.New()} + initialState := &TodoState{} + + baseOpts := []livetemplate.Option{ + livetemplate.WithParseFiles(extractTemplate()), + } + baseOpts = append(baseOpts, opts...) + + tmpl := livetemplate.Must(livetemplate.New("progressive-enhancement", baseOpts...)) + + mux := http.NewServeMux() + mux.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) + mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) + mux.HandleFunc("/livetemplate.css", e2etest.ServeCSS) + return mux +} diff --git a/content/recipes/progressive-enhancement/_app/progressive-enhancement.tmpl b/content/recipes/progressive-enhancement/_app/progressive-enhancement.tmpl new file mode 100644 index 0000000..d0b751e --- /dev/null +++ b/content/recipes/progressive-enhancement/_app/progressive-enhancement.tmpl @@ -0,0 +1,100 @@ + + + + + + + Progressive Enhancement — LiveTemplate + + {{if .lvt.DevMode}} + + + {{else}} + + + {{end}} + + +
+
+
+
+

{{.Title}}

+

This app works with or without JavaScript enabled

+
+
+ + + + + + {{if .lvt.HasFlash "success"}} + {{.lvt.Flash "success"}} + {{end}} + {{if .lvt.HasFlash "error"}} + {{.lvt.Flash "error"}} + {{end}} + + +
+
+ + +
+ {{.lvt.ErrorTag "title"}} +
+ +
+ +
+ {{if not .Items}} +

No todos yet. Add one above!

+ {{else}} + + + {{range .Items}} + + + + + + + {{end}} + +
+ +
+ + +
+ +
+ {{if .Completed}}{{.Title}}{{else}}{{.Title}}{{end}} + + {{.CreatedAt}} + +
+ + +
+
+ {{end}} +
+
+ + + diff --git a/content/recipes/progressive-enhancement/index.md b/content/recipes/progressive-enhancement/index.md new file mode 100644 index 0000000..a147556 --- /dev/null +++ b/content/recipes/progressive-enhancement/index.md @@ -0,0 +1,123 @@ +--- +title: "Progressive enhancement: graceful degradation" +description: "How a LiveTemplate app stays functional across three transports — full live, HTTP fetch fallback, and raw form POST — from a single controller and a single template. The only code-level switch between modes is one option flag." +source_repo: https://github.com/livetemplate/docs +source_path: content/recipes/progressive-enhancement/index.md +--- + +# Progressive enhancement: graceful degradation + +Most "live" frameworks have a load-bearing assumption: JavaScript is on, the WebSocket connects, and the user agent cooperates. LiveTemplate is built so that those assumptions can fail one at a time without the app breaking. The same controller, the same template, and the same form markup degrade through three modes: + +- **Tier A** — JS on, WS on (default). Actions travel over the WebSocket; UI updates as diff patches. +- **Tier B** — JS on, WS off (`WithWebSocketDisabled()`). The client falls back to plain HTTP `fetch()`; same diff patches over a different transport. +- **Tier C** — JS off entirely. Browser submits the form natively; server responds with `303 See Other` (POST-Redirect-GET). + +The interesting bit is that **the template doesn't change between tiers** — every form is `
`, every button is `