diff --git a/cmd/site/main.go b/cmd/site/main.go index f5a843e..d25d146 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -102,11 +102,11 @@ func main() { ))) // shared-notepad — per-user state map + explicit peer refresh via - // ctx.BroadcastAction("Refresh", nil). The recipe TEACHES BasicAuth + // ctx.Publish(ctx.SelfTopic(), "Refresh", nil). The recipe TEACHES BasicAuth // (the e2e suite + examples/shared-notepad use notepad.NewDemoBasicAuth); // the embed here uses AnonymousAuthenticator because tinkerdown's // embed-lvt server-side prefetch can't forward Authorization headers. - // Same-browser tabs share the cookie, so the BroadcastAction + // Same-browser tabs share the cookie, so the Publish-to-SelfTopic // multi-tab refresh story still works in the embed; cross-browser // users get different identities for the isolation demo. mux.Handle("/apps/shared-notepad/", http.StripPrefix("/apps/shared-notepad", notepad.Handler( diff --git a/content/recipes/counter/_app/counter.go b/content/recipes/counter/_app/counter.go index 0e3ab5f..a27b19c 100644 --- a/content/recipes/counter/_app/counter.go +++ b/content/recipes/counter/_app/counter.go @@ -3,8 +3,8 @@ package counter import "github.com/livetemplate/livetemplate" // CounterState is per-session state — pure data, cloned per session by -// livetemplate. AnonymousAuthenticator (handler.go) keeps state private -// per browser; BroadcastAction (below) keeps a single user's tabs in +// livetemplate. AnonymousAuthenticator (handler.go) keeps state private per +// browser; Publish to SelfTopic() (below) keeps a single user's tabs in // sync without leaking state to other visitors. type CounterState struct { Counter int @@ -14,20 +14,32 @@ type CounterState struct { // exposes action methods invoked by name from the template. type CounterController struct{} -// Increment is invoked when the user clicks the "+1" button. The -// runtime calls it with a clone of the current state and stores -// whatever you return. The BroadcastAction call tells the runtime -// to apply this same action on every other connected client, so -// multiple embeds and tabs stay in lockstep. +// Mount subscribes the self-topic so peer tabs of the same session receive +// the Increment / Decrement dispatches Publish'd from the actions below. +func (c *CounterController) Mount(s CounterState, ctx *livetemplate.Context) (CounterState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return s, err + } + return s, nil +} + +// Increment is invoked when the user clicks the "+1" button. The runtime +// calls it with a clone of the current state and stores whatever you return. +// The Publish call tells peer tabs subscribed to the same SelfTopic() to run +// Increment too, keeping multiple embeds and tabs in lockstep. func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) { s.Counter++ - ctx.BroadcastAction("Increment", nil) + if err := ctx.Publish(ctx.SelfTopic(), "Increment", nil); err != nil { + return s, err + } return s, nil } // Decrement follows the same pattern. func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) { s.Counter-- - ctx.BroadcastAction("Decrement", nil) + if err := ctx.Publish(ctx.SelfTopic(), "Decrement", nil); err != nil { + return s, err + } return s, nil } diff --git a/content/recipes/patterns/_app/data.go b/content/recipes/patterns/_app/data.go index 26251e3..9861fae 100644 --- a/content/recipes/patterns/_app/data.go +++ b/content/recipes/patterns/_app/data.go @@ -345,8 +345,8 @@ func allPatterns() []PatternCategory { { Name: "Real-Time & Multi-User", Patterns: []PatternLink{ - {Name: "Multi-User Refresh", Path: "/patterns/realtime/multi-user-sync", Description: "Explicit peer refresh via BroadcastAction", Implemented: true}, - {Name: "Broadcasting", Path: "/patterns/realtime/broadcasting", Description: "Cross-connection updates via BroadcastAction", Implemented: true}, + {Name: "Multi-User Refresh", Path: "/patterns/realtime/multi-user-sync", Description: "Explicit peer refresh via Publish to SelfTopic()", Implemented: true}, + {Name: "Broadcasting", Path: "/patterns/realtime/broadcasting", Description: "Cross-connection updates via Publish to SelfTopic()", Implemented: true}, {Name: "Presence Tracking", Path: "/patterns/realtime/presence", Description: "Explicit join/leave with shared state", Implemented: true}, {Name: "Reconnection Recovery", Path: "/patterns/realtime/reconnection", Description: "State persistence across disconnects", Implemented: true}, {Name: "Live Preview", Path: "/patterns/realtime/live-preview", Description: "Real-time input preview via Change()", Implemented: true}, diff --git a/content/recipes/patterns/_app/handlers_lists.go b/content/recipes/patterns/_app/handlers_lists.go index b1d8b57..f1728bf 100644 --- a/content/recipes/patterns/_app/handlers_lists.go +++ b/content/recipes/patterns/_app/handlers_lists.go @@ -170,7 +170,7 @@ func infiniteScrollHandler() http.Handler { // --- Sortable List --- -// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need BroadcastAction). +// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need Publish to SelfTopic()). type SortableController struct { mu sync.Mutex items []SortableItem diff --git a/content/recipes/patterns/_app/handlers_realtime.go b/content/recipes/patterns/_app/handlers_realtime.go index 70e8cde..89fa364 100644 --- a/content/recipes/patterns/_app/handlers_realtime.go +++ b/content/recipes/patterns/_app/handlers_realtime.go @@ -17,19 +17,24 @@ type MultiUserSyncController struct { counter int } -// Mount runs on every initial render. Without it, a tab that opens -// AFTER other tabs have incremented would render Counter:0 and only -// converge on the next peer refresh. Same fix as PresenceController. +// Mount runs on every initial render. Subscribing the self-topic wires this +// connection to receive RefreshCounter from peer Publishes. Without the +// initial-render counter read, a tab that opens AFTER other tabs have +// incremented would render Counter:0 and only converge on the next peer +// publish. Same fix as PresenceController. func (c *MultiUserSyncController) Mount(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } c.mu.RLock() state.Counter = c.counter c.mu.RUnlock() return state, nil } -// RefreshCounter is explicitly broadcast to peer connections after Increment. -// The state arg is the peer's local state; we replace its Counter from the -// shared controller value so all tabs converge. +// RefreshCounter is the action peer connections run when Increment publishes +// to SelfTopic(). The state arg is the peer's local state; we replace its +// Counter from the shared controller value so all tabs converge. func (c *MultiUserSyncController) RefreshCounter(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) { c.mu.RLock() state.Counter = c.counter @@ -42,7 +47,9 @@ func (c *MultiUserSyncController) Increment(state MultiUserSyncState, ctx *livet c.counter++ state.Counter = c.counter c.mu.Unlock() - ctx.BroadcastAction("RefreshCounter", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil); err != nil { + return state, err + } return state, nil } @@ -71,6 +78,9 @@ func (c *BroadcastingController) snapshotLocked() []BroadcastMessage { } func (c *BroadcastingController) Mount(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } c.mu.RLock() state.Messages = c.snapshotLocked() c.mu.RUnlock() @@ -100,16 +110,18 @@ func (c *BroadcastingController) Send(state BroadcastingState, ctx *livetemplate c.mu.Lock() c.nextID++ // No cap on c.messages: deliberately omitted to keep the demo focused - // on the BroadcastAction mechanism. Production apps would ring-buffer, - // paginate, or persist to a store with TTL. + // on the Publish-to-SelfTopic mechanism. Production apps would + // ring-buffer, paginate, or persist to a store with TTL. c.messages = append(c.messages, BroadcastMessage{ID: c.nextID, User: state.Username, Text: text}) state.Messages = c.snapshotLocked() c.mu.Unlock() - // BroadcastAction must come after the lock release — holding the - // connection registry mutex while queuing broadcasts can deadlock with - // peer dispatches that take the same mutex from the other side. Peers + // Publish must come after the lock release — holding the connection + // registry mutex while queuing peer dispatches can deadlock with peer + // dispatches that take the same mutex from the other side. Peers // receive "NewMessage" and refresh their local copy. - ctx.BroadcastAction("NewMessage", nil) + if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil { + return state, err + } return state, nil } @@ -144,11 +156,15 @@ func newPresenceController() *PresenceController { return &PresenceController{onlineUsers: make(map[string]bool)} } -// Mount runs on every initial render. Without it a new visitor's -// state.OnlineCount would default to 0 even when other users are -// already in the shared map — they'd see "0 user(s) online" until -// the next Join/Leave broadcast updates them. +// Mount runs on every initial render. Subscribing the self-topic wires this +// connection to receive PresenceChanged from peer Publishes. Without the +// initial-render OnlineCount read, a new visitor's state.OnlineCount would +// default to 0 even when other users are already in the shared map — they'd +// see "0 user(s) online" until the next Join/Leave publish updates them. func (c *PresenceController) Mount(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } c.mu.RLock() state.OnlineCount = len(c.onlineUsers) c.mu.RUnlock() @@ -166,7 +182,9 @@ func (c *PresenceController) Join(state PresenceState, ctx *livetemplate.Context state.Joined = true state.OnlineCount = len(c.onlineUsers) c.mu.Unlock() - ctx.BroadcastAction("PresenceChanged", nil) + if err := ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil); err != nil { + return state, err + } return state, nil } @@ -180,13 +198,15 @@ func (c *PresenceController) Leave(state PresenceState, ctx *livetemplate.Contex state.Joined = false state.OnlineCount = len(c.onlineUsers) c.mu.Unlock() - ctx.BroadcastAction("PresenceChanged", nil) + if err := ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil); err != nil { + return state, err + } return state, nil } // PresenceChanged refreshes only the shared OnlineCount. Username and // Joined are per-connection identity and must NOT be overwritten from a -// peer broadcast — every connection's own Join/Leave is the only thing +// peer publish — every connection's own Join/Leave is the only thing // that mutates those fields locally. func (c *PresenceController) PresenceChanged(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) { c.mu.RLock() diff --git a/content/recipes/shared-notepad/_app/controller.go b/content/recipes/shared-notepad/_app/controller.go index decaa7d..bf480b1 100644 --- a/content/recipes/shared-notepad/_app/controller.go +++ b/content/recipes/shared-notepad/_app/controller.go @@ -33,9 +33,14 @@ type NotepadState struct { // >>> region:mount // Mount runs on every fresh state (page load, reconnect with stale -// state). It binds Username to the authenticated user and rehydrates -// the textarea from the controller's per-user map. +// state). It subscribes the self-topic so peer tabs of the same user +// receive the Refresh dispatch from Save's Publish below, binds Username +// to the authenticated user, and rehydrates the textarea from the +// controller's per-user map. func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } state.Username = ctx.UserID() c.mu.RLock() if saved, ok := c.notes[ctx.UserID()]; ok { @@ -50,10 +55,10 @@ func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) // <<< region:mount // >>> region:save -// Save writes the textarea content into the per-user map and broadcasts -// a "Refresh" action to peer connections in the same session group -// (other tabs of the same user). The framework drains the broadcast -// queue after this action's response is sent. +// Save writes the textarea content into the per-user map and Publishes a +// "Refresh" action to peer connections subscribed to SelfTopic() (other +// tabs of the same user). The framework drains the publish queue after +// this action's response is sent. func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { state.Content = ctx.GetString("content") state.CharCount = utf8.RuneCountInString(state.Content) @@ -63,7 +68,14 @@ func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) c.notes[ctx.UserID()] = state c.mu.Unlock() - ctx.BroadcastAction("Refresh", nil) + // Propagate Publish's error rather than log-and-swallow: the only errors + // it can return are programmer errors (empty SelfTopic from a + // misconfigured Authenticator, or the per-action publish cap exceeded). + // Surfacing them loudly is a feature. Same pattern in every recipe app + // that Publishes to SelfTopic(). + if err := ctx.Publish(ctx.SelfTopic(), "Refresh", nil); err != nil { + return state, err + } return state, nil } @@ -81,11 +93,11 @@ func (c *NotepadController) Change(state NotepadState, ctx *livetemplate.Context } // >>> region:refresh -// Refresh is the action peer tabs run when Save broadcasts. It re-reads +// Refresh is the action peer tabs run when Save publishes. It re-reads // the latest state from the per-user map. Note this is a regular // controller action, not a framework-reserved name — pre-v0.9.0 the // framework auto-dispatched a Sync() method; that was removed in -// livetemplate#406 in favour of explicit BroadcastAction("Refresh", nil) +// livetemplate#406 in favour of explicit Publish-to-SelfTopic() calls // for clearer control over when peers actually refresh. func (c *NotepadController) Refresh(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { c.mu.RLock() diff --git a/content/recipes/shared-notepad/_app/handler.go b/content/recipes/shared-notepad/_app/handler.go index 1b9facc..6ccb0c0 100644 --- a/content/recipes/shared-notepad/_app/handler.go +++ b/content/recipes/shared-notepad/_app/handler.go @@ -10,10 +10,11 @@ // state under a sync.RWMutex. Mount + Refresh both read from it; // Save writes. // -// - ctx.BroadcastAction("Refresh", nil) is the explicit peer-refresh -// primitive. After Save commits, every peer connection in the same -// session group (other tabs of the same user) runs Refresh and -// re-reads from the map. +// - ctx.Publish(ctx.SelfTopic(), "Refresh", nil) is the explicit +// peer-refresh primitive. After Save commits, every peer connection +// subscribed to the same SelfTopic() (other tabs of the same user) +// runs Refresh and re-reads from the map. Mount establishes the +// Subscribe. // // There is no main() here. Production runs via the docs single-binary // container, mounted by cmd/site at /apps/shared-notepad/. The example @@ -77,9 +78,9 @@ func extractTemplate() string { // >>> region:basicauth // NewDemoBasicAuth returns the authenticator the recipe text teaches: // BasicAuth with password "demo", any username. The username becomes -// both ctx.UserID() (per-user state map key) and the session-group ID -// (BroadcastAction routing). This is the production-shaped wiring and -// what examples/shared-notepad + the e2e suite use. +// both ctx.UserID() (per-user state map key) and the SelfTopic() identity +// (Publish routing key — lvt:user:). This is the production-shaped +// wiring and what examples/shared-notepad + the e2e suite use. // // The docs-site mount (cmd/site) uses AnonymousAuthenticator instead // of this — see the Handler doc comment for the reason. @@ -105,7 +106,7 @@ func NewDemoBasicAuth() livetemplate.Authenticator { // extract the LiveTemplate wrapper, and that prefetch can't // forward Authorization headers — a BasicAuth mount would degrade // to "live demo unavailable" in the docs page. Same-browser tabs -// share the cookie, so the multi-tab BroadcastAction demo still +// share the cookie, so the multi-tab Publish-to-SelfTopic demo still // works; different browsers get different identities for isolation. func Handler(opts ...livetemplate.Option) http.Handler { controller := &NotepadController{ diff --git a/content/recipes/todos/_app/controller.go b/content/recipes/todos/_app/controller.go index 6e29b4e..7bdb5c0 100644 --- a/content/recipes/todos/_app/controller.go +++ b/content/recipes/todos/_app/controller.go @@ -17,6 +17,11 @@ type TodoController struct { } func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + // Subscribe self-topic so peer tabs of the same user receive the + // RefreshTodos dispatch from Publish calls in actions below. + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } state.Username = ctx.UserID() state = initComponents(state) return c.loadTodos(context.Background(), state, ctx.UserID()) @@ -60,7 +65,9 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } @@ -99,7 +106,9 @@ func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (Tod if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } @@ -134,7 +143,9 @@ func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } @@ -225,7 +236,9 @@ func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Conte if err != nil { return state, err } - ctx.BroadcastAction("RefreshTodos", nil) + if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil { + return state, err + } return state, nil } diff --git a/e2e/go.mod b/e2e/go.mod index bab9f1e..c3afd06 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -7,7 +7,7 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/gorilla/websocket v1.5.3 github.com/livetemplate/docs v0.0.0-00010101000000-000000000000 - github.com/livetemplate/livetemplate v0.9.0 + github.com/livetemplate/livetemplate v0.9.2 github.com/livetemplate/lvt v0.1.6 ) diff --git a/e2e/go.sum b/e2e/go.sum index 6fe9f3b..403c5f5 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -92,8 +92,8 @@ 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.9.0 h1:6vHNNpZLIY4gO08/Gth3vr1F3A3ANwSKTQvfuy1I8js= -github.com/livetemplate/livetemplate v0.9.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.9.2 h1:zF/mbhXxp5uLX3UBdgkdBk+Jmhfx3g5DAlUCisTXoZM= +github.com/livetemplate/livetemplate v0.9.2/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= 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= diff --git a/e2e/patterns/patterns_test.go b/e2e/patterns/patterns_test.go index 99ca7fd..f653347 100644 --- a/e2e/patterns/patterns_test.go +++ b/e2e/patterns/patterns_test.go @@ -3396,7 +3396,7 @@ func TestMultiUserSync(t *testing.T) { // chromedp.NewContext(parent) where parent is a chromedp context creates // a NEW TAB in the same browser. Cookies and storage are shared, so both // tabs land in the same session group — the prerequisite for - // BroadcastAction to dispatch to peers. + // Publish-to-SelfTopic to dispatch to peers. peerCtx, peerCancel := chromedp.NewContext(ctx) defer peerCancel() if err := chromedp.Run(peerCtx, @@ -3415,11 +3415,11 @@ func TestMultiUserSync(t *testing.T) { t.Fatalf("Tab 1 did not reflect Counter: 1: %v", err) } // Peer must see the same value via the explicit RefreshCounter - // broadcast issued by Increment. + // dispatch issued by Increment's Publish to SelfTopic(). if err := chromedp.Run(peerCtx, e2etest.WaitForText(`article`, "Counter: 1", 3*time.Second), ); err != nil { - t.Fatalf("Peer did not pick up Counter: 1 from BroadcastAction: %v", err) + t.Fatalf("Peer did not pick up Counter: 1 from Publish: %v", err) } }) diff --git a/e2e/shared-notepad/notepad_test.go b/e2e/shared-notepad/notepad_test.go index b71ac91..279716a 100644 --- a/e2e/shared-notepad/notepad_test.go +++ b/e2e/shared-notepad/notepad_test.go @@ -10,9 +10,9 @@ // beyond the original example's // single-user shape and asserts // the per-user state map. -// TestSharedNotepad_BroadcastRefreshAction — verifies the BroadcastAction -// queue dispatches the -// "Refresh" action. +// TestSharedNotepad_PublishRefreshAction — verifies the Publish-to-SelfTopic +// queue dispatches the +// "Refresh" action. package main import ( diff --git a/go.mod b/go.mod index ae5e58c..335afc8 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.1 require ( github.com/go-playground/validator/v10 v10.30.2 - github.com/livetemplate/livetemplate v0.9.0 + github.com/livetemplate/livetemplate v0.9.2 github.com/livetemplate/lvt v0.1.6 github.com/livetemplate/lvt/components v0.1.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index c84a854..94c9b05 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ 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.9.0 h1:6vHNNpZLIY4gO08/Gth3vr1F3A3ANwSKTQvfuy1I8js= -github.com/livetemplate/livetemplate v0.9.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.9.2 h1:zF/mbhXxp5uLX3UBdgkdBk+Jmhfx3g5DAlUCisTXoZM= +github.com/livetemplate/livetemplate v0.9.2/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= 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=