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
11 changes: 11 additions & 0 deletions cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/livetemplate/docs/examples/counter"
counterbasic "github.com/livetemplate/docs/examples/counter-basic"
draftform "github.com/livetemplate/docs/examples/draft-form"
filetree "github.com/livetemplate/docs/examples/file-tree"
"github.com/livetemplate/docs/examples/greet"
greetloading "github.com/livetemplate/docs/examples/greet-loading"
greetloadingserver "github.com/livetemplate/docs/examples/greet-loading-server"
Expand Down Expand Up @@ -94,6 +95,16 @@ func main() {
livetemplate.WithAllowedOrigins(allowedOrigins),
)))

// file-tree is the recursive-template recipe: a directory tree whose
// template invokes itself for each child. This is the shape {{template}}
// inlining cannot express — livetemplate leaves the self-referential call
// un-inlined and evaluates it at build time (v0.19.0+). Starring a file
// several levels down scopes the update to that leaf rather than re-sending
// the branch, which is what makes deep trees practical over the wire.
mux.Handle("/apps/file-tree/", http.StripPrefix("/apps/file-tree", filetree.Handler(
livetemplate.WithAllowedOrigins(allowedOrigins),
)))

// live-dashboard is the out-of-band fan-out recipe: one process-wide
// background goroutine calls handler.Publish("dashboard", "Refresh", nil)
// and every connected browser re-renders — no per-tab timer, no Session
Expand Down
98 changes: 98 additions & 0 deletions content/recipes/apps/file-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: "File Tree"
description: "A directory tree rendered by a template that invokes itself — the recursive shape template inlining cannot express, with updates that scope to a single leaf instead of re-sending the branch."
source_repo: "https://github.com/livetemplate/docs"
source_path: "content/recipes/apps/file-tree.md"
---

# File Tree — a template that calls itself

A directory contains directories. Rendering one means rendering its children
the same way, to whatever depth the data happens to go — and you don't know
that depth when you write the template.

Go's `html/template` handles this natively: a `{{define}}` block can invoke
itself. LiveTemplate could not, until v0.19.0, and the reason is worth
understanding because it explains what changed. The full source is
[`examples/file-tree/`](https://github.com/livetemplate/docs/tree/main/examples/file-tree).

## Try it

```embed-lvt path="/apps/file-tree/" upstream="http://localhost:9091" height="520px"
```

Expand a folder or star a file. Every control above is a plain
`<button name="...">` inside a form — no custom attributes, no hand-written
JavaScript.

## The template

The whole recipe is one self-referential block. `node` renders an entry, and
for a directory it loops over the children invoking `node` again:

```html
{{define "node"}}
<li data-key="{{.Path}}">
{{if .IsDir}}
<form method="POST" style="display:inline">
<button name="toggle" value="{{.Path}}">{{if .Expanded}}▾{{else}}▸{{end}} {{.Name}}/</button>
</form>
{{if .Expanded}}
<ul>
{{range .Children}}{{template "node" .}}{{end}}
</ul>
{{end}}
{{else}}
<span class="file">
<form method="POST" style="display:inline">
<button name="star" value="{{.Path}}">{{if .Starred}}★{{else}}☆{{end}}</button>
</form>
{{.Name}}
</span>
{{end}}
</li>
{{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 `<li>` 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.
1 change: 1 addition & 0 deletions content/recipes/apps/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions content/tinkerdown.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
59 changes: 59 additions & 0 deletions examples/file-tree/cmd/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
50 changes: 50 additions & 0 deletions examples/file-tree/file-tree.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>File Tree</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="{{lvtClientStyleURL}}">
<script defer src="{{lvtClientScriptURL}}"></script>
<style>
.tree, .tree ul { list-style: none; margin: 0; padding-left: 1.25rem; }
.tree > li { padding-left: 0; }
.tree button { padding: 0 .35rem; margin: 0; border: none; background: none; }
.tree .file { display: inline-flex; gap: .35rem; align-items: center; }
</style>
</head>
<body>
<main class="container">
<h1>File tree</h1>
<p>A directory contains directories, so the template that renders one
invokes <em>itself</em> for each child. Expand a folder or star a file
and only that node's markup is sent over the wire.</p>

<ul class="tree">
{{template "node" .Root}}
</ul>
</main>
</body>
</html>

{{define "node"}}
<li data-key="{{.Path}}">
{{if .IsDir}}
<form method="POST" style="display:inline">
<button name="toggle" value="{{.Path}}">{{if .Expanded}}▾{{else}}▸{{end}} {{.Name}}/</button>
</form>
{{if .Expanded}}
<ul>
{{range .Children}}{{template "node" .}}{{end}}
</ul>
{{end}}
{{else}}
<span class="file">
<form method="POST" style="display:inline">
<button name="star" value="{{.Path}}">{{if .Starred}}★{{else}}☆{{end}}</button>
</form>
{{.Name}}
</span>
{{end}}
</li>
{{end}}
Loading
Loading