From 66346016953e70bd53680495c8c4a20e94995f5b Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 18 Jul 2026 10:41:45 +0000 Subject: [PATCH] feat(examples): file-tree recursive-template recipe; adopt v0.19.0 + lvt v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the pinned toolchain and adds the recipe for the feature it unlocks: - livetemplate v0.18.0 -> v0.19.0 (recursive {{template}}, per-leaf range diff) - lvt v0.1.6 -> v0.2.0, lvt/components v0.1.2 -> v0.1.8 examples/file-tree renders a directory tree from a single self-referential {{define "node"}} block — the shape {{template}} inlining cannot express, and the reason recursion was rejected before v0.19.0. Starring a file four levels down scopes the update to that leaf instead of re-sending the branch. Tier 1 throughout: every control is a plain + + {{if .Expanded}} + + {{end}} + {{else}} + +
+ +
+ {{.Name}} +
+ {{end}} + +{{end}} +``` + +## Why this used to fail + +LiveTemplate normally **inlines** `{{template}}` calls when it parses: it +splices the invoked body into the caller, producing one flat template it can +analyze for statics and dynamics. A self-referential call has no fixed point +to inline toward — expanding `node` yields another `node` to expand, forever. +So recursion was rejected at parse time. + +From v0.19.0, the parser first finds templates reachable from themselves and +leaves *those* calls un-inlined, evaluating them at build time instead. The +recursive region becomes nested tree nodes rather than flattened markup, which +is what keeps it inside the reactive tree instead of degrading to opaque HTML. + +## Why the update stays small + +Because the recursion produces real nested structure, a change deep in the +tree diffs against that structure. Starring `query.go` four levels down sends +an update addressed to that one node — not a re-render of `/internal`, and not +a re-send of the branch containing it. The sibling `migrate.go` isn't just +visually unchanged; its DOM node is never replaced. + +That property is what makes deep trees practical. Before per-leaf diffing, a +change anywhere under a branch meant re-sending the branch, which on a +five-level tree is roughly a hundred times more bytes than the change itself. + +## Keys matter here + +Each `
  • ` carries `data-key="{{.Path}}"`, and path — not name — is the right +choice. Sibling names repeat across directories: two `README.md` files in +different folders are different nodes. Keying on name would let the diff +engine confuse them and move the wrong row; a full path cannot collide. + +## Depth is capped + +Recursion runs until the data stops nesting, so data that refers to itself +would recurse forever. LiveTemplate caps invocation depth at **128** by +default, surfacing an error rather than overflowing the stack. Raise it with +`WithMaxTemplateDepth(n)` or `LVT_MAX_TEMPLATE_DEPTH` only when your data is +legitimately deeper — see the +[template support matrix](/references/template-support-matrix#recursion-depth) +for the full behavior, including why a too-low cap can go unnoticed on first +render. diff --git a/content/recipes/apps/index.md b/content/recipes/apps/index.md index 3a89aa5..d0d0a29 100644 --- a/content/recipes/apps/index.md +++ b/content/recipes/apps/index.md @@ -33,6 +33,7 @@ All examples follow the [progressive complexity](https://github.com/livetemplate | `counter/` | 1 | Counter with logging + graceful shutdown | None | | `chat/` | 1+2 | Real-time multi-user chat | `lvt-fx:scroll` | | `seat-picker/` | 1 | **Cross-user** real-time seat booking (different users, shared topic) | None | +| `file-tree/` | 1 | **Recursive template** — a directory tree whose template invokes itself; deep edits scope to one leaf | None | | `todos/` | 1+2 | Full CRUD with SQLite, auth, modal + toast components | `lvt-on:change`, `lvt-fx:animate`, `lvt-fx:highlight`, `lvt-el:setAttr` | | `flash-messages/` | 1 | Flash notification patterns | None | | `avatar-upload/` | 1+2 | File upload with progress (Volume mode) | `lvt-upload` | diff --git a/content/tinkerdown.yaml b/content/tinkerdown.yaml index a96f96b..8ee7472 100644 --- a/content/tinkerdown.yaml +++ b/content/tinkerdown.yaml @@ -186,6 +186,8 @@ navigation: path: "recipes/apps/chat.md" - title: "Seat Picker" path: "recipes/apps/seat-picker.md" + - title: "File Tree" + path: "recipes/apps/file-tree.md" - title: "Avatar Upload" path: "recipes/apps/avatar-upload.md" - title: "Upload Modes" diff --git a/examples/file-tree/cmd/main.go b/examples/file-tree/cmd/main.go new file mode 100644 index 0000000..1a99950 --- /dev/null +++ b/examples/file-tree/cmd/main.go @@ -0,0 +1,59 @@ +// Command file-tree starts the recursive-template recipe as a standalone HTTP +// server. Production deployments embed the recipe via the docs-site cmd/site +// aggregator; this entry point exists so developers can run +// `go run ./examples/file-tree/cmd` to iterate on the recipe in isolation, and +// so the cross-repo test harness can drive a real browser against a real +// process. +// +// Flags / environment: +// +// PORT listen port (default 8080) +// LVT_DEV_MODE=true alias for --dev (set by e2etest.StartTestServer so the +// subprocess inherits dev-mode without needing to pass +// flags through the test harness) +// --dev relax origin checks for localhost development (so the +// WebSocket upgrader accepts requests from arbitrary +// localhost ports); the production allowlist applies when +// the flag is absent. +package main + +import ( + "flag" + "log" + "net/http" + "os" + + "github.com/livetemplate/livetemplate" + + filetree "github.com/livetemplate/docs/examples/file-tree" +) + +func main() { + dev := flag.Bool("dev", false, "enable dev mode (permissive origin checks, dev-mode template reload)") + flag.Parse() + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + var opts []livetemplate.Option + if *dev || os.Getenv("LVT_DEV_MODE") == "true" { + // Dev mode also relaxes the WebSocket origin check (allows all + // origins), so localhost on any port works during development. + opts = append(opts, livetemplate.WithDevMode(true)) + } else { + opts = append(opts, livetemplate.WithAllowedOrigins([]string{ + "https://livetemplate.fly.dev", + "https://livetemplate-docs-staging.fly.dev", + "http://localhost:8080", + "http://localhost:8084", + "http://devbox:8084", + })) + } + + log.Printf("file-tree listening on :%s", port) + if err := http.ListenAndServe(":"+port, filetree.Handler(opts...)); err != nil { + log.Fatal(err) + } +} diff --git a/examples/file-tree/file-tree.tmpl b/examples/file-tree/file-tree.tmpl new file mode 100644 index 0000000..7e84d8f --- /dev/null +++ b/examples/file-tree/file-tree.tmpl @@ -0,0 +1,50 @@ + + + + + File Tree + + + + + + +
    +

    File tree

    +

    A directory contains directories, so the template that renders one + invokes itself for each child. Expand a folder or star a file + and only that node's markup is sent over the wire.

    + +
      + {{template "node" .Root}} +
    +
    + + + +{{define "node"}} +
  • + {{if .IsDir}} +
    + +
    + {{if .Expanded}} + + {{end}} + {{else}} + +
    + +
    + {{.Name}} +
    + {{end}} +
  • +{{end}} diff --git a/examples/file-tree/file_tree_test.go b/examples/file-tree/file_tree_test.go new file mode 100644 index 0000000..2c44798 --- /dev/null +++ b/examples/file-tree/file_tree_test.go @@ -0,0 +1,399 @@ +package filetree + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +// ctxWithValue builds the Context an action sees when a button carrying +// value="" is clicked, which is how the template tells an action which +// of the many rendered nodes was hit. +func ctxWithValue(path string) *livetemplate.Context { + return livetemplate.NewContext(context.Background(), "", map[string]interface{}{ + "value": path, + }) +} + +// --------------------------------------------------------------------------- +// White-box logic tests — fast, deterministic, no browser. These pin the tree +// walk (does a click on a deeply nested node reach the right node?) without +// the cost of a real browser. The render test below then proves the recursive +// template itself resolves, which is the thing this recipe exists to show. +// --------------------------------------------------------------------------- + +// deepFile is the deepest leaf in the fixture. Tests reference it by name so +// a change to sampleTree's shape fails loudly here rather than silently +// weakening the "deep" in these assertions. +const deepFile = "/internal/store/sql/query.go" + +func TestUpdateNode_ReachesDeepestLeaf(t *testing.T) { + root := sampleTree() + + if ok := updateNode(&root, deepFile, func(n *Node) { n.Starred = true }); !ok { + t.Fatalf("updateNode did not find %s", deepFile) + } + + var found *Node + var walk func(*Node) + walk = func(n *Node) { + if n.Path == deepFile { + found = n + return + } + for i := range n.Children { + walk(&n.Children[i]) + } + } + walk(&root) + + if found == nil { + t.Fatalf("%s missing from tree after update", deepFile) + } + if !found.Starred { + t.Errorf("expected %s to be starred", deepFile) + } +} + +func TestUpdateNode_UnknownPathIsANoop(t *testing.T) { + root := sampleTree() + if updateNode(&root, "/nope/missing.go", func(n *Node) { n.Starred = true }) { + t.Error("updateNode reported a match for a path that is not in the tree") + } +} + +// TestUpdateNode_StopsAtFirstMatch guards the early return. Paths are unique +// in the fixture, so the observable contract is simply that the walk reports +// a match and does not keep descending afterwards. +func TestUpdateNode_StopsAtFirstMatch(t *testing.T) { + root := sampleTree() + calls := 0 + if !updateNode(&root, "/cmd", func(n *Node) { calls++ }) { + t.Fatal("updateNode did not find /cmd") + } + if calls != 1 { + t.Errorf("expected the mutator to run once, ran %d times", calls) + } +} + +func TestToggle_FlipsExpanded(t *testing.T) { + c := &Controller{} + s := State{Root: sampleTree()} + + // /internal starts collapsed in the fixture. + before := findNode(t, &s.Root, "/internal") + if before.Expanded { + t.Fatal("fixture changed: /internal is expected to start collapsed") + } + + s, err := c.Toggle(s, ctxWithValue("/internal")) + if err != nil { + t.Fatalf("Toggle: %v", err) + } + if !findNode(t, &s.Root, "/internal").Expanded { + t.Error("Toggle did not expand /internal") + } + + s, err = c.Toggle(s, ctxWithValue("/internal")) + if err != nil { + t.Fatalf("Toggle (second): %v", err) + } + if findNode(t, &s.Root, "/internal").Expanded { + t.Error("Toggle did not collapse /internal on the second click") + } +} + +func TestStar_FlipsStarredOnALeaf(t *testing.T) { + c := &Controller{} + s := State{Root: sampleTree()} + + s, err := c.Star(s, ctxWithValue(deepFile)) + if err != nil { + t.Fatalf("Star: %v", err) + } + if !findNode(t, &s.Root, deepFile).Starred { + t.Errorf("Star did not mark %s", deepFile) + } +} + +func TestMount_SeedsTreeOnce(t *testing.T) { + c := &Controller{} + + s, err := c.Mount(State{}, nil) + if err != nil { + t.Fatalf("Mount: %v", err) + } + if s.Root.Path == "" { + t.Fatal("Mount did not seed the tree") + } + + // A second Mount (reconnect) must not discard state the visitor built up. + s = mustStar(t, c, s, deepFile) + s, err = c.Mount(s, nil) + if err != nil { + t.Fatalf("Mount (second): %v", err) + } + if !findNode(t, &s.Root, deepFile).Starred { + t.Error("Mount reset a tree that was already seeded, losing session state") + } +} + +// TestHandler_RendersRecursiveDepth is the version gate. The template invokes +// itself, which livetemplate could not do before v0.19.0 — under an older core +// the parse fails outright. Asserting on the deepest leaf proves the recursion +// resolved all the way down rather than stopping at the first level. +func TestHandler_RendersRecursiveDepth(t *testing.T) { + srv := httptest.NewServer(Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET status = %d, want 200", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + html := string(body) + + // /cmd/server/main.go sits three directories down and every one of them is + // expanded in the fixture, so it must be present on first paint. + for _, want := range []string{ + `data-key="/"`, + `data-key="/cmd"`, + `data-key="/cmd/server"`, + `data-key="/cmd/server/main.go"`, + } { + if !strings.Contains(html, want) { + t.Errorf("first render missing %s — recursion did not reach that depth:\n%s", want, html) + } + } + + // /internal is collapsed, so its children must NOT be rendered. This keeps + // the test honest: it would still pass if the template rendered every node + // regardless of Expanded, which is not what the recipe demonstrates. + if strings.Contains(html, `data-key="/internal/store"`) { + t.Error("collapsed directory rendered its children") + } +} + +// findNode locates a node by path or fails the test. +func findNode(t *testing.T, root *Node, path string) *Node { + t.Helper() + var found *Node + var walk func(*Node) + walk = func(n *Node) { + if n.Path == path { + found = n + return + } + for i := range n.Children { + walk(&n.Children[i]) + } + } + walk(root) + if found == nil { + t.Fatalf("node %s not found", path) + } + return found +} + +func mustStar(t *testing.T, c *Controller, s State, path string) State { + t.Helper() + s, err := c.Star(s, ctxWithValue(path)) + if err != nil { + t.Fatalf("Star %s: %v", path, err) + } + return s +} + +// --------------------------------------------------------------------------- +// Browser E2E. The logic tests above prove the tree walk; this proves the +// recipe actually works in a browser against the real published client. +// +// It exists mainly to catch one specific silent failure. A full HTML document +// whose recursive {{define}} blocks fail to resolve does not error — the +// initial tree build falls back to HTML-string diffing, and the page still +// renders. It just stops updating reactively. So rendering correctly proves +// nothing on its own; the server log assertion is what makes this a real gate. +// --------------------------------------------------------------------------- + +// fallbackWarning is the log line livetemplate emits when the reactive tree +// build fails and it degrades to HTML-string diffing. +const fallbackWarning = "falling back to HTML structure-based tree" + +func TestFileTreeE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Concurrency-safe server-log capture. Do NOT add t.Parallel() — global + // log capture is incompatible with it. + serverLogs := e2etest.NewSafeBuffer() + log.SetOutput(serverLogs) + defer log.SetOutput(os.Stderr) + + port, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("free port: %v", err) + } + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: Handler(livetemplate.WithDevMode(true)), + } + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Server error: %v", err) + } + }() + e2etest.WaitForServer(t, fmt.Sprintf("http://localhost:%d", port), 10*time.Second) + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + t.Logf("server shutdown warning: %v", err) + } + }() + + chromeCtx, cleanup := e2etest.SetupDockerChrome(t, 30*time.Second) + defer cleanup() + ctx := chromeCtx.Context + + consoleLog := e2etest.NewConsoleLogger() + consoleLog.Start(ctx) + wsLog := e2etest.RecordWSFrames(ctx) + + dumpDiagnostics := func(label string) { + t.Logf("=== %s ===", label) + for _, l := range consoleLog.GetLogs() { + t.Logf("console [%s]: %s", l.Type, l.Message) + } + for _, m := range wsLog.GetMessages() { + t.Logf("ws %s: %s", m.Direction, m.Data) + } + t.Logf("server log:\n%s", serverLogs.String()) + var html string + if err := chromedp.Run(ctx, chromedp.OuterHTML(`html`, &html, chromedp.ByQuery)); err != nil { + t.Logf("outerHTML: %v", err) + } else { + t.Logf("rendered HTML:\n%s", html) + } + } + + url := e2etest.GetChromeTestURL(port) + + // Phase 1 — first load must render the recursion all the way down. + var deepPresent bool + if err := chromedp.Run(ctx, + chromedp.Navigate(url), + chromedp.WaitVisible(`li[data-key="/"]`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('li[data-key="/cmd/server/main.go"]') !== null`, &deepPresent), + ); err != nil { + dumpDiagnostics("initial load failed") + t.Fatalf("chromedp.Run (initial load): %v", err) + } + if !deepPresent { + dumpDiagnostics("recursion did not reach depth on first load") + t.Fatal("expected /cmd/server/main.go to render on first load") + } + + // Phase 2 — expanding a collapsed directory must arrive over the socket, + // revealing children that were not in the initial HTML at all. + wsBefore := len(wsLog.GetMessages()) + var childVisible, singleNavigation bool + if err := chromedp.Run(ctx, + chromedp.Click(`button[name="toggle"][value="/internal"]`, chromedp.ByQuery), + chromedp.WaitVisible(`li[data-key="/internal/store"]`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('li[data-key="/internal/store"]') !== null`, &childVisible), + chromedp.Evaluate(`window.performance.getEntriesByType('navigation').length === 1`, &singleNavigation), + ); err != nil { + dumpDiagnostics("expand failed") + t.Fatalf("chromedp.Run (expand /internal): %v", err) + } + if !childVisible { + dumpDiagnostics("expand did not reveal children") + t.Error("expected /internal/store to appear after expanding /internal") + } + if !singleNavigation { + dumpDiagnostics("page navigated instead of updating over the socket") + t.Error("expand caused a full navigation; the update must arrive over the WebSocket") + } + if len(wsLog.GetMessages()) == wsBefore { + dumpDiagnostics("no WebSocket traffic on expand") + t.Error("expand produced no WebSocket frames") + } + + // Phase 3 — starring a leaf four levels down must update that leaf without + // disturbing its siblings. Marking a sibling from the browser first gives + // us a DOM-identity witness: if the branch were rebuilt rather than patched + // per-leaf, the marker would not survive. + if err := chromedp.Run(ctx, + chromedp.Click(`button[name="toggle"][value="/internal/store"]`, chromedp.ByQuery), + chromedp.WaitVisible(`li[data-key="/internal/store/sql"]`, chromedp.ByQuery), + chromedp.Click(`button[name="toggle"][value="/internal/store/sql"]`, chromedp.ByQuery), + chromedp.WaitVisible(`li[data-key="`+deepFile+`"]`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('li[data-key="/internal/store/sql/migrate.go"]').__lvtMark = 'sibling'; true`, nil), + ); err != nil { + dumpDiagnostics("drilling to the deep leaf failed") + t.Fatalf("chromedp.Run (drill down): %v", err) + } + + var starred, siblingSurvived bool + if err := chromedp.Run(ctx, + chromedp.Click(`button[name="star"][value="`+deepFile+`"]`, chromedp.ByQuery), + chromedp.WaitVisible(`li[data-key="`+deepFile+`"] button[name="star"]`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('li[data-key="`+deepFile+`"] button[name="star"]').textContent.includes('★')`, &starred), + chromedp.Evaluate(`document.querySelector('li[data-key="/internal/store/sql/migrate.go"]').__lvtMark === 'sibling'`, &siblingSurvived), + ); err != nil { + dumpDiagnostics("star failed") + t.Fatalf("chromedp.Run (star deep leaf): %v", err) + } + if !starred { + dumpDiagnostics("star did not take effect") + t.Errorf("expected %s to render as starred", deepFile) + } + if !siblingSurvived { + dumpDiagnostics("sibling DOM node was replaced") + t.Error("starring a leaf replaced its sibling's DOM node; the update was not per-leaf") + } + + // Prove the capture works before trusting its silence. If log.SetOutput + // ever stops reaching livetemplate's logger, serverLogs goes empty and the + // fallback check below passes for the wrong reason — a green test with no + // gate behind it. A connected session always logs, so empty means broken. + if serverLogs.String() == "" { + t.Fatal("captured no server logs; the fallback assertion below would pass vacuously") + } + + // The gate: a recursive full-HTML document that silently degrades still + // renders and still updates via HTML-string diffing, so every assertion + // above can pass while the reactive path is dead. This is what catches it. + if logs := serverLogs.String(); strings.Contains(logs, fallbackWarning) { + dumpDiagnostics("reactive path degraded") + t.Errorf("server fell back to HTML-string diffing; the recursive region is not reactive:\n%s", logs) + } + + for _, l := range consoleLog.GetLogs() { + if l.Type == "error" { + dumpDiagnostics("console error") + t.Errorf("browser console error: %s", l.Message) + } + } +} diff --git a/examples/file-tree/filetree.go b/examples/file-tree/filetree.go new file mode 100644 index 0000000..11fe6b9 --- /dev/null +++ b/examples/file-tree/filetree.go @@ -0,0 +1,113 @@ +package filetree + +import "github.com/livetemplate/livetemplate" + +// Node is one entry in the tree. A Node contains Nodes, which is what makes +// the template recursive: file-tree.tmpl defines a "node" block that invokes +// itself for each child, to whatever depth the data happens to go. +// +// Path is the stable identity used as data-key. Sibling names can repeat +// across directories (two different README.md files), so keying on Name would +// let the diff engine confuse them; the full path cannot collide. +type Node struct { + Path string + Name string + IsDir bool + Expanded bool + Starred bool + Children []Node +} + +// State is per-session state — pure data, cloned per session. Each visitor +// expands and stars independently. +type State struct { + Root Node +} + +// Controller holds shared dependencies (none here) and exposes the action +// methods the template invokes by button name. +type Controller struct{} + +// Mount seeds the tree on first render. Returning fresh data here rather than +// storing it on the Controller keeps the Controller free of per-session state. +func (c *Controller) Mount(s State, ctx *livetemplate.Context) (State, error) { + if s.Root.Path == "" { + s.Root = sampleTree() + } + return s, nil +} + +// Toggle expands or collapses one directory. The button carries the node's +// path as its value, so the action knows which of the many rendered nodes was +// clicked without any per-node wiring. +func (c *Controller) Toggle(s State, ctx *livetemplate.Context) (State, error) { + path := ctx.GetString("value") + updateNode(&s.Root, path, func(n *Node) { n.Expanded = !n.Expanded }) + return s, nil +} + +// Star marks a single file. This is the interesting one for the wire format: +// starring a leaf five levels down changes exactly one node, and the update +// sent to the browser is a nested ["u", key, ...] chain addressing that leaf — +// not a re-send of the branch containing it. +func (c *Controller) Star(s State, ctx *livetemplate.Context) (State, error) { + path := ctx.GetString("value") + updateNode(&s.Root, path, func(n *Node) { n.Starred = !n.Starred }) + return s, nil +} + +// updateNode walks to the node with the given path and applies fn. It reports +// whether it found one, which lets the recursion stop at the first match. +func updateNode(n *Node, path string, fn func(*Node)) bool { + if n.Path == path { + fn(n) + return true + } + for i := range n.Children { + if updateNode(&n.Children[i], path, fn) { + return true + } + } + return false +} + +// sampleTree is a fixed fixture so the recipe renders the same tree for +// everyone. Depth matters more than breadth here — the deepest file exists to +// show that a change down there stays scoped to that leaf. +func sampleTree() Node { + return Node{ + Path: "/", Name: "project", IsDir: true, Expanded: true, + Children: []Node{ + { + Path: "/cmd", Name: "cmd", IsDir: true, Expanded: true, + Children: []Node{ + { + Path: "/cmd/server", Name: "server", IsDir: true, Expanded: true, + Children: []Node{ + {Path: "/cmd/server/main.go", Name: "main.go"}, + }, + }, + }, + }, + { + Path: "/internal", Name: "internal", IsDir: true, + Children: []Node{ + { + Path: "/internal/store", Name: "store", IsDir: true, + Children: []Node{ + { + Path: "/internal/store/sql", Name: "sql", IsDir: true, + Children: []Node{ + {Path: "/internal/store/sql/query.go", Name: "query.go"}, + {Path: "/internal/store/sql/migrate.go", Name: "migrate.go"}, + }, + }, + }, + }, + {Path: "/internal/auth.go", Name: "auth.go"}, + }, + }, + {Path: "/README.md", Name: "README.md"}, + }, + } +} diff --git a/examples/file-tree/handler.go b/examples/file-tree/handler.go new file mode 100644 index 0000000..542cb57 --- /dev/null +++ b/examples/file-tree/handler.go @@ -0,0 +1,36 @@ +// Package filetree is the recursive-template recipe deployable — a directory +// tree rendered by a template that invokes itself, which is the shape +// {{template}} inlining cannot express and runtime invocation can (livetemplate +// v0.19.0 and later). Handler returns the mountable http.Handler; cmd/main.go +// wraps it in a standalone listener. The docs-site cmd/site aggregator mounts +// the same Handler at /apps/file-tree/ in the docs container. +package filetree + +import ( + "embed" + "net/http" + + "github.com/livetemplate/livetemplate" +) + +//go:embed file-tree.tmpl +var templateFS embed.FS + +// Handler returns the file-tree app as an http.Handler ready to mount. +// AnonymousAuthenticator gives each browser its own session group, so one +// visitor expanding a folder does not move anyone else's tree. Callers supply +// environment-specific options (origin allowlists, dev mode) via opts — the +// recipe itself stays origin-agnostic so cmd/site can pass production hosts +// and cmd/main.go can pass localhost-permissive settings under --dev. +// +// No depth option is set: the fixture is a handful of levels deep and the +// default cap is 128. WithMaxTemplateDepth matters when recursion runs over +// user-supplied data, where a cycle in the data is possible. +func Handler(opts ...livetemplate.Option) http.Handler { + baseOpts := []livetemplate.Option{ + livetemplate.WithParseFS(templateFS, "file-tree.tmpl"), + livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}), + } + tmpl := livetemplate.Must(livetemplate.New("file-tree", append(baseOpts, opts...)...)) + return tmpl.Handle(&Controller{}, livetemplate.AsState(&State{})) +} diff --git a/go.mod b/go.mod index 7cafc9f..dc53687 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,9 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/go-playground/validator/v10 v10.30.2 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.18.0 - github.com/livetemplate/lvt v0.1.6 - github.com/livetemplate/lvt/components v0.1.2 + github.com/livetemplate/livetemplate v0.19.0 + github.com/livetemplate/lvt v0.2.0 + github.com/livetemplate/lvt/components v0.1.8 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.43.0 ) diff --git a/go.sum b/go.sum index 5940fe2..4622a2e 100644 --- a/go.sum +++ b/go.sum @@ -97,12 +97,12 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/livetemplate/livetemplate v0.18.0 h1:BlSLBEo+gcurO3c5z4tLPJBXRx4KcGmHA46O0r0HXIA= -github.com/livetemplate/livetemplate v0.18.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ= -github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4= -github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw= -github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag= -github.com/livetemplate/lvt/components v0.1.2/go.mod h1:G9PElN3LRf8xoRtoxbOAcTkV/4FhrCE/Laczkz5bfL4= +github.com/livetemplate/livetemplate v0.19.0 h1:tLuF6vqDS5FE7ggwMjie6hmxLY2C94glPl2nAqFceNo= +github.com/livetemplate/livetemplate v0.19.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ= +github.com/livetemplate/lvt v0.2.0 h1:BQkZHKwFIACeq//L92cxjWdF7c4b5qjl9fxHnvbgQ6U= +github.com/livetemplate/lvt v0.2.0/go.mod h1:JPz+9CHGq4nTHVNH+rQAZxEapL9F+m9zyhzJJ8pYbzQ= +github.com/livetemplate/lvt/components v0.1.8 h1:9LNwmhrcoqxtnUQDYC1+sh2X9m9Yoqay0tDs8qoj9Xc= +github.com/livetemplate/lvt/components v0.1.8/go.mod h1:9sviNzKFEOLBong7f0Ah9GvBZiIUtwBy3M2uJRscB3E= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=