Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/ to match the embed-lvt
// `path=` attribute on docs pages. Tinkerdown's auto-proxy
Expand All @@ -49,27 +62,30 @@ 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
// like patterns). Auth is intrinsic to the recipe (BasicAuth with
// 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")
Expand Down
125 changes: 125 additions & 0 deletions content/recipes/progressive-enhancement/_app/controller.go
Original file line number Diff line number Diff line change
@@ -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")
}
93 changes: 93 additions & 0 deletions content/recipes/progressive-enhancement/_app/handler.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Progressive Enhancement — LiveTemplate</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
{{if .lvt.DevMode}}
<link rel="stylesheet" href="/livetemplate.css">
<script defer src="/livetemplate-client.js"></script>
{{else}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/livetemplate.css">
<script defer src="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
{{end}}
</head>
<body>
<main class="container">
<article>
<header>
<hgroup>
<h1>{{.Title}}</h1>
<p>This app works with or without JavaScript enabled</p>
</hgroup>
</header>

<!-- region:noscript-banner -->
<noscript>
<mark>
<strong>No JavaScript Mode:</strong> Using traditional HTTP form submissions with page reloads.
Each action reloads the page to show updates.
</mark>
</noscript>
<!-- endregion:noscript-banner -->

{{if .lvt.HasFlash "success"}}
<ins style="display:block;text-decoration:none">{{.lvt.Flash "success"}}</ins>
{{end}}
{{if .lvt.HasFlash "error"}}
<del style="display:block;text-decoration:none">{{.lvt.Flash "error"}}</del>
{{end}}

<!-- region:add-form -->
<form method="POST" name="add">
<fieldset role="group">
<input
type="text"
name="title"
value="{{.InputTitle}}"
placeholder="What needs to be done?"
{{.lvt.AriaInvalid "title"}}
autofocus
>
<button type="submit" name="add" {{.lvt.AriaDisabled "title"}}>Add</button>
</fieldset>
{{.lvt.ErrorTag "title"}}
</form>
<!-- endregion:add-form -->
</article>

<article>
{{if not .Items}}
<p><small>No todos yet. Add one above!</small></p>
{{else}}
<table>
<tbody>
{{range .Items}}
<tr>
<td>
<!-- region:toggle-form -->
<form method="POST" name="toggle" class="inline">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" name="toggle" class="compact secondary outline" aria-label="{{if .Completed}}Mark incomplete{{else}}Mark complete{{end}}">
{{if .Completed}}&#10003;{{else}}&#9675;{{end}}
</button>
</form>
<!-- endregion:toggle-form -->
</td>
<td>
{{if .Completed}}<s>{{.Title}}</s>{{else}}{{.Title}}{{end}}
</td>
<td>
<small>{{.CreatedAt}}</small>
</td>
<td>
<form method="POST" name="delete" class="inline">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" name="delete" class="compact contrast outline" aria-label="Delete todo">&#10005;</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</article>
</main>

</body>
</html>
Loading
Loading